Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M .gitattributes
1818
*.exe binary
1919
*.gif binary
20
*.ico binary
2021
*.jar binary
2122
*.jpg binary
M BUILD.md
1010
* [Gradle 7.6-rc-1](https://services.gradle.org/distributions/gradle-7.6-rc-1-bin.zip)
1111
* [Git 2.38.1](https://git-scm.com/downloads)
12
* [warp v0.4.0-alpha](https://github.com/Reisz/warp/releases/tag/v0.4.0)
13
14
Note: The forked warp packer release fixes a bug in the main branch.
1215
1316
## Repository
M R/conversion.R
8484
# -----------------------------------------------------------------------------
8585
# Translates a number from digits to words using Chicago Manual of Style.
86
# This does not translate numbers greater than one hundred. If ordinal
86
# This will translate numbers greater than one by truncating to nearest
87
# thousandth, millionth, billionth, etc. regardless of oridinal. If ordinal
8788
# is TRUE, this will return the ordinal name. This will not produce ordinals
8889
# for numbers greater than 100.
90
#
91
# If scaled is TRUE, this will write large numbers as comma-separated values.
8992
# -----------------------------------------------------------------------------
90
cms <- function( n, ordinal = FALSE ) {
93
cms <- function( n, ordinal = FALSE, scaled = TRUE ) {
9194
  n <- x( n )
9295
...
103106
    result = "negative "
104107
    n = abs( n )
108
  }
109
110
  if( n > 999 && scaled ) {
111
    scales <- c(
112
      "thousand", "million", "billion", "trillion", "quadrillion",
113
      "quintillion", "sextillion", "septillion", "octillion", "nonillion",
114
      "decillion", "undecillion", "duodecillion", "tredecillion",
115
      "quattuordecillion", "quindecillion", "sexdecillion", "septendecillion",
116
      "octodecillion", "novemdecillion", "vigintillion", "centillion",
117
      "quadrillion", "quitillion", "sextillion"
118
    );
119
120
    d <- round( n / (10 ^ (log10( n ) - log10( n ) %% 3)) );
121
    n <- floor( log10( n ) ) / 3;
122
    return( paste( cms( d ), scales[ n ] ) );
105123
  }
106124
...
122140
  # Samuel Langhorne Clemens noted English has too many exceptions.
123141
  small = c(
124
    "one", "two", "three", "four", "five",
125
    "six", "seven", "eight", "nine", "ten",
126
    "eleven", "twelve", "thirteen", "fourteen", "fifteen",
127
    "sixteen", "seventeen", "eighteen", "nineteen"
142
    "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
143
    "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
144
    "seventeen", "eighteen", "nineteen"
128145
  )
129146
130147
  ord_small = c(
131
    "first", "second", "third", "fourth", "fifth",
132
    "sixth", "seventh", "eighth", "ninth", "tenth",
133
    "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth",
134
    "sixteenth", "seventeenth", "eighteenth", "nineteenth", "twentieth"
148
    "first", "second", "third", "fourth", "fifth", "sixth", "seventh",
149
    "eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth",
150
    "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth",
151
    "nineteenth", "twentieth"
135152
  )
136153
...
179196
  # Hyphenate the tens and the ones together.
180197
  concat( unit_10, concat( "-", unit_1 ) )
198
}
199
200
cms.big <- function( n ) {
181201
}
202
182203
183204
# -----------------------------------------------------------------------------
A R/numeric.R
1
# TODO: Finish the implementation
2
3
# -----------------------------------------------------------------------------
4
# Converts an integer value into English words. Negative numbers are prefixed
5
# with the word minus. This is useful for very large numbers.
6
#
7
# See https://english.stackexchange.com/a/111837/22099
8
#
9
# @param n Any integer value, including zero, and negative numbers.
10
# -----------------------------------------------------------------------------
11
to.words <- function( n ) {
12
  s <- 'zero';
13
14
  if( n > 0 ) {
15
    s <- to.words.nz( n );
16
  }
17
  else if( n < 0 ) {
18
    s <- paste0( 'minus ', to.words.nz( -n ) );
19
  }
20
21
  s
22
}
23
24
# -----------------------------------------------------------------------------
25
# Converts a non-zero number into English words.
26
# -----------------------------------------------------------------------------
27
to.words.nz <- function( n ) {
28
  scales <- c(
29
    "thousand", "million", "billion", "trillion", "quadrillion",
30
    "quintillion", "sextillion", "septillion", "octillion", "nonillion",
31
    "decillion", "undecillion", "duodecillion", "tredecillion",
32
    "quattuordecillion", "quindecillion", "sexdecillion", "septendecillion",
33
    "octodecillion", "novemdecillion", "vigintillion", "centillion",
34
    "quadrillion", "quitillion", "sextillion"
35
  );
36
37
  i <- 0;
38
  s <- "";
39
40
  while( n > 0 ) {
41
    if( !(n %% 1000 == 0) ) {
42
      j <- if( n < 100 ) "," else "";
43
      s <- paste( to.words.help( n %% 1000 ), scales[ i ], j, s );
44
    }
45
46
    n <- floor( n / 1000 );
47
    i <- i + 1;
48
  }
49
50
  s
51
}
52
53
to.words.help <- function( n ) {
54
  low <- c( 
55
    "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
56
    "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
57
    "seventeen", "eighteen", "nineteen"
58
  );
59
60
  tens <- c(
61
    "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"
62
  );
63
64
  if( n < 20 ) {
65
    s <- low[ n ];
66
  }
67
  else if( n < 100 ) {
68
    d <- n %% 10;
69
    j <- if( d > 0 ) "-" else "";
70
    s <- paste0( tens[ (n / 10) - 1 ], j, to.words.help( d ) );
71
  }
72
  else {
73
    d <- (n / 100);
74
    r <- (n %% 100);
75
    j <- if( r > 0 ) "and" else "";
76
    s <- paste( low[ d ], "hundred", j, to.words.help( r ) );
77
  }
78
79
  s
80
}
81
182
M README.md
1
![Total Downloads](https://img.shields.io/github/downloads/DaveJarvis/keenwrite/total?color=blue&label=Total%20Downloads&style=flat) ![Release Downloads](https://img.shields.io/github/downloads/DaveJarvis/keenwrite/latest/total?color=purple&label=Release%20Downloads&style=flat) ![Release Date](https://img.shields.io/github/release-date/DaveJarvis/keenwrite?color=red&style=flat&label=Release%20Date) ![Release Version](https://img.shields.io/github/v/release/DaveJarvis/keenwrite?style=flat&label=Release)
1
![Total Downloads](https://img.shields.io/github/downloads/DaveJarvis/keenwrite/total?color=blue&label=Total%20Downloads&style=flat) ![Release Downloads](https://img.shields.io/github/downloads/DaveJarvis/keenwrite/latest/total?color=purple&label=Release%20Downloads&style=flat) ![Released](https://img.shields.io/github/release-date/DaveJarvis/keenwrite?color=red&style=flat&label=Released) ![Version](https://img.shields.io/github/v/release/DaveJarvis/keenwrite?style=flat&label=Release)
22
33
# ![Logo](docs/images/app-title.png)
44
5
A text editor that uses [interpolated strings](https://en.wikipedia.org/wiki/String_interpolation) to reference values defined externally.
5
A free, open-source, cross-platform desktop Markdown editor that can produce beautifully typeset PDFs.
66
77
## Download
...
1818
1919
### Windows
20
21
When upgrading to a new version, delete the following directory:
22
23
    C:\Users\%USERNAME%\AppData\Local\warp\packages\keenwrite.exe
2420
2521
Double-click the application to start; give the application permission to run.
M build.gradle
11
import static org.gradle.api.JavaVersion.*
22
3
buildscript {
4
  repositories {
5
    mavenCentral()
6
  }
7
  dependencies {
8
    classpath 'org.owasp:dependency-check-gradle:7.4.3'
9
  }
10
}
11
312
plugins {
413
  id 'application'
514
  id 'org.openjfx.javafxplugin' version '0.0.13'
615
  id 'com.palantir.git-version' version '0.15.0'
716
}
17
18
apply plugin: 'org.owasp.dependencycheck'
819
920
repositories {
...
3849
3950
def moduleSecurity = [
51
    '--add-opens=javafx.graphics/javafx.scene=ALL-UNNAMED',
4052
    '--add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED',
4153
    '--add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED',
...
5466
javafx {
5567
  version = '19'
56
  modules = ['javafx.controls', 'javafx.swing']
68
  modules = ['javafx.base', 'javafx.controls', 'javafx.graphics', 'javafx.swing']
5769
  configuration = 'compileOnly'
5870
}
5971
6072
dependencies {
61
  def v_junit = '5.9.1'
73
  def v_junit = '5.9.2'
6274
  def v_flexmark = '0.64.0'
6375
  def v_jackson = '2.14.0'
64
  def v_echosvg = '0.2.1'
76
  def v_echosvg = '0.2.2'
6577
  def v_picocli = '4.7.0'
6678
6779
  // JavaFX
6880
  implementation 'org.controlsfx:controlsfx:11.1.2'
6981
  implementation 'org.fxmisc.richtext:richtextfx:0.11.0'
7082
  implementation 'org.fxmisc.flowless:flowless:0.7.0'
7183
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
7284
  implementation 'com.miglayout:miglayout-javafx:11.0'
7385
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.11.0'
86
  implementation 'com.panemu:tiwulfx-dock:0.2'
7487
7588
  // Markdown
...
8699
  implementation "com.fasterxml.jackson.core:jackson-annotations:${v_jackson}"
87100
  implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${v_jackson}"
88
  implementation 'org.yaml:snakeyaml:1.33'
89101
90102
  // HTML parsing and rendering
...
158170
159171
compileJava {
160
  options.compilerArgs
161
      << '-Xlint:unchecked'
162
      << '-Xlint:deprecation'
163
      << "-Aproject=${applicationPackage}/${applicationName}"
172
  options.compilerArgs += [
173
      "-Xlint:unchecked",
174
      "-Xlint:deprecation",
175
      "-Aproject=${applicationPackage}/${applicationName}"
176
  ]
164177
}
165178
...
195208
distributions {
196209
  main {
197
    distributionBaseName = applicationName
210
    distributionBaseName.set( applicationName )
211
198212
    contents {
199213
      from { ['LICENSE.md', 'README.md'] }
M container/Containerfile
1
# ########################################################################
2
#
3
# Copyright 2022 White Magic Software, Ltd.
4
#
5
# Creates a container image that can run ConTeXt to typeset documents.
6
#
7
# ########################################################################
8
19
FROM alpine:latest
10
ENV ENV="/etc/profile"
11
ENV PROFILE=/etc/profile
212
3
RUN apk --update add --no-cache fontconfig curl
4
RUN rm -rf /var/cache
13
ENV INSTALL_DIR=/opt
14
ENV SOURCE_DIR=/root/source
15
ENV TARGET_DIR=/root/target
16
ENV IMAGES_DIR=/root/images
17
ENV THEMES_DIR=/root/themes
18
ENV CACHES_DIR=/root/caches
19
ENV FONTS_DIR=/usr/share/fonts/user
20
ENV DOWNLOAD_DIR=/root
521
6
# Download fonts.
7
ENV FONT_DIR=/usr/share/fonts/user
8
RUN mkdir -p $FONT_DIR
9
WORKDIR $FONT_DIR
22
ENV CONTEXT_HOME=$INSTALL_DIR/context
1023
11
ADD "https://fonts.google.com/download?family=Roboto" "roboto.zip"
12
ADD "https://fonts.google.com/download?family=Inconsolata" "inconsolata.zip"
13
ADD "https://github.com/adobe-fonts/source-serif/releases/download/4.004R/source-serif-4.004.zip" "source-serif.zip"
14
ADD "https://github.com/googlefonts/Libre-Baskerville/blob/master/fonts/ttf/LibreBaskerville-Bold.ttf" "LibreBaskerville-Bold.ttf"
15
ADD "https://github.com/googlefonts/Libre-Baskerville/blob/master/fonts/ttf/LibreBaskerville-Italic.ttf" "LibreBaskerville-Italic.ttf"
16
ADD "https://github.com/googlefonts/Libre-Baskerville/blob/master/fonts/ttf/LibreBaskerville-Regular.ttf" "LibreBaskerville-Regular.ttf"
17
ADD "https://www.omnibus-type.com/wp-content/uploads/Archivo-Narrow.zip" "archivo-narrow.zip"
24
# ########################################################################
25
#
26
# Download all required dependencies
27
#
28
# ########################################################################
29
WORKDIR $DOWNLOAD_DIR
1830
19
# Unpack fonts (prior to ConTeXt).
20
RUN unzip -j -o roboto.zip "*.ttf"
21
RUN unzip -j -o inconsolata.zip "**/Inconsolata/*.ttf"
22
RUN unzip -j -o source-serif.zip "source-serif-4.004/OTF/SourceSerif4-*.otf"
23
RUN unzip -j -o archivo-narrow.zip "Archivo-Narrow/otf/*.otf"
24
RUN rm -f roboto.zip
25
RUN rm -f inconsolata.zip
26
RUN rm -f source-serif.zip
27
RUN rm -f archivo-narrow.zip
31
# Carlito (Calibri replacement)
32
ADD "https://github.com/googlefonts/carlito/raw/main/fonts/ttf/Carlito-Regular.ttf" "Carlito-Regular.ttf"
33
ADD "https://github.com/googlefonts/carlito/raw/main/fonts/ttf/Carlito-Bold.ttf" "Carlito-Bold.ttf"
34
ADD "https://github.com/googlefonts/carlito/raw/main/fonts/ttf/Carlito-Italic.ttf" "Carlito-Italic.ttf"
35
ADD "https://github.com/googlefonts/carlito/raw/main/fonts/ttf/Carlito-BoldItalic.ttf" "Carlito-BoldItalic.ttf"
2836
29
# Update system font cache.
30
RUN fc-cache -f -v
37
# Open Sans Emoji
38
ADD "https://github.com/MorbZ/OpenSansEmoji/raw/master/OpenSansEmoji.ttf" "OpenSansEmoji.ttf"
3139
32
WORKDIR "/opt"
40
# Underwood Quiet Tab
41
ADD "https://site.xavier.edu/polt/typewriters/Underwood_Quiet_Tab.ttf" "Underwood_Quiet_Tab.ttf"
3342
34
# Download themes.
35
ADD "https://github.com/DaveJarvis/keenwrite-themes/releases/latest/download/theme-pack.zip" "theme-pack.zip"
36
RUN unzip theme-pack.zip
43
# Archives
44
ADD "https://fonts.google.com/download?family=Courier%20Prime" "courier-prime.zip"
45
ADD "https://fonts.google.com/download?family=Inconsolata" "inconsolata.zip"
46
ADD "https://fonts.google.com/download?family=Libre%20Baskerville" "libre-baskerville.zip"
47
ADD "https://fonts.google.com/download?family=Nunito" "nunito.zip"
48
ADD "https://fonts.google.com/download?family=Roboto" "roboto.zip"
49
ADD "https://fonts.google.com/download?family=Roboto%20Mono" "roboto-mono.zip"
50
ADD "https://github.com/adobe-fonts/source-serif/releases/download/4.004R/source-serif-4.004.zip" "source-serif.zip"
51
ADD "https://www.omnibus-type.com/wp-content/uploads/Archivo-Narrow.zip" "archivo-narrow.zip"
3752
38
# Download ConTeXt.
53
# Typesetting software
3954
ADD "http://lmtx.pragma-ade.nl/install-lmtx/context-linuxmusl.zip" "context.zip"
40
RUN unzip context.zip -d context
41
RUN rm -f context.zip
42
43
# Install ConTeXt.
44
WORKDIR "context"
45
RUN sh install.sh
4655
47
# Configure environment to find ConTeXt.
48
ENV PROFILE=/etc/profile
49
ENV CONTEXT_HOME=/opt/context
50
RUN echo "export CONTEXT_HOME=\"$CONTEXT_HOME\"" >> $PROFILE
51
RUN echo "export PATH=\"\$PATH:\$CONTEXT_HOME/tex/texmf-linuxmusl/bin\"" >> $PROFILE
52
RUN echo "export OSFONTDIR=\"/usr/share/fonts//\""
53
RUN echo "PS1=\"typesetter:\\w\\\$ \"" >> $PROFILE
56
# ########################################################################
57
#
58
# Install components, modules, configure system, remove unnecessary files
59
#
60
# ########################################################################
61
WORKDIR $CONTEXT_HOME
5462
55
# Trim the fat.
56
RUN source $PROFILE
57
RUN rm -rf $CONTEXT_HOME/tex/texmf-context/doc
58
RUN find . -type f -name "*.pdf" -exec rm {} \;
63
RUN \
64
  apk --update --no-cache \
65
    add ca-certificates curl fontconfig inkscape rsync && \
66
  mkdir -p \
67
    "$FONTS_DIR" "$INSTALL_DIR" \
68
    "$TARGET_DIR" "$SOURCE_DIR" "$THEMES_DIR" "$IMAGES_DIR" "$CACHES_DIR" && \
69
  echo "export CONTEXT_HOME=\"$CONTEXT_HOME\"" >> $PROFILE && \
70
  echo "export PATH=\"\$PATH:\$CONTEXT_HOME/tex/texmf-linuxmusl/bin\"" >> $PROFILE && \
71
  echo "export OSFONTDIR=\"/usr/share/fonts//\"" >> $PROFILE && \
72
  echo "PS1='\\u@typesetter:\\w\\$ '" >> $PROFILE && \
73
  unzip -d $CONTEXT_HOME $DOWNLOAD_DIR/context.zip && \
74
  unzip -j -o -d $FONTS_DIR $DOWNLOAD_DIR/courier-prime.zip "*.ttf" && \
75
  unzip -j -o -d $FONTS_DIR $DOWNLOAD_DIR/libre-baskerville.zip "*.ttf" && \
76
  unzip -j -o -d $FONTS_DIR $DOWNLOAD_DIR/inconsolata.zip "**/Inconsolata/*.ttf" && \
77
  unzip -j -o -d $FONTS_DIR $DOWNLOAD_DIR/nunito.zip "static/*.ttf" && \
78
  unzip -j -o -d $FONTS_DIR $DOWNLOAD_DIR/roboto.zip "*.ttf" && \
79
  unzip -j -o -d $FONTS_DIR $DOWNLOAD_DIR/roboto-mono.zip "static/*.ttf" && \
80
  unzip -j -o -d $FONTS_DIR $DOWNLOAD_DIR/source-serif.zip "source-serif-4.004/OTF/SourceSerif4-*.otf" && \
81
  unzip -j -o -d $FONTS_DIR $DOWNLOAD_DIR/archivo-narrow.zip "Archivo-Narrow/otf/*.otf" && \
82
  mv $DOWNLOAD_DIR/*tf $FONTS_DIR && \
83
  fc-cache -f -v && \
84
  mkdir -p tex && \
85
  rsync \
86
    --recursive --links --times \
87
    --info=progress2,remove,symsafe,flist,del \
88
    --human-readable --del \
89
    rsync://contextgarden.net/minimals/current/modules/ modules && \
90
  rsync \
91
    -rlt --exclude=/VERSION --del modules/*/ tex/texmf-modules && \
92
  sh install.sh && \
93
  rm -f $DOWNLOAD_DIR/*.zip && \
94
  rm -rf \
95
    "modules" \
96
    "/var/cache" \
97
    "/usr/share/icons" \
98
    $CONTEXT_HOME/tex/texmf-modules/doc \
99
    $CONTEXT_HOME/tex/texmf-context/doc && \
100
  mkdir -p $CONTEXT_HOME/tex/texmf-fonts/tex/context/user && \
101
  ln -s $CONTEXT_HOME/tex/texmf-fonts/tex/context/user $HOME/fonts && \
102
  source $PROFILE && \
103
  mtxrun --generate && \
104
  find \
105
    /usr/share/inkscape \
106
    -type f -not -iname "*.xml" -exec rm {} \; && \
107
  find \
108
    $CONTEXT_HOME \
109
    -type f \
110
      \( -iname \*.pdf -o -iname \*.txt -o -iname \*.log \) \
111
    -exec rm {} \;
59112
60
# Prepare to process text files.
61
WORKDIR "/root"
113
# ########################################################################
114
#
115
# Ensure login goes to the target directory. ConTeXt prefers to export to
116
# the current working directory.
117
#
118
# ########################################################################
119
WORKDIR $TARGET_DIR
62120
63121
D container/context-container.sh
1
#!/usr/bin/env bash
2
3
if [ -z ${IMAGES_DIR} ]; then
4
  echo "Set IMAGES_DIR"
5
  exit 10
6
fi
7
8
readonly CONTAINER_NAME=typesetter
9
10
# Force clean
11
podman rmi --all --force
12
13
# Build from Containerfile
14
podman build --tag ${CONTAINER_NAME} .
15
16
# Connect and mount images
17
podman run \
18
  --rm \
19
  -i \
20
  -v ${IMAGES_DIR}:/root/images:ro \
21
  -t ${CONTAINER_NAME} \
22
  /bin/sh --login -c 'context --version'
23
24
# Create a persistent container
25
# podman create typesetter typesetter
26
27
# Create a long-running task
28
# podman create -ti typesetter /bin/sh
29
30
# Connect
31
32
# Export
33
# podman image save context -o typesetter.tar
34
# zip -9 -r typesetter.zip typesetter.tar
35
361
A container/manage.sh
1
#!/usr/bin/env bash
2
3
# ---------------------------------------------------------------------------
4
# Copyright 2022 White Magic Software, Ltd.
5
#
6
# This script manages the container configured to run ConTeXt.
7
# ---------------------------------------------------------------------------
8
9
source ../scripts/build-template
10
11
readonly BUILD_DIR=build
12
readonly PROPERTIES="${SCRIPT_DIR}/../src/main/resources/bootstrap.properties"
13
14
# Read the properties file to get the container version.
15
while IFS='=' read -r key value
16
do
17
  key=$(echo $key | tr '.' '_')
18
  eval ${key}=\${value}
19
done < "${PROPERTIES}"
20
21
readonly CONTAINER_EXE=podman
22
readonly CONTAINER_SHORTNAME=typesetter
23
readonly CONTAINER_VERSION=${container_version}
24
readonly CONTAINER_NETWORK=host
25
readonly CONTAINER_FILE="${CONTAINER_SHORTNAME}"
26
readonly CONTAINER_ARCHIVE_FILE="${CONTAINER_FILE}.tar"
27
readonly CONTAINER_ARCHIVE_PATH="${BUILD_DIR}/${CONTAINER_ARCHIVE_FILE}"
28
readonly CONTAINER_COMPRESSED_FILE="${CONTAINER_ARCHIVE_FILE}.gz"
29
readonly CONTAINER_COMPRESSED_PATH="${BUILD_DIR}/${CONTAINER_ARCHIVE_FILE}.gz"
30
readonly CONTAINER_DIR_SOURCE="/root/source"
31
readonly CONTAINER_DIR_TARGET="/root/target"
32
readonly CONTAINER_DIR_IMAGES="/root/images"
33
readonly CONTAINER_DIR_FONTS="/root/fonts"
34
readonly CONTAINER_REPO=ghcr.io
35
36
ARG_CONTAINER_NAME="${CONTAINER_SHORTNAME}:${CONTAINER_VERSION}"
37
ARG_CONTAINER_COMMAND="context --version"
38
ARG_MOUNTPOINT_SOURCE=""
39
ARG_MOUNTPOINT_TARGET="."
40
ARG_MOUNTPOINT_IMAGES=""
41
ARG_MOUNTPOINT_FONTS="${HOME}/.fonts"
42
ARG_ACCESS_TOKEN=""
43
44
DEPENDENCIES=(
45
  "podman,https://podman.io"
46
  "tar,https://www.gnu.org/software/tar"
47
  "bzip2,https://gitlab.com/bzip2/bzip2"
48
)
49
50
ARGUMENTS+=(
51
  "b,build,Build container"
52
  "c,connect,Connect to container"
53
  "d,delete,Remove all containers"
54
  "s,source,Set mount point for input document (before typesetting)"
55
  "t,target,Set mount point for output file (after typesetting)"
56
  "i,images,Set mount point for image files (to typeset)"
57
  "f,fonts,Set mount point for font files (during typesetting)"
58
  "k,token,Set personal access token (to publish)"
59
  "l,load,Load container (${CONTAINER_COMPRESSED_PATH})"
60
  "p,publish,Publish the container (after logging in)"
61
  "r,run,Run a command in the container (\"${ARG_CONTAINER_COMMAND}\")"
62
  "v,version,Set container version to publish (${CONTAINER_VERSION})"
63
  "x,export,Save container (${CONTAINER_COMPRESSED_PATH})"
64
)
65
66
# ---------------------------------------------------------------------------
67
# Manages the container.
68
# ---------------------------------------------------------------------------
69
execute() {
70
  $do_delete
71
  $do_build
72
  $do_publish
73
  $do_export
74
  $do_load
75
  $do_execute
76
  $do_connect
77
78
  return 1
79
}
80
81
# ---------------------------------------------------------------------------
82
# Deletes all containers.
83
# ---------------------------------------------------------------------------
84
utile_delete() {
85
  $log "Deleting all containers"
86
87
  ${CONTAINER_EXE} rmi --all --force > /dev/null
88
89
  $log "Containers deleted"
90
}
91
92
# ---------------------------------------------------------------------------
93
# Builds the container file in the current working directory.
94
# ---------------------------------------------------------------------------
95
utile_build() {
96
  $log "Building"
97
98
  # Show what commands are run while building, but not the commands' output.
99
  ${CONTAINER_EXE} build \
100
    --network=${CONTAINER_NETWORK} \
101
    --squash \
102
    -t ${ARG_CONTAINER_NAME} . | \
103
  grep ^STEP
104
}
105
106
# ---------------------------------------------------------------------------
107
# Publishes the container to the repository.
108
# ---------------------------------------------------------------------------
109
utile_publish() {
110
  local -r username=$(git config user.name | tr '[A-Z]' '[a-z]')
111
  local -r repo="${CONTAINER_REPO}/${username}/${ARG_CONTAINER_NAME}"
112
113
  if [ ! -z ${ARG_ACCESS_TOKEN} ]; then
114
    echo ${ARG_ACCESS_TOKEN} | \
115
      ${CONTAINER_EXE} login ghcr.io -u $(git config user.name) --password-stdin
116
117
    $log "Tagging"
118
119
    ${CONTAINER_EXE} tag ${ARG_CONTAINER_NAME} ${repo}
120
121
    $log "Pushing ${ARG_CONTAINER_NAME} to ${CONTAINER_REPO}"
122
123
    ${CONTAINER_EXE} push ${repo}
124
125
    $log "Published ${ARG_CONTAINER_NAME} to ${CONTAINER_REPO}"
126
  else
127
    error "Provide a personal access token (-k TOKEN) to publish."
128
  fi
129
}
130
131
# ---------------------------------------------------------------------------
132
# Creates the command-line option for a read-only mountpoint.
133
#
134
# $1 - The host directory.
135
# $2 - The guest (container) directory.
136
# $3 - The file system permissions (set to 1 for read-write).
137
# ---------------------------------------------------------------------------
138
get_mountpoint() {
139
  $log "Mounting ${1} as ${2}"
140
141
  local result=""
142
  local binding="ro"
143
144
  if [ ! -z "${3+x}" ]; then
145
    binding="Z"
146
  fi
147
148
  if [ ! -z "${1}" ]; then
149
    result="-v ${1}:${2}:${binding}"
150
  fi
151
152
  echo "${result}"
153
}
154
155
get_mountpoint_source() {
156
  echo $(get_mountpoint "${ARG_MOUNTPOINT_SOURCE}" "${CONTAINER_DIR_SOURCE}")
157
}
158
159
get_mountpoint_target() {
160
  echo $(get_mountpoint "${ARG_MOUNTPOINT_TARGET}" "${CONTAINER_DIR_TARGET}" 1)
161
}
162
163
get_mountpoint_images() {
164
  echo $(get_mountpoint "${ARG_MOUNTPOINT_IMAGES}" "${CONTAINER_DIR_IMAGES}")
165
}
166
167
get_mountpoint_fonts() {
168
  echo $(get_mountpoint "${ARG_MOUNTPOINT_FONTS}" "${CONTAINER_DIR_FONTS}")
169
}
170
171
# ---------------------------------------------------------------------------
172
# Connects to the container.
173
# ---------------------------------------------------------------------------
174
utile_connect() {
175
  $log "Connecting to container"
176
177
  declare -r mount_source=$(get_mountpoint_source)
178
  declare -r mount_target=$(get_mountpoint_target)
179
  declare -r mount_images=$(get_mountpoint_images)
180
  declare -r mount_fonts=$(get_mountpoint_fonts)
181
182
  ${CONTAINER_EXE} run \
183
    --network="${CONTAINER_NETWORK}" \
184
    --rm \
185
    -it \
186
    ${mount_source} \
187
    ${mount_target} \
188
    ${mount_images} \
189
    ${mount_fonts} \
190
    "${ARG_CONTAINER_NAME}"
191
}
192
193
# ---------------------------------------------------------------------------
194
# Runs a command in the container.
195
#
196
# Examples:
197
#
198
#   ./manage.sh -r "ls /"
199
#   ./manage.sh -r "context --version"
200
# ---------------------------------------------------------------------------
201
utile_execute() {
202
  $log "Running \"${ARG_CONTAINER_COMMAND}\":"
203
204
  ${CONTAINER_EXE} run \
205
    --network=${CONTAINER_NETWORK} \
206
    --rm \
207
    -i \
208
    -t "${ARG_CONTAINER_NAME}" \
209
    /bin/sh --login -c "${ARG_CONTAINER_COMMAND}"
210
}
211
212
# ---------------------------------------------------------------------------
213
# Saves the container to a file.
214
# ---------------------------------------------------------------------------
215
utile_export() {
216
  if [[ -f "${CONTAINER_COMPRESSED_PATH}" ]]; then
217
    warning "${CONTAINER_COMPRESSED_PATH} exists, delete before saving."
218
  else
219
    $log "Saving ${CONTAINER_SHORTNAME} image"
220
221
    mkdir -p "${BUILD_DIR}"
222
223
    ${CONTAINER_EXE} save \
224
      --quiet \
225
      -o "${BUILD_DIR}/${CONTAINER_ARCHIVE_FILE}" \
226
      "${ARG_CONTAINER_NAME}"
227
228
    $log "Compressing to ${CONTAINER_COMPRESSED_PATH}"
229
    gzip "${CONTAINER_ARCHIVE_PATH}"
230
231
    $log "Saved ${CONTAINER_SHORTNAME} image"
232
  fi
233
}
234
235
# ---------------------------------------------------------------------------
236
# Loads the container from a file.
237
# ---------------------------------------------------------------------------
238
utile_load() {
239
  if [[ -f "${CONTAINER_COMPRESSED_PATH}" ]]; then
240
    $log "Loading ${CONTAINER_SHORTNAME} from ${CONTAINER_COMPRESSED_PATH}"
241
242
    ${CONTAINER_EXE} load \
243
      --quiet \
244
      -i "${CONTAINER_COMPRESSED_PATH}"
245
246
    $log "Loaded ${CONTAINER_SHORTNAME} image"
247
  else
248
    warning "Missing ${CONTAINER_COMPRESSED_PATH}; use build follwed by save"
249
  fi
250
}
251
252
argument() {
253
  local consume=1
254
255
  case "$1" in
256
    -b|--build)
257
    do_build=utile_build
258
    ;;
259
    -c|--connect)
260
    do_connect=utile_connect
261
    ;;
262
    -d|--delete)
263
    do_delete=utile_delete
264
    ;;
265
    -k|--token)
266
    if [ ! -z "${2+x}" ]; then
267
      ARG_ACCESS_TOKEN="$2"
268
      consume=2
269
    fi
270
    ;;
271
    -l|--load)
272
    do_load=utile_load
273
    ;;
274
    -i|--images)
275
    if [ ! -z "${2+x}" ]; then
276
      ARG_MOUNTPOINT_IMAGES="$2"
277
      consume=2
278
    fi
279
    ;;
280
    -t|--target)
281
    if [ ! -z "${2+x}" ]; then
282
      ARG_MOUNTPOINT_TARGET="$2"
283
      consume=2
284
    fi
285
    ;;
286
    -p|--publish)
287
    do_publish=utile_publish
288
    ;;
289
    -r|--run)
290
    do_execute=utile_execute
291
292
    if [ ! -z "${2+x}" ]; then
293
      ARG_CONTAINER_COMMAND="$2"
294
      consume=2
295
    fi
296
    ;;
297
    -s|--source)
298
    if [ ! -z "${2+x}" ]; then
299
      ARG_MOUNTPOINT_SOURCE="$2"
300
      consume=2
301
    fi
302
    ;;
303
    -v|--version)
304
    if [ ! -z "${2+x}" ]; then
305
      ARG_CONTAINER_NAME="${CONTAINER_SHORTNAME}:$2"
306
      consume=2
307
    fi
308
    ;;
309
    -x|--export)
310
    do_export=utile_export
311
    ;;
312
  esac
313
314
  return ${consume}
315
}
316
317
do_build=:
318
do_connect=:
319
do_delete=:
320
do_execute=:
321
do_load=:
322
do_publish=:
323
do_export=:
324
325
main "$@"
326
1327
A docs/typesetting-custom.md
1
# Overview
2
3
Typesetting PDF files entails the following:
4
5
* Download and install typesetting software
6
* Download a theme pack
7
8
These are described in the subsequent sections. Once the requirements have been met, continue reading to learn how to typeset a document.
9
10
# Download typesetter
11
12
Download the typesetting software as follows:
13
14
1. Start the text editor.
15
1. Click **File → Export As → PDF**.
16
    * Note the following details (e.g., Windows X86 64-bit):
17
        * operating system name;
18
        * instruction set; and
19
        * architecture.
20
1. Click the [link](https://wiki.contextgarden.net/Installation) in the dialog.
21
1. Download the appropriate archive file.
22
23
# Install typesetter
24
25
This section describes the installation steps for various platforms. Follow the steps that apply to the computer's operating system:
26
27
* [Windows](#windows) (includes Windows 7, Windows 10, and similar)
28
* [Unix](#unix) (includes MacOS, FreeBSD, Linux, and similar)
29
30
## Windows
31
32
Proceed with a Windows installation of the typesetting software as follows:
33
34
1. Extract the `.zip` file into `C:\Users\%USERNAME%\AppData\Local\context` (the "root" directory)
35
1. Run **install.bat** to download and install the software.
36
    * If prompted, click **Run anyway** (or click **More info** first).
37
1. Right-click <a href="https://github.com/DaveJarvis/keenwrite/raw/master/scripts/localpath.bat">localpath.bat</a>.
38
1. Select **Save Link As** (or similar).
39
1. Save the file to the typesetting software's "root" directory.
40
1. Rename `localpath.bat.txt` to `localpath.bat`, if necessary.
41
1. Run `localpath.bat` (to set and save the `PATH` environment variable).
42
43
Installation is complete. Verify the installation as follows:
44
45
1. Type: `context --version`
46
1. Press `Enter`.
47
48
If version information is displayed then the software is installed correctly.
49
50
Continue by installing a [theme pack](#theme-pack).
51
52
## Unix
53
54
For Linux, MacOS, FreeBSD, and similar operating systems, proceed as follows:
55
56
1. Create `$HOME/.local/bin/context`
57
1. Extract the `.zip` file within `$HOME/.local/bin/context`
58
1. Run `sh install.sh`
59
1. Add `export PATH=$PATH:$HOME/.local/bin/context/tex/texmf-linux-64/bin` to the login script.
60
61
Installation is complete. Verify the installation as follows:
62
63
1. Open a new terminal (to export the new PATH setting).
64
1. Type: `context --version`
65
1. Press `Enter`.
66
67
If version information is displayed then the software is installed correctly.
68
69
Continue by installing a [theme pack](#theme-pack).
70
71
# Theme pack
72
73
A theme pack is a set of themes that define how documents appear when typeset. Broadly, themes are applied as follows:
74
75
* Install a theme pack
76
* Configure individual themes
77
78
## Install theme pack
79
80
Install and configure the default theme pack as follows:
81
82
1. Download the <a href="https://gitreleases.dev/gh/DaveJarvis/keenwrite-themes/latest/theme-pack.zip">theme-pack.zip</a> archive.
83
1. Extract archive into a known location.
84
1. Start the text editor, if not already running.
85
1. Click **Edit → Preferences**.
86
1. Click **Typesetting**.
87
1. Click **Browse** beside **Themes**.
88
1. Navigate to the `themes` directory.
89
1. Click **Open**.
90
1. Click **OK**.
91
92
The theme pack is installed.
93
94
Each theme has its own requirements, described below. 
95
96
## Configure Boschet theme
97
98
Download and install the following font families:
99
100
* [Libre Baskerville](https://fonts.google.com/specimen/Libre+Baskerville)
101
* [Archivo Narrow](https://fonts.google.com/specimen/Archivo+Narrow)
102
* [Inconsolata](https://fonts.google.com/specimen/Inconsolata)
103
104
The theme is configured.
105
106
# Typeset single document
107
108
Typeset a document as follows:
109
110
1. Start the text editor, if not already running.
111
1. Click **File → New** (or type `Ctrl+n`).
112
1. Type in some text.
113
1. Click **File → Export As → PDF** (or type `Ctrl+p`).
114
1. Select a theme from the drop-down list.
115
1. Click **OK** (or press `Enter`).
116
1. Set the **File name** to the PDF file name.
117
1. Click **Save**.
118
119
The document is typeset; open the PDF file in a PDF reader to view the result.
120
121
# Typeset multiple documents
122
123
Typeset multiple documents similar to single documents, with one difference:
124
125
* Click **File → Export As → Joined PDF** (or type `Ctrl+Shift+p`).
126
127
All documents having the same file name extension in the same directory
128
(or sub-directories) as the actively edited file are first concatenated then
129
typeset into a single PDF document. The order that files are concatenated
130
is numeric and alphabetic.
131
132
For example, if `1.Rmd` is a sibling of the following files in the same
133
directory, then all the files will be included in the PDF, as expected:
134
135
    chapter_1.Rmd
136
    chapter_2.Rmd
137
    chapter_2a.Rmd
138
    chapter_2b.Rmd
139
    chapter_3.Rmd
140
    chapter_10.Rmd
141
142
Basically, sorting honours numbers and letters in file names.
143
144
# Background 
145
146
This text editor helps keep content separated from presentation. Plain text documents will remain readable long after proprietary formats have become obsolete. However, we've come to expect much more in what we read than mere text: from hyperlinked tables of contents to indexes, from footers to footnotes, from mathematical expressions to complex graphics, modern documents are nuanced and multifaceted.
147
148
## History
149
150
Before computer-based typesetting, much of mathematics was put to page by hand. Professional typesetters, who were often expensive and usually not mathematicians, would inadvertently introduce typographic errors into equations. Phototypesetting technology improved upon hand-typesetting, but well-known computer scientist Donald Knuth---whose third volume of *The Art of Computer Programming* was phototypeset in 1976---expressed dissatisfaction with its typographic quality. He set himself two goals: let anyone create high-quality books without much effort and provide software that typesets consistently on all capable computers. Two years later, he released a typesetting system and a font description language: TeX and METAFONT, respectively.
151
152
In short, TeX is software that helps typeset plain text documents.
153
154
## ConTeXt
155
156
Programming computers to typeset internationalized text automatically at the level we've become accustomed takes decades of development effort. Many free and open source software solutions can typeset text, including: ConTeXt, LaTeX, Sile, and others. ConTeXt, which builds upon TeX, is ideal for typesetting plain text into beautiful documents because it is developed with a notion of *setups*. These setups can wholly describe how text is to be typeset and---by being external to the text itself---configuring setups provides ample control over the document's final appearance without changing the prose.
157
158
# Further reading
159
160
Here are a few documents that introduce the typesetting system:
161
162
* *What is ConTeXt?* ([English](https://www.pragma-ade.com/general/manuals/what-is-context.pdf))
163
* *A not so short introduction to ConTeXt* ([English](https://github.com/contextgarden/not-so-short-introduction-to-context/raw/main/en/introCTX_eng.pdf) or [Spanish](https://raw.githubusercontent.com/contextgarden/not-so-short-introduction-to-context/main/es/introCTX_esp.pdf))
164
* *Dealing with XML in ConTeXt MKIV* ([English](https://pragma-ade.com/general/manuals/xml-mkiv.pdf))
165
* *Typographic Programming* ([English](https://www.pragma-ade.com/general/manuals/style.pdf))
166
167
The [documentation library](https://wiki.contextgarden.net/Documentation) includes the following gems:
168
169
* [ConTeXt Manual](https://www.pragma-ade.nl/general/manuals/ma-cb-en.pdf)
170
* [ConTeXt command reference](https://www.pragma-ade.nl/general/qrcs/setup-en.pdf)
171
* [METAFUN Manual](https://www.pragma-ade.nl/general/manuals/metafun-p.pdf)
172
* [It's in the Details](https://www.pragma-ade.nl/general/manuals/details.pdf)
173
* [Fonts out of ConTeXt](https://www.pragma-ade.com/general/manuals/fonts-mkiv.pdf)
174
175
Expert-level documentation includes the [LuaTeX Reference Manual](https://www.pragma-ade.nl/general/manuals/luatex.pdf).
176
1177
M docs/typesetting.md
1
# Overview
2
3
Typesetting PDF files entails the following:
4
5
* Download and install typesetting software
6
* Download a theme pack
1
# Typesetting
72
8
These are described in the subsequent sections. Once the requirements have been met, continue reading to learn how to typeset a document.
3
The application uses the [ConTeXt](https://contextgarden.net) typesetting
4
system, the [podman](https://podman.io/) container manager, various
5
[themes](https://github.com/DaveJarvis/keenwrite-themes/), and numerous
6
fonts to produce high-quality PDF files. The container manager significantly
7
reduces the number of manual steps in the installation process.
98
10
# Download typesetter
9
When exporting a document to a PDF file for the first time, a series of
10
semi-automated steps guides users through the installation process. These
11
steps differ depending on the operating system.
1112
12
Download the typesetting software as follows:
13
Run the installation wizard as follows:
1314
14
1. Start the text editor.
15
1. Start the application.
1516
1. Click **File → Export As → PDF**.
16
    * Note the following details (e.g., Windows X86 64-bit):
17
        * operating system name;
18
        * instruction set; and
19
        * architecture.
20
1. Click the [link](https://wiki.contextgarden.net/Installation) in the dialog.
21
1. Download the appropriate archive file.
22
23
# Install typesetter
24
25
This section describes the installation steps for various platforms. Follow the steps that apply to the computer's operating system:
2617
27
* [Windows](#windows) (includes Windows 7, Windows 10, and similar)
28
* [Unix](#unix) (includes MacOS, FreeBSD, Linux, and similar)
18
A wizard appears.
2919
3020
## Windows
31
32
Proceed with a Windows installation of the typesetting software as follows:
33
34
1. Extract the `.zip` file into `C:\Users\%USERNAME%\AppData\Local\context` (the "root" directory)
35
1. Run **install.bat** to download and install the software.
36
    * If prompted, click **Run anyway** (or click **More info** first).
37
1. Right-click <a href="https://github.com/DaveJarvis/keenwrite/raw/master/scripts/localpath.bat">localpath.bat</a>.
38
1. Select **Save Link As** (or similar).
39
1. Save the file to the typesetting software's "root" directory.
40
1. Rename `localpath.bat.txt` to `localpath.bat`, if necessary.
41
1. Run `localpath.bat` (to set and save the `PATH` environment variable).
42
43
Installation is complete. Verify the installation as follows:
44
45
1. Type: `context --version`
46
1. Press `Enter`.
47
48
If version information is displayed then the software is installed correctly.
49
50
Continue by installing a [theme pack](#theme-pack).
51
52
## Unix
53
54
For Linux, MacOS, FreeBSD, and similar operating systems, proceed as follows:
55
56
1. Create `$HOME/.local/bin/context`
57
1. Extract the `.zip` file within `$HOME/.local/bin/context`
58
1. Run `sh install.sh`
59
1. Add `export PATH=$PATH:$HOME/.local/bin/context/tex/texmf-linux-64/bin` to the login script.
60
61
Installation is complete. Verify the installation as follows:
62
63
1. Open a new terminal (to export the new PATH setting).
64
1. Type: `context --version`
65
1. Press `Enter`.
66
67
If version information is displayed then the software is installed correctly.
68
69
Continue by installing a [theme pack](#theme-pack).
70
71
# Theme pack
72
73
A theme pack is a set of themes that define how documents appear when typeset. Broadly, themes are applied as follows:
74
75
* Install a theme pack
76
* Configure individual themes
77
78
## Install theme pack
79
80
Install and configure the default theme pack as follows:
81
82
1. Download the <a href="https://gitreleases.dev/gh/DaveJarvis/keenwrite-themes/latest/theme-pack.zip">theme-pack.zip</a> archive.
83
1. Extract archive into a known location.
84
1. Start the text editor, if not already running.
85
1. Click **Edit → Preferences**.
86
1. Click **Typesetting**.
87
1. Click **Browse** beside **Themes**.
88
1. Navigate to the `themes` directory.
89
1. Click **Open**.
90
1. Click **OK**.
91
92
The theme pack is installed.
93
94
Each theme has its own requirements, described below. 
95
96
## Configure Boschet theme
97
98
Download and install the following font families:
99
100
* [Libre Baskerville](https://fonts.google.com/specimen/Libre+Baskerville)
101
* [Archivo Narrow](https://fonts.google.com/specimen/Archivo+Narrow)
102
* [Inconsolata](https://fonts.google.com/specimen/Inconsolata)
103
104
The theme is configured.
105
106
# Typeset single document
107
108
Typeset a document as follows:
109
110
1. Start the text editor, if not already running.
111
1. Click **File → New** (or type `Ctrl+n`).
112
1. Type in some text.
113
1. Click **File → Export As → PDF** (or type `Ctrl+p`).
114
1. Select a theme from the drop-down list.
115
1. Click **OK** (or press `Enter`).
116
1. Set the **File name** to the PDF file name.
117
1. Click **Save**.
118
119
The document is typeset; open the PDF file in a PDF reader to view the result.
120
121
# Typeset multiple documents
122
123
Typeset multiple documents similar to single documents, with one difference:
124
125
* Click **File → Export As → Joined PDF** (or type `Ctrl+Shift+p`).
126
127
All documents having the same file name extension in the same directory
128
(or sub-directories) as the actively edited file are first concatenated then
129
typeset into a single PDF document. The order that files are concatenated
130
is numeric and alphabetic.
131
132
For example, if `1.Rmd` is a sibling of the following files in the same
133
directory, then all the files will be included in the PDF, as expected:
134
135
    chapter_1.Rmd
136
    chapter_2.Rmd
137
    chapter_2a.Rmd
138
    chapter_2b.Rmd
139
    chapter_3.Rmd
140
    chapter_10.Rmd
141
142
Basically, sorting honours numbers and letters in file names.
143
144
# Background 
145
146
This text editor helps keep content separated from presentation. Plain text documents will remain readable long after proprietary formats have become obsolete. However, we've come to expect much more in what we read than mere text: from hyperlinked tables of contents to indexes, from footers to footnotes, from mathematical expressions to complex graphics, modern documents are nuanced and multifaceted.
147
148
## History
149
150
Before computer-based typesetting, much of mathematics was put to page by hand. Professional typesetters, who were often expensive and usually not mathematicians, would inadvertently introduce typographic errors into equations. Phototypesetting technology improved upon hand-typesetting, but well-known computer scientist Donald Knuth---whose third volume of *The Art of Computer Programming* was phototypeset in 1976---expressed dissatisfaction with its typographic quality. He set himself two goals: let anyone create high-quality books without much effort and provide software that typesets consistently on all capable computers. Two years later, he released a typesetting system and a font description language: TeX and METAFONT, respectively.
151
152
In short, TeX is software that helps typeset plain text documents.
153
154
## ConTeXt
155
156
Programming computers to typeset internationalized text automatically at the level we've become accustomed takes decades of development effort. Many free and open source software solutions can typeset text, including: ConTeXt, LaTeX, Sile, and others. ConTeXt, which builds upon TeX, is ideal for typesetting plain text into beautiful documents because it is developed with a notion of *setups*. These setups can wholly describe how text is to be typeset and---by being external to the text itself---configuring setups provides ample control over the document's final appearance without changing the prose.
157
158
# Further reading
159
160
Here are a few documents that introduce the typesetting system:
16121
162
* *What is ConTeXt?* ([English](https://www.pragma-ade.com/general/manuals/what-is-context.pdf))
163
* *A not so short introduction to ConTeXt* ([English](https://github.com/contextgarden/not-so-short-introduction-to-context/raw/main/en/introCTX_eng.pdf) or [Spanish](https://raw.githubusercontent.com/contextgarden/not-so-short-introduction-to-context/main/es/introCTX_esp.pdf))
164
* *Dealing with XML in ConTeXt MKIV* ([English](https://pragma-ade.com/general/manuals/xml-mkiv.pdf))
165
* *Typographic Programming* ([English](https://www.pragma-ade.com/general/manuals/style.pdf))
16622
167
The [documentation library](https://wiki.contextgarden.net/Documentation) includes the following gems:
23
## Linux
16824
169
* [ConTeXt Manual](https://www.pragma-ade.nl/general/manuals/ma-cb-en.pdf)
170
* [ConTeXt command reference](https://www.pragma-ade.nl/general/qrcs/setup-en.pdf)
171
* [METAFUN Manual](https://www.pragma-ade.nl/general/manuals/metafun-p.pdf)
172
* [It's in the Details](https://www.pragma-ade.nl/general/manuals/details.pdf)
173
* [Fonts out of ConTeXt](https://www.pragma-ade.com/general/manuals/fonts-mkiv.pdf)
17425
175
Expert-level documentation includes the [LuaTeX Reference Manual](https://www.pragma-ade.nl/general/manuals/luatex.pdf).
26
## macOS
17627
17728
D images/logo64.png
Binary file
M installer.sh
4343
DEPENDENCIES=(
4444
  "gradle,https://gradle.org"
45
  "warp-packer,https://github.com/dgiagio/warp"
45
  "warp-packer,https://github.com/Reisz/warp/releases"
4646
  "tar,https://www.gnu.org/software/tar"
47
  "wine,https://www.winehq.org"
4748
  "unzip,http://infozip.sourceforge.net"
4849
)
...
7677
7778
  $do_create_launcher
79
80
  $do_brand_windows
7881
7982
  return 1
...
9093
    APP_EXTENSION="exe"
9194
    do_create_launch_script=utile_create_launch_script_windows
95
    do_brand_windows=utile_brand_windows
9296
  fi
9397
}
...
177181
  # This avoids any potential line conversion issues with the repository.
178182
  sed -i 's/$/\r/' "${FILE_DIST_EXEC}"
183
}
184
185
# ---------------------------------------------------------------------------
186
# Modify the binary to include icon and identifying information.
187
# ---------------------------------------------------------------------------
188
utile_brand_windows() {
189
  # Read the properties file to get the application name (case sensitvely).
190
  while IFS='=' read -r key value
191
  do
192
    key=$(echo $key | tr '.' '_')
193
    eval ${key}=\${value}
194
  done < "src/main/resources/bootstrap.properties"
195
196
  readonly BINARY="${APP_NAME}.exe"
197
  readonly VERSION=$(git describe --tags)
198
  readonly COMPANY="White Magic Software, Ltd."
199
  readonly YEAR=$(date +%Y)
200
  readonly DESCRIPTION="Markdown editor with live preview, variables, and math."
201
  readonly SIZE=$(stat --format="%s" ${BINARY})
202
203
  wine ${SCRIPT_DIR}/scripts/rcedit-x64.exe "${BINARY}" \
204
    --set-icon "scripts/logo.ico" \
205
    --set-version-string "OriginalFilename" "${application_title}.exe" \
206
    --set-version-string "CompanyName" "${COMPANY}" \
207
    --set-version-string "ProductName" "${application_title}" \
208
    --set-version-string "LegalCopyright" "Copyright ${YEAR} ${COMPANY}" \
209
    --set-version-string "FileDescription" "${DESCRIPTION}" \
210
    --set-version-string "Size" "${DESCRIPTION}" \
211
    --set-product-version "${VERSION}" \
212
    --set-file-version "${VERSION}"
213
214
  mv -f "${BINARY}" "${application_title}.exe"
179215
}
180216
...
203239
204240
  warp-packer \
241
    pack \
205242
    --arch "${ARG_JAVA_OS}-${ARG_JAVA_ARCH}" \
206
    --input_dir "${ARG_DIR_DIST}" \
243
    --input-dir "${ARG_DIR_DIST}" \
207244
    --exec "${FILE_DIST_EXEC}" \
208245
    --output "${FILE_APP_NAME}" > /dev/null
...
239276
do_copy_archive=utile_copy_archive
240277
do_create_launcher=utile_create_launcher
278
do_brand_windows=:
241279
242280
main "$@"
M libs/keentex.jar
Binary file
D libs/tiwulfx-dock-0.1.jar
Binary file
M libs/tokenize.jar
Binary file
A scripts/icons.sh
1
#!/bin/bash
2
3
INKSCAPE="/usr/bin/inkscape"
4
PNG_COMPRESS="optipng"
5
PNG_COMPRESS_OPTS="-o9 *png"
6
7
declare -a SIZES=("16" "32" "64" "128" "256" "512")
8
9
for i in "${SIZES[@]}"; do
10
  # -y: export background opacity 0
11
  $INKSCAPE -y 0 -w "${i}" --export-overwrite --export-type=png -o "logo${i}.png" "logo.svg" 
12
done
13
14
# Compess the PNG images.
15
which $PNG_COMPRESS && $PNG_COMPRESS $PNG_COMPRESS_OPTS
16
117
A scripts/jsign-4.2.jar
Binary file
A scripts/logo.ico
Binary file
A scripts/rcedit-x64.exe
Binary file
A scripts/squish.sh
1
#!/usr/bin/env bash
2
3
# TODO: This file does not work with Picocli and there are other issues.
4
# TODO: Revisit after replacing Picocli and using FastR instead of Renjin.
5
6
MODULES="${JAVA_HOME}/jmods/"
7
LIBS=$(ls -1 ../libs/*jar | sed 's/\(.*\)/-libraryjars \1/g')
8
9
java -jar ../tex/lib/proguard.jar \
10
  -libraryjars "${MODULES}java.base.jmod/(!**.jar;!module-info.class)" \
11
  -libraryjars "${MODULES}java.desktop.jmod/(!**.jar;!module-info.class)" \
12
  -libraryjars "${MODULES}java.xml.jmod/(!**.jar;!module-info.class)" \
13
  -libraryjars "${MODULES}javafx.controls.jmod/(!**.jar;!module-info.class)" \
14
  -libraryjars "${MODULES}javafx.graphics.jmod/(!**.jar;!module-info.class)" \
15
  ${LIBS} \
16
  -injars ../build/libs/keenwrite.jar \
17
  -outjars ../build/libs/keenwrite-min.jar \
18
  -keep 'class com.keenwrite.** { *; }' \
19
  -keep 'class com.whitemagicsoftware.tex.** { *; }' \
20
  -keep 'class org.renjin.** { *; }' \
21
  -keep 'class picocli.** { *; }' \
22
  -keep 'interface picocli.** { *; }' \
23
  -keep 'class picocli.CommandLine { *; }' \
24
  -keep 'class picocli.CommandLine$* { *; }' \
25
  -keepattributes '*Annotation*, Signature, Exception' \
26
  -keepclassmembers 'class * extends java.util.concurrent.Callable {
27
      public java.lang.Integer call();
28
  }' \
29
  -keepclassmembers 'class * {
30
      @javax.inject.Inject <init>(...);
31
      @picocli.CommandLine$Option *;
32
  }' \
33
  -keepclassmembers 'class * extends java.lang.Enum {
34
      <fields>;
35
      public static **[] values();
36
      public static ** valueOf(java.lang.String);
37
  }' \
38
  -keepnames \
39
    'class org.apache.lucene.analysis.tokenattributes.KeywordAttributeImpl' \
40
  -dontnote \
41
  -dontwarn \
42
  -dontoptimize \
43
  -dontobfuscate
44
145
M src/main/java/com/keenwrite/AppCommands.java
7979
        final var context = args.createProcessorContext();
8080
        final var concat = context.getConcatenate();
81
        final var inputPath = context.getInputPath();
82
        final var outputPath = context.getOutputPath();
81
        final var inputPath = context.getSourcePath();
82
        final var outputPath = context.getTargetPath();
8383
        final var chain = createProcessors( context );
8484
        final var inputDoc = read( inputPath, concat );
M src/main/java/com/keenwrite/Bootstrap.java
33
44
import com.keenwrite.constants.Constants;
5
import com.keenwrite.io.UserDataDir;
56
7
import java.io.File;
68
import java.io.InputStream;
9
import java.nio.file.Path;
710
import java.util.Calendar;
811
import java.util.Properties;
...
2932
3033
  public static String APP_TITLE;
31
  public static String APP_TITLE_LOWERCASE;
3234
  public static String APP_VERSION;
33
  public static String APP_YEAR;
35
  public static String CONTAINER_VERSION;
36
37
  public static final String APP_TITLE_ABBR = "kwr";
38
  public static final String APP_TITLE_LOWERCASE;
39
  public static final String APP_VERSION_CLEAN;
40
  public static final String APP_YEAR;
41
42
  public static final Path USER_DATA_DIR;
43
  public static final File USER_CACHE_DIR;
3444
3545
  static {
3646
    try( final var in = openResource( PATH_BOOTSTRAP ) ) {
3747
      sP.load( in );
3848
3949
      APP_TITLE = sP.getProperty( "application.title" );
50
      CONTAINER_VERSION = sP.getProperty( "container.version" );
4051
    } catch( final Exception ex ) {
4152
      APP_TITLE = "KeenWrite";
4253
4354
      // Bootstrap properties cannot be found, use a default value.
4455
      final var fmt = "Unable to load %s resource, applying defaults.%n";
4556
      clue( ex, fmt, PATH_BOOTSTRAP );
57
58
      // There's no way to know what container version is compatible. This
59
      // value will cause a failure when downloading the container,
60
      CONTAINER_VERSION = "1.0.0";
4661
    }
4762
...
5873
    }
5974
75
    // The plug-in that requests the version from the repository tag will
76
    // add a "dirty" number and indicator suffix. Removing it allows the
77
    // "clean" version to be used to pull a corresponding typesetter container.
78
    APP_VERSION_CLEAN = APP_VERSION.replaceAll( "-.*", "" );
6079
    APP_YEAR = getYear();
6180
6281
    // This also sets the user agent for the SVG rendering library.
63
    System.setProperty( "http.agent", APP_TITLE + " " + APP_VERSION );
82
    System.setProperty( "http.agent", APP_TITLE + " " + APP_VERSION_CLEAN );
83
84
    USER_DATA_DIR = UserDataDir.getAppPath( APP_TITLE_LOWERCASE );
85
    USER_CACHE_DIR = USER_DATA_DIR.resolve( "cache" ).toFile();
86
87
    if( !USER_CACHE_DIR.exists() ) {
88
      final var ignored = USER_CACHE_DIR.mkdirs();
89
    }
6490
  }
6591
M src/main/java/com/keenwrite/Launcher.java
155155
  private static void disableLogging() {
156156
    LogManager.getLogManager().reset();
157
    stderrDisable();
158
  }
159
160
  /**
161
   * TODO: Delete this after JavaFX/GTK 3 no longer barfs useless warnings.
162
   */
163
  private static void stderrDisable() {
157
    // TODO: Delete this after JavaFX/GTK 3 no longer barfs useless warnings.
164158
    System.err.close();
165159
  }
M src/main/java/com/keenwrite/MainPane.java
2626
import com.keenwrite.spelling.impl.PermissiveSpeller;
2727
import com.keenwrite.spelling.impl.SymSpellSpeller;
28
import com.keenwrite.ui.explorer.FilePickerFactory;
29
import com.keenwrite.ui.heuristics.DocumentStatistics;
30
import com.keenwrite.ui.outline.DocumentOutline;
31
import com.keenwrite.ui.spelling.TextEditorSpellChecker;
32
import com.keenwrite.util.GenericBuilder;
33
import com.panemu.tiwulfx.control.dock.DetachableTab;
34
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
35
import javafx.application.Platform;
36
import javafx.beans.property.*;
37
import javafx.collections.ListChangeListener;
38
import javafx.concurrent.Task;
39
import javafx.event.ActionEvent;
40
import javafx.event.Event;
41
import javafx.event.EventHandler;
42
import javafx.scene.Node;
43
import javafx.scene.Scene;
44
import javafx.scene.control.*;
45
import javafx.scene.control.TreeItem.TreeModificationEvent;
46
import javafx.scene.input.KeyEvent;
47
import javafx.scene.layout.FlowPane;
48
import javafx.stage.Stage;
49
import javafx.stage.Window;
50
import org.greenrobot.eventbus.Subscribe;
51
52
import java.io.File;
53
import java.io.FileNotFoundException;
54
import java.nio.file.Path;
55
import java.util.*;
56
import java.util.concurrent.ExecutorService;
57
import java.util.concurrent.ScheduledExecutorService;
58
import java.util.concurrent.ScheduledFuture;
59
import java.util.concurrent.atomic.AtomicBoolean;
60
import java.util.concurrent.atomic.AtomicReference;
61
import java.util.function.Consumer;
62
import java.util.function.Function;
63
import java.util.stream.Collectors;
64
65
import static com.keenwrite.ExportFormat.NONE;
66
import static com.keenwrite.Launcher.terminate;
67
import static com.keenwrite.Messages.get;
68
import static com.keenwrite.constants.Constants.*;
69
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
70
import static com.keenwrite.events.Bus.register;
71
import static com.keenwrite.events.StatusEvent.clue;
72
import static com.keenwrite.io.MediaType.*;
73
import static com.keenwrite.preferences.AppKeys.*;
74
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
75
import static com.keenwrite.processors.ProcessorContext.Mutator;
76
import static com.keenwrite.processors.ProcessorContext.builder;
77
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
78
import static java.lang.String.format;
79
import static java.lang.System.getProperty;
80
import static java.util.concurrent.Executors.newFixedThreadPool;
81
import static java.util.concurrent.Executors.newScheduledThreadPool;
82
import static java.util.concurrent.TimeUnit.SECONDS;
83
import static java.util.stream.Collectors.groupingBy;
84
import static javafx.application.Platform.runLater;
85
import static javafx.scene.control.Alert.AlertType.ERROR;
86
import static javafx.scene.control.ButtonType.*;
87
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
88
import static javafx.scene.input.KeyCode.ENTER;
89
import static javafx.scene.input.KeyCode.SPACE;
90
import static javafx.scene.input.KeyCombination.ALT_DOWN;
91
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
92
import static javafx.util.Duration.millis;
93
import static javax.swing.SwingUtilities.invokeLater;
94
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
95
96
/**
97
 * Responsible for wiring together the main application components for a
98
 * particular {@link Workspace} (project). These include the definition views,
99
 * text editors, and preview pane along with any corresponding controllers.
100
 */
101
public final class MainPane extends SplitPane {
102
103
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
104
  private static final Notifier sNotifier = Services.load( Notifier.class );
105
106
  /**
107
   * Used when opening files to determine how each file should be binned and
108
   * therefore what tab pane to be opened within.
109
   */
110
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
111
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
112
  );
113
114
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
115
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
116
    new AtomicReference<>();
117
118
  /**
119
   * Prevents re-instantiation of processing classes.
120
   */
121
  private final Map<TextResource, Processor<String>> mProcessors =
122
    new HashMap<>();
123
124
  private final Workspace mWorkspace;
125
126
  /**
127
   * Groups similar file type tabs together.
128
   */
129
  private final List<TabPane> mTabPanes = new ArrayList<>();
130
131
  /**
132
   * Renders the actively selected plain text editor tab.
133
   */
134
  private final HtmlPreview mPreview;
135
136
  /**
137
   * Provides an interactive document outline.
138
   */
139
  private final DocumentOutline mOutline = new DocumentOutline();
140
141
  /**
142
   * Changing the active editor fires the value changed event. This allows
143
   * refreshes to happen when external definitions are modified and need to
144
   * trigger the processing chain.
145
   */
146
  private final ObjectProperty<TextEditor> mTextEditor =
147
    createActiveTextEditor();
148
149
  /**
150
   * Changing the active definition editor fires the value changed event. This
151
   * allows refreshes to happen when external definitions are modified and need
152
   * to trigger the processing chain.
153
   */
154
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
155
156
  private final ObjectProperty<SpellChecker> mSpellChecker;
157
158
  private final TextEditorSpellChecker mEditorSpeller;
159
160
  /**
161
   * Called when the definition data is changed.
162
   */
163
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
164
    event -> {
165
      process( getTextEditor() );
166
      save( getTextDefinition() );
167
    };
168
169
  /**
170
   * Tracks the number of detached tab panels opened into their own windows,
171
   * which allows unique identification of subordinate windows by their title.
172
   * It is doubtful more than 128 windows, much less 256, will be created.
173
   */
174
  private byte mWindowCount;
175
176
  private final VariableNameInjector mVariableNameInjector;
177
178
  private final RBootstrapController mRBootstrapController;
179
180
  private final DocumentStatistics mStatistics;
181
182
  /**
183
   * Adds all content panels to the main user interface. This will load the
184
   * configuration settings from the workspace to reproduce the settings from
185
   * a previous session.
186
   */
187
  public MainPane( final Workspace workspace ) {
188
    mWorkspace = workspace;
189
    mSpellChecker = createSpellChecker();
190
    mEditorSpeller = createTextEditorSpellChecker( mSpellChecker );
191
    mPreview = new HtmlPreview( workspace );
192
    mStatistics = new DocumentStatistics( workspace );
193
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
194
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
195
    mVariableNameInjector = new VariableNameInjector( mWorkspace );
196
    mRBootstrapController = new RBootstrapController(
197
      mWorkspace, this::getDefinitions );
198
199
    open( collect( getRecentFiles() ) );
200
    viewPreview();
201
    setDividerPositions( calculateDividerPositions() );
202
203
    // Once the main scene's window regains focus, update the active definition
204
    // editor to the currently selected tab.
205
    runLater( () -> getWindow().setOnCloseRequest( event -> {
206
      // Order matters: Open file names must be persisted before closing all.
207
      mWorkspace.save();
208
209
      if( closeAll() ) {
210
        Platform.exit();
211
        terminate( 0 );
212
      }
213
214
      event.consume();
215
    } ) );
216
217
    register( this );
218
    initAutosave( workspace );
219
220
    restoreSession();
221
    runLater( this::restoreFocus );
222
  }
223
224
  /**
225
   * Called when spellchecking can be run. This will reload the dictionary
226
   * into memory once, and then re-use it for all the existing text editors.
227
   *
228
   * @param event The event to process, having a populated word-frequency map.
229
   */
230
  @Subscribe
231
  public void handle( final LexiconLoadedEvent event ) {
232
    final var lexicon = event.getLexicon();
233
234
    try {
235
      final var checker = SymSpellSpeller.forLexicon( lexicon );
236
      mSpellChecker.set( checker );
237
    } catch( final Exception ex ) {
238
      clue( ex );
239
    }
240
  }
241
242
  @Subscribe
243
  public void handle( final TextEditorFocusEvent event ) {
244
    mTextEditor.set( event.get() );
245
  }
246
247
  @Subscribe
248
  public void handle( final TextDefinitionFocusEvent event ) {
249
    mDefinitionEditor.set( event.get() );
250
  }
251
252
  /**
253
   * Typically called when a file name is clicked in the preview panel.
254
   *
255
   * @param event The event to process, must contain a valid file reference.
256
   */
257
  @Subscribe
258
  public void handle( final FileOpenEvent event ) {
259
    final File eventFile;
260
    final var eventUri = event.getUri();
261
262
    if( eventUri.isAbsolute() ) {
263
      eventFile = new File( eventUri.getPath() );
264
    }
265
    else {
266
      final var activeFile = getTextEditor().getFile();
267
      final var parent = activeFile.getParentFile();
268
269
      if( parent == null ) {
270
        clue( new FileNotFoundException( eventUri.getPath() ) );
271
        return;
272
      }
273
      else {
274
        final var parentPath = parent.getAbsolutePath();
275
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
276
      }
277
    }
278
279
    runLater( () -> open( eventFile ) );
280
  }
281
282
  @Subscribe
283
  public void handle( final CaretNavigationEvent event ) {
284
    runLater( () -> {
285
      final var textArea = getTextEditor();
286
      textArea.moveTo( event.getOffset() );
287
      textArea.requestFocus();
288
    } );
289
  }
290
291
  @Subscribe
292
  @SuppressWarnings( "unused" )
293
  public void handle( final ExportFailedEvent event ) {
294
    final var os = getProperty( "os.name" );
295
    final var arch = getProperty( "os.arch" ).toLowerCase();
296
    final var bits = getProperty( "sun.arch.data.model" );
297
298
    final var title = Messages.get( "Alert.typesetter.missing.title" );
299
    final var header = Messages.get( "Alert.typesetter.missing.header" );
300
    final var version = Messages.get(
301
      "Alert.typesetter.missing.version",
302
      os,
303
      arch
304
        .replaceAll( "amd.*|i.*|x86.*", "X86" )
305
        .replaceAll( "mips.*", "MIPS" )
306
        .replaceAll( "armv.*", "ARM" ),
307
      bits );
308
    final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
309
310
    // Download and install ConTeXt for {0} {1} {2}-bit
311
    final var content = format( "%s %s", text, version );
312
    final var flowPane = new FlowPane();
313
    final var link = new Hyperlink( text );
314
    final var label = new Label( version );
315
    flowPane.getChildren().addAll( link, label );
316
317
    final var alert = new Alert( ERROR, content, OK );
318
    alert.setTitle( title );
319
    alert.setHeaderText( header );
320
    alert.getDialogPane().contentProperty().set( flowPane );
321
    alert.setGraphic( ICON_DIALOG_NODE );
322
323
    link.setOnAction( e -> {
324
      alert.close();
325
      final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
326
      runLater( () -> HyperlinkOpenEvent.fire( url ) );
327
    } );
328
329
    alert.showAndWait();
330
  }
331
332
  @Subscribe
333
  public void handle( final InsertDefinitionEvent<String> event ) {
334
    final var leaf = event.getLeaf();
335
    final var editor = mTextEditor.get();
336
337
    mVariableNameInjector.insert( editor, leaf );
338
  }
339
340
  private void initAutosave( final Workspace workspace ) {
341
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
342
343
    rate.addListener(
344
      ( c, o, n ) -> {
345
        final var taskRef = mSaveTask.get();
346
347
        // Prevent multiple autosaves from running.
348
        if( taskRef != null ) {
349
          taskRef.cancel( false );
350
        }
351
352
        initAutosave( rate );
353
      }
354
    );
355
356
    // Start the save listener (avoids duplicating some code).
357
    initAutosave( rate );
358
  }
359
360
  private void initAutosave( final IntegerProperty rate ) {
361
    mSaveTask.set(
362
      mSaver.scheduleAtFixedRate(
363
        () -> {
364
          if( getTextEditor().isModified() ) {
365
            // Ensure the modified indicator is cleared by running on EDT.
366
            runLater( this::save );
367
          }
368
        }, 0, rate.intValue(), SECONDS
369
      )
370
    );
371
  }
372
373
  /**
374
   * TODO: Load divider positions from exported settings, see
375
   *   {@link #collect(SetProperty)} comment.
376
   */
377
  private double[] calculateDividerPositions() {
378
    final var ratio = 100f / getItems().size() / 100;
379
    final var positions = getDividerPositions();
380
381
    for( int i = 0; i < positions.length; i++ ) {
382
      positions[ i ] = ratio * i;
383
    }
384
385
    return positions;
386
  }
387
388
  /**
389
   * Opens all the files into the application, provided the paths are unique.
390
   * This may only be called for any type of files that a user can edit
391
   * (i.e., update and persist), such as definitions and text files.
392
   *
393
   * @param files The list of files to open.
394
   */
395
  public void open( final List<File> files ) {
396
    files.forEach( this::open );
397
  }
398
399
  /**
400
   * This opens the given file. Since the preview pane is not a file that
401
   * can be opened, it is safe to add a listener to the detachable pane.
402
   * This will exit early if the given file is not a regular file (i.e., a
403
   * directory).
404
   *
405
   * @param inputFile The file to open.
406
   */
407
  private void open( final File inputFile ) {
408
    // Prevent opening directories (a non-existent "untitled.md" is fine).
409
    if( !inputFile.isFile() && inputFile.exists() ) {
410
      return;
411
    }
412
413
    final var tab = createTab( inputFile );
414
    final var node = tab.getContent();
415
    final var mediaType = MediaType.valueFrom( inputFile );
416
    final var tabPane = obtainTabPane( mediaType );
417
418
    tab.setTooltip( createTooltip( inputFile ) );
419
    tabPane.setFocusTraversable( false );
420
    tabPane.setTabClosingPolicy( ALL_TABS );
421
    tabPane.getTabs().add( tab );
422
423
    // Attach the tab scene factory for new tab panes.
424
    if( !getItems().contains( tabPane ) ) {
425
      addTabPane(
426
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
427
      );
428
    }
429
430
    if( inputFile.isFile() ) {
431
      getRecentFiles().add( inputFile.getAbsolutePath() );
432
    }
433
  }
434
435
  /**
436
   * Gives focus to the most recently edited document and attempts to move
437
   * the caret to the most recently known offset into said document.
438
   */
439
  private void restoreSession() {
440
    final var workspace = getWorkspace();
441
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
442
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
443
444
    for( final var pane : mTabPanes ) {
445
      for( final var tab : pane.getTabs() ) {
446
        final var tooltip = tab.getTooltip();
447
448
        if( tooltip != null ) {
449
          final var tabName = tooltip.getText();
450
          final var fileName = file.getValue().toString();
451
452
          if( tabName.equalsIgnoreCase( fileName ) ) {
453
            final var node = tab.getContent();
454
455
            pane.getSelectionModel().select( tab );
456
            node.requestFocus();
457
458
            if( node instanceof TextEditor editor ) {
459
              editor.moveTo( offset.getValue() );
460
            }
461
462
            break;
463
          }
464
        }
465
      }
466
    }
467
  }
468
469
  /**
470
   * Sets the focus to the middle pane, which contains the text editor tabs.
471
   */
472
  private void restoreFocus() {
473
    // Work around a bug where focusing directly on the middle pane results
474
    // in the R engine not loading variables properly.
475
    mTabPanes.get( 0 ).requestFocus();
476
477
    // This is the only line that should be required.
478
    mTabPanes.get( 1 ).requestFocus();
479
  }
480
481
  /**
482
   * Opens a new text editor document using the default document file name.
483
   */
484
  public void newTextEditor() {
485
    open( DOCUMENT_DEFAULT );
486
  }
487
488
  /**
489
   * Opens a new definition editor document using the default definition
490
   * file name.
491
   */
492
  public void newDefinitionEditor() {
493
    open( DEFINITION_DEFAULT );
494
  }
495
496
  /**
497
   * Iterates over all tab panes to find all {@link TextEditor}s and request
498
   * that they save themselves.
499
   */
500
  public void saveAll() {
501
    iterateEditors( this::save );
502
  }
503
504
  /**
505
   * Requests that the active {@link TextEditor} saves itself. Don't bother
506
   * checking if modified first because if the user swaps external media from
507
   * an external source (e.g., USB thumb drive), save should not second-guess
508
   * the user: save always re-saves. Also, it's less code.
509
   */
510
  public void save() {
511
    save( getTextEditor() );
512
  }
513
514
  /**
515
   * Saves the active {@link TextEditor} under a new name.
516
   *
517
   * @param files The new active editor {@link File} reference, must contain
518
   *              at least one element.
519
   */
520
  public void saveAs( final List<File> files ) {
521
    assert files != null;
522
    assert !files.isEmpty();
523
    final var editor = getTextEditor();
524
    final var tab = getTab( editor );
525
    final var file = files.get( 0 );
526
527
    editor.rename( file );
528
    tab.ifPresent( t -> {
529
      t.setText( editor.getFilename() );
530
      t.setTooltip( createTooltip( file ) );
531
    } );
532
533
    save();
534
  }
535
536
  /**
537
   * Saves the given {@link TextResource} to a file. This is typically used
538
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
539
   *
540
   * @param resource The resource to export.
541
   */
542
  private void save( final TextResource resource ) {
543
    try {
544
      resource.save();
545
    } catch( final Exception ex ) {
546
      clue( ex );
547
      sNotifier.alert(
548
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
549
      );
550
    }
551
  }
552
553
  /**
554
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
555
   *
556
   * @return {@code true} when all editors, modified or otherwise, were
557
   * permitted to close; {@code false} when one or more editors were modified
558
   * and the user requested no closing.
559
   */
560
  public boolean closeAll() {
561
    var closable = true;
562
563
    for( final var tabPane : mTabPanes ) {
564
      final var tabIterator = tabPane.getTabs().iterator();
565
566
      while( tabIterator.hasNext() ) {
567
        final var tab = tabIterator.next();
568
        final var resource = tab.getContent();
569
570
        // The definition panes auto-save, so being specific here prevents
571
        // closing the definitions in the situation where the user wants to
572
        // continue editing (i.e., possibly save unsaved work).
573
        if( !(resource instanceof TextEditor) ) {
574
          continue;
575
        }
576
577
        if( canClose( (TextEditor) resource ) ) {
578
          tabIterator.remove();
579
          close( tab );
580
        }
581
        else {
582
          closable = false;
583
        }
584
      }
585
    }
586
587
    return closable;
588
  }
589
590
  /**
591
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
592
   * event.
593
   *
594
   * @param tab The {@link Tab} that was closed.
595
   */
596
  private void close( final Tab tab ) {
597
    assert tab != null;
598
599
    final var handler = tab.getOnClosed();
600
601
    if( handler != null ) {
602
      handler.handle( new ActionEvent() );
603
    }
604
  }
605
606
  /**
607
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
608
   */
609
  public void close() {
610
    final var editor = getTextEditor();
611
612
    if( canClose( editor ) ) {
613
      close( editor );
614
    }
615
  }
616
617
  /**
618
   * Closes the given {@link TextResource}. This must not be called from within
619
   * a loop that iterates over the tab panes using {@code forEach}, lest a
620
   * concurrent modification exception be thrown.
621
   *
622
   * @param resource The {@link TextResource} to close, without confirming with
623
   *                 the user.
624
   */
625
  private void close( final TextResource resource ) {
626
    getTab( resource ).ifPresent(
627
      tab -> {
628
        close( tab );
629
        tab.getTabPane().getTabs().remove( tab );
630
      }
631
    );
632
  }
633
634
  /**
635
   * Answers whether the given {@link TextResource} may be closed.
636
   *
637
   * @param editor The {@link TextResource} to try closing.
638
   * @return {@code true} when the editor may be closed; {@code false} when
639
   * the user has requested to keep the editor open.
640
   */
641
  private boolean canClose( final TextResource editor ) {
642
    final var editorTab = getTab( editor );
643
    final var canClose = new AtomicBoolean( true );
644
645
    if( editor.isModified() ) {
646
      final var filename = new StringBuilder();
647
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
648
649
      final var message = sNotifier.createNotification(
650
        Messages.get( "Alert.file.close.title" ),
651
        Messages.get( "Alert.file.close.text" ),
652
        filename.toString()
653
      );
654
655
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
656
657
      dialog.showAndWait().ifPresent(
658
        save -> canClose.set( save == YES ? editor.save() : save == NO )
659
      );
660
    }
661
662
    return canClose.get();
663
  }
664
665
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
666
    mTabPanes.forEach(
667
      tp -> tp.getTabs().forEach( tab -> {
668
        final var node = tab.getContent();
669
670
        if( node instanceof final TextEditor editor ) {
671
          consumer.accept( editor );
672
        }
673
      } )
674
    );
675
  }
676
677
  private ObjectProperty<TextEditor> createActiveTextEditor() {
678
    final var editor = new SimpleObjectProperty<TextEditor>();
679
680
    editor.addListener( ( c, o, n ) -> {
681
      if( n != null ) {
682
        mPreview.setBaseUri( n.getPath() );
683
        process( n );
684
      }
685
    } );
686
687
    return editor;
688
  }
689
690
  /**
691
   * Adds the HTML preview tab to its own, singular tab pane.
692
   */
693
  public void viewPreview() {
694
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
695
  }
696
697
  /**
698
   * Adds the document outline tab to its own, singular tab pane.
699
   */
700
  public void viewOutline() {
701
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
702
  }
703
704
  public void viewStatistics() {
705
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
706
  }
707
708
  public void viewFiles() {
709
    try {
710
      final var factory = new FilePickerFactory( getWorkspace() );
711
      final var fileManager = factory.createModeless();
712
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
713
    } catch( final Exception ex ) {
714
      clue( ex );
715
    }
716
  }
717
718
  private void viewTab(
719
    final Node node, final MediaType mediaType, final String key ) {
720
    final var tabPane = obtainTabPane( mediaType );
721
722
    for( final var tab : tabPane.getTabs() ) {
723
      if( tab.getContent() == node ) {
724
        return;
725
      }
726
    }
727
728
    tabPane.getTabs().add( createTab( get( key ), node ) );
729
    addTabPane( tabPane );
730
  }
731
732
  public void viewRefresh() {
733
    mPreview.refresh();
734
    Engine.clear();
735
    mRBootstrapController.update();
736
  }
737
738
  /**
739
   * Returns the tab that contains the given {@link TextEditor}.
740
   *
741
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
742
   * @return The first tab having content that matches the given tab.
743
   */
744
  private Optional<Tab> getTab( final TextResource editor ) {
745
    return mTabPanes.stream()
746
                    .flatMap( pane -> pane.getTabs().stream() )
747
                    .filter( tab -> editor.equals( tab.getContent() ) )
748
                    .findFirst();
749
  }
750
751
  /**
752
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
753
   * is used to detect when the active {@link DefinitionEditor} has changed.
754
   * Upon changing, the variables are interpolated and the active text editor
755
   * is refreshed.
756
   *
757
   * @param textEditor Text editor to update with the revised resolved map.
758
   * @return A newly configured property that represents the active
759
   * {@link DefinitionEditor}, never null.
760
   */
761
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
762
    final ObjectProperty<TextEditor> textEditor ) {
763
    final var defEditor = new SimpleObjectProperty<>(
764
      createDefinitionEditor()
765
    );
766
767
    defEditor.addListener( ( c, o, n ) -> {
768
      final var editor = textEditor.get();
769
770
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
771
        // Initialize R before the editor is added.
772
        mRBootstrapController.update();
773
      }
774
775
      process( editor );
776
    } );
777
778
    return defEditor;
779
  }
780
781
  private Tab createTab( final String filename, final Node node ) {
782
    return new DetachableTab( filename, node );
783
  }
784
785
  private Tab createTab( final File file ) {
786
    final var r = createTextResource( file );
787
    final var tab = createTab( r.getFilename(), r.getNode() );
788
789
    r.modifiedProperty().addListener(
790
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
791
    );
792
793
    // This is called when either the tab is closed by the user clicking on
794
    // the tab's close icon or when closing (all) from the file menu.
795
    tab.setOnClosed(
796
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
797
    );
798
799
    // When closing a tab, give focus to the newly revealed tab.
800
    tab.selectedProperty().addListener( ( c, o, n ) -> {
801
      if( n != null && n ) {
802
        final var pane = tab.getTabPane();
803
804
        if( pane != null ) {
805
          pane.requestFocus();
806
        }
807
      }
808
    } );
809
810
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
811
      if( nPane != null ) {
812
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
813
          if( n != null && n ) {
814
            final var selected = nPane.getSelectionModel().getSelectedItem();
815
            final var node = selected.getContent();
816
            node.requestFocus();
817
          }
818
        } );
819
      }
820
    } );
821
822
    return tab;
823
  }
824
825
  /**
826
   * Creates bins for the different {@link MediaType}s, which eventually are
827
   * added to the UI as separate tab panes. If ever a general-purpose scene
828
   * exporter is developed to serialize a scene to an FXML file, this could
829
   * be replaced by such a class.
830
   * <p>
831
   * When binning the files, this makes sure that at least one file exists
832
   * for every type. If the user has opted to close a particular type (such
833
   * as the definition pane), the view will suppressed elsewhere.
834
   * </p>
835
   * <p>
836
   * The order that the binned files are returned will be reflected in the
837
   * order that the corresponding panes are rendered in the UI.
838
   * </p>
839
   *
840
   * @param paths The file paths to bin according to their type.
841
   * @return An in-order list of files, first by structured definition files,
842
   * then by plain text documents.
843
   */
844
  private List<File> collect( final SetProperty<String> paths ) {
845
    // Treat all files destined for the text editor as plain text documents
846
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
847
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
848
    final Function<MediaType, MediaType> bin =
849
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
850
851
    // Create two groups: YAML files and plain text files. The order that
852
    // the elements are listed in the enumeration for media types determines
853
    // what files are loaded first. Variable definitions come before all other
854
    // plain text documents.
855
    final var bins = paths
856
      .stream()
857
      .collect(
858
        groupingBy(
859
          path -> bin.apply( MediaType.fromFilename( path ) ),
860
          () -> new TreeMap<>( Enum::compareTo ),
861
          Collectors.toList()
862
        )
863
      );
864
865
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
866
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
867
868
    final var result = new LinkedList<File>();
869
870
    // Ensure that the same types are listed together (keep insertion order).
871
    bins.forEach( ( mediaType, files ) -> result.addAll(
872
      files.stream().map( File::new ).toList() )
873
    );
874
875
    return result;
876
  }
877
878
  /**
879
   * Force the active editor to update, which will cause the processor
880
   * to re-evaluate the interpolated definition map thereby updating the
881
   * preview pane.
882
   *
883
   * @param editor Contains the source document to update in the preview pane.
884
   */
885
  private void process( final TextEditor editor ) {
886
    // Ensure processing does not run on the JavaFX thread, which frees the
887
    // text editor immediately for caret movement. The preview will have a
888
    // slight delay when catching up to the caret position.
889
    final var task = new Task<Void>() {
890
      @Override
891
      public Void call() {
892
        try {
893
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
894
          p.apply( editor == null ? "" : editor.getText() );
895
        } catch( final Exception ex ) {
896
          clue( ex );
897
        }
898
899
        return null;
900
      }
901
    };
902
903
    // TODO: Each time the editor successfully runs the processor the task is
904
    //   considered successful. Due to the rapid-fire nature of processing
905
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
906
    //   scroll each time.
907
    //   The algorithm:
908
    //   1. Peek at the oldest time.
909
    //   2. If the difference between the oldest time and current time exceeds
910
    //      250 milliseconds, then invoke the scrolling.
911
    //   3. Insert the current time into the circular queue.
912
    task.setOnSucceeded(
913
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
914
    );
915
916
    // Prevents multiple process requests from executing simultaneously (due
917
    // to having a restricted queue size).
918
    sExecutor.execute( task );
919
  }
920
921
  /**
922
   * Lazily creates a {@link TabPane} configured to listen for tab select
923
   * events. The tab pane is associated with a given media type so that
924
   * similar files can be grouped together.
925
   *
926
   * @param mediaType The media type to associate with the tab pane.
927
   * @return An instance of {@link TabPane} that will handle tab docking.
928
   */
929
  private TabPane obtainTabPane( final MediaType mediaType ) {
930
    for( final var pane : mTabPanes ) {
931
      for( final var tab : pane.getTabs() ) {
932
        final var node = tab.getContent();
933
934
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
935
          return pane;
936
        }
937
      }
938
    }
939
940
    final var pane = createTabPane();
941
    mTabPanes.add( pane );
942
    return pane;
943
  }
944
945
  /**
946
   * Creates an initialized {@link TabPane} instance.
947
   *
948
   * @return A new {@link TabPane} with all listeners configured.
949
   */
950
  private TabPane createTabPane() {
951
    final var tabPane = new DetachableTabPane();
952
953
    initStageOwnerFactory( tabPane );
954
    initTabListener( tabPane );
955
956
    return tabPane;
957
  }
958
959
  /**
960
   * When any {@link DetachableTabPane} is detached from the main window,
961
   * the stage owner factory must be given its parent window, which will
962
   * own the child window. The parent window is the {@link MainPane}'s
963
   * {@link Scene}'s {@link Window} instance.
964
   *
965
   * <p>
966
   * This will derives the new title from the main window title, incrementing
967
   * the window count to help uniquely identify the child windows.
968
   * </p>
969
   *
970
   * @param tabPane A new {@link DetachableTabPane} to configure.
971
   */
972
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
973
    tabPane.setStageOwnerFactory( stage -> {
974
      final var title = get(
975
        "Detach.tab.title",
976
        ((Stage) getWindow()).getTitle(), ++mWindowCount
977
      );
978
      stage.setTitle( title );
979
980
      return getScene().getWindow();
981
    } );
982
  }
983
984
  /**
985
   * Responsible for configuring the content of each {@link DetachableTab} when
986
   * it is added to the given {@link DetachableTabPane} instance.
987
   * <p>
988
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
989
   * is initialized to perform synchronized scrolling between the editor and
990
   * its preview window. Additionally, the last tab in the tab pane's list of
991
   * tabs is given focus.
992
   * </p>
993
   * <p>
994
   * Note that multiple tabs can be added simultaneously.
995
   * </p>
996
   *
997
   * @param tabPane A new {@link TabPane} to configure.
998
   */
999
  private void initTabListener( final TabPane tabPane ) {
1000
    tabPane.getTabs().addListener(
1001
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
1002
        while( listener.next() ) {
1003
          if( listener.wasAdded() ) {
1004
            final var tabs = listener.getAddedSubList();
1005
1006
            tabs.forEach( tab -> {
1007
              final var node = tab.getContent();
1008
1009
              if( node instanceof TextEditor ) {
1010
                initScrollEventListener( tab );
1011
              }
1012
            } );
1013
1014
            // Select and give focus to the last tab opened.
1015
            final var index = tabs.size() - 1;
1016
            if( index >= 0 ) {
1017
              final var tab = tabs.get( index );
1018
              tabPane.getSelectionModel().select( tab );
1019
              tab.getContent().requestFocus();
1020
            }
1021
          }
1022
        }
1023
      }
1024
    );
1025
  }
1026
1027
  /**
1028
   * Synchronizes scrollbar positions between the given {@link Tab} that
1029
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
1030
   *
1031
   * @param tab The container for an instance of {@link TextEditor}.
1032
   */
1033
  private void initScrollEventListener( final Tab tab ) {
1034
    final var editor = (TextEditor) tab.getContent();
1035
    final var scrollPane = editor.getScrollPane();
1036
    final var scrollBar = mPreview.getVerticalScrollBar();
1037
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1038
1039
    handler.enabledProperty().bind( tab.selectedProperty() );
1040
  }
1041
1042
  private void addTabPane( final int index, final TabPane tabPane ) {
1043
    final var items = getItems();
1044
1045
    if( !items.contains( tabPane ) ) {
1046
      items.add( index, tabPane );
1047
    }
1048
  }
1049
1050
  private void addTabPane( final TabPane tabPane ) {
1051
    addTabPane( getItems().size(), tabPane );
1052
  }
1053
1054
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1055
    final var w = getWorkspace();
1056
1057
    return builder()
1058
      .with( Mutator::setDefinitions, this::getDefinitions )
1059
      .with( Mutator::setLocale, w::getLocale )
1060
      .with( Mutator::setMetadata, w::getMetadata )
1061
      .with( Mutator::setThemePath, w::getThemePath )
1062
      .with( Mutator::setCaret,
1063
             () -> getTextEditor().getCaret() )
1064
      .with( Mutator::setImageDir,
1065
             () -> w.getFile( KEY_IMAGES_DIR ) )
1066
      .with( Mutator::setImageOrder,
1067
             () -> w.getString( KEY_IMAGES_ORDER ) )
1068
      .with( Mutator::setImageServer,
1069
             () -> w.getString( KEY_IMAGES_SERVER ) )
1070
      .with( Mutator::setSigilBegan,
1071
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1072
      .with( Mutator::setSigilEnded,
1073
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1074
      .with( Mutator::setRScript,
1075
             () -> w.getString( KEY_R_SCRIPT ) )
1076
      .with( Mutator::setRWorkingDir,
1077
             () -> w.getFile( KEY_R_DIR ).toPath() )
1078
      .with( Mutator::setCurlQuotes,
1079
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1080
      .with( Mutator::setAutoClean,
1081
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1082
  }
1083
1084
  public ProcessorContext createProcessorContext() {
1085
    return createProcessorContext( null, NONE );
1086
  }
1087
1088
  /**
1089
   * @param outputPath Used when exporting to a PDF file (binary).
1090
   * @param format     Used when processors export to a new text format.
1091
   * @return A new {@link ProcessorContext} to use when creating an instance of
1092
   * {@link Processor}.
1093
   */
1094
  public ProcessorContext createProcessorContext(
1095
    final Path outputPath, final ExportFormat format ) {
1096
    final var textEditor = getTextEditor();
1097
    final var inputPath = textEditor.getPath();
1098
1099
    return processorContextBuilder()
1100
      .with( Mutator::setInputPath, inputPath )
1101
      .with( Mutator::setOutputPath, outputPath )
1102
      .with( Mutator::setExportFormat, format )
1103
      .build();
1104
  }
1105
1106
  /**
1107
   * @param inputPath Used by {@link ProcessorFactory} to determine
1108
   *                  {@link Processor} type to create based on file type.
1109
   * @return A new {@link ProcessorContext} to use when creating an instance of
1110
   * {@link Processor}.
1111
   */
1112
  private ProcessorContext createProcessorContext( final Path inputPath ) {
1113
    return processorContextBuilder()
1114
      .with( Mutator::setInputPath, inputPath )
28
import com.keenwrite.typesetting.installer.TypesetterInstaller;
29
import com.keenwrite.ui.explorer.FilePickerFactory;
30
import com.keenwrite.ui.heuristics.DocumentStatistics;
31
import com.keenwrite.ui.outline.DocumentOutline;
32
import com.keenwrite.ui.spelling.TextEditorSpellChecker;
33
import com.keenwrite.util.GenericBuilder;
34
import com.panemu.tiwulfx.control.dock.DetachableTab;
35
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
36
import javafx.application.Platform;
37
import javafx.beans.property.*;
38
import javafx.collections.ListChangeListener;
39
import javafx.concurrent.Task;
40
import javafx.event.ActionEvent;
41
import javafx.event.Event;
42
import javafx.event.EventHandler;
43
import javafx.scene.Node;
44
import javafx.scene.Scene;
45
import javafx.scene.control.SplitPane;
46
import javafx.scene.control.Tab;
47
import javafx.scene.control.TabPane;
48
import javafx.scene.control.Tooltip;
49
import javafx.scene.control.TreeItem.TreeModificationEvent;
50
import javafx.scene.input.KeyEvent;
51
import javafx.stage.Stage;
52
import javafx.stage.Window;
53
import org.greenrobot.eventbus.Subscribe;
54
55
import java.io.File;
56
import java.io.FileNotFoundException;
57
import java.nio.file.Path;
58
import java.util.*;
59
import java.util.concurrent.ExecutorService;
60
import java.util.concurrent.ScheduledExecutorService;
61
import java.util.concurrent.ScheduledFuture;
62
import java.util.concurrent.atomic.AtomicBoolean;
63
import java.util.concurrent.atomic.AtomicReference;
64
import java.util.function.Consumer;
65
import java.util.function.Function;
66
import java.util.stream.Collectors;
67
68
import static com.keenwrite.ExportFormat.NONE;
69
import static com.keenwrite.Launcher.terminate;
70
import static com.keenwrite.Messages.get;
71
import static com.keenwrite.constants.Constants.*;
72
import static com.keenwrite.events.Bus.register;
73
import static com.keenwrite.events.StatusEvent.clue;
74
import static com.keenwrite.io.MediaType.*;
75
import static com.keenwrite.preferences.AppKeys.*;
76
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
77
import static com.keenwrite.processors.ProcessorContext.Mutator;
78
import static com.keenwrite.processors.ProcessorContext.builder;
79
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
80
import static java.util.concurrent.Executors.newFixedThreadPool;
81
import static java.util.concurrent.Executors.newScheduledThreadPool;
82
import static java.util.concurrent.TimeUnit.SECONDS;
83
import static java.util.stream.Collectors.groupingBy;
84
import static javafx.application.Platform.runLater;
85
import static javafx.scene.control.ButtonType.NO;
86
import static javafx.scene.control.ButtonType.YES;
87
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
88
import static javafx.scene.input.KeyCode.ENTER;
89
import static javafx.scene.input.KeyCode.SPACE;
90
import static javafx.scene.input.KeyCombination.ALT_DOWN;
91
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
92
import static javafx.util.Duration.millis;
93
import static javax.swing.SwingUtilities.invokeLater;
94
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
95
96
/**
97
 * Responsible for wiring together the main application components for a
98
 * particular {@link Workspace} (project). These include the definition views,
99
 * text editors, and preview pane along with any corresponding controllers.
100
 */
101
public final class MainPane extends SplitPane {
102
103
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
104
  private static final Notifier sNotifier = Services.load( Notifier.class );
105
106
  /**
107
   * Used when opening files to determine how each file should be binned and
108
   * therefore what tab pane to be opened within.
109
   */
110
  private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
111
    TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
112
  );
113
114
  private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
115
  private final AtomicReference<ScheduledFuture<?>> mSaveTask =
116
    new AtomicReference<>();
117
118
  /**
119
   * Prevents re-instantiation of processing classes.
120
   */
121
  private final Map<TextResource, Processor<String>> mProcessors =
122
    new HashMap<>();
123
124
  private final Workspace mWorkspace;
125
126
  /**
127
   * Groups similar file type tabs together.
128
   */
129
  private final List<TabPane> mTabPanes = new ArrayList<>();
130
131
  /**
132
   * Renders the actively selected plain text editor tab.
133
   */
134
  private final HtmlPreview mPreview;
135
136
  /**
137
   * Provides an interactive document outline.
138
   */
139
  private final DocumentOutline mOutline = new DocumentOutline();
140
141
  /**
142
   * Changing the active editor fires the value changed event. This allows
143
   * refreshes to happen when external definitions are modified and need to
144
   * trigger the processing chain.
145
   */
146
  private final ObjectProperty<TextEditor> mTextEditor =
147
    createActiveTextEditor();
148
149
  /**
150
   * Changing the active definition editor fires the value changed event. This
151
   * allows refreshes to happen when external definitions are modified and need
152
   * to trigger the processing chain.
153
   */
154
  private final ObjectProperty<TextDefinition> mDefinitionEditor;
155
156
  private final ObjectProperty<SpellChecker> mSpellChecker;
157
158
  private final TextEditorSpellChecker mEditorSpeller;
159
160
  /**
161
   * Called when the definition data is changed.
162
   */
163
  private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
164
    event -> {
165
      process( getTextEditor() );
166
      save( getTextDefinition() );
167
    };
168
169
  /**
170
   * Tracks the number of detached tab panels opened into their own windows,
171
   * which allows unique identification of subordinate windows by their title.
172
   * It is doubtful more than 128 windows, much less 256, will be created.
173
   */
174
  private byte mWindowCount;
175
176
  private final VariableNameInjector mVariableNameInjector;
177
178
  private final RBootstrapController mRBootstrapController;
179
180
  private final DocumentStatistics mStatistics;
181
182
  @SuppressWarnings( {"FieldCanBeLocal", "unused"} )
183
  private final TypesetterInstaller mInstallWizard;
184
185
  /**
186
   * Adds all content panels to the main user interface. This will load the
187
   * configuration settings from the workspace to reproduce the settings from
188
   * a previous session.
189
   */
190
  public MainPane( final Workspace workspace ) {
191
    mWorkspace = workspace;
192
    mSpellChecker = createSpellChecker();
193
    mEditorSpeller = createTextEditorSpellChecker( mSpellChecker );
194
    mPreview = new HtmlPreview( workspace );
195
    mStatistics = new DocumentStatistics( workspace );
196
    mTextEditor.set( createMarkdownEditor( DOCUMENT_DEFAULT ) );
197
    mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
198
    mVariableNameInjector = new VariableNameInjector( mWorkspace );
199
    mRBootstrapController = new RBootstrapController(
200
      mWorkspace, this::getDefinitions );
201
202
    open( collect( getRecentFiles() ) );
203
    viewPreview();
204
    setDividerPositions( calculateDividerPositions() );
205
206
    // Once the main scene's window regains focus, update the active definition
207
    // editor to the currently selected tab.
208
    runLater( () -> getWindow().setOnCloseRequest( event -> {
209
      // Order matters: Open file names must be persisted before closing all.
210
      mWorkspace.save();
211
212
      if( closeAll() ) {
213
        Platform.exit();
214
        terminate( 0 );
215
      }
216
217
      event.consume();
218
    } ) );
219
220
    register( this );
221
    initAutosave( workspace );
222
223
    restoreSession();
224
    runLater( this::restoreFocus );
225
226
    mInstallWizard = new TypesetterInstaller( workspace );
227
  }
228
229
  /**
230
   * Called when spellchecking can be run. This will reload the dictionary
231
   * into memory once, and then re-use it for all the existing text editors.
232
   *
233
   * @param event The event to process, having a populated word-frequency map.
234
   */
235
  @Subscribe
236
  public void handle( final LexiconLoadedEvent event ) {
237
    final var lexicon = event.getLexicon();
238
239
    try {
240
      final var checker = SymSpellSpeller.forLexicon( lexicon );
241
      mSpellChecker.set( checker );
242
    } catch( final Exception ex ) {
243
      clue( ex );
244
    }
245
  }
246
247
  @Subscribe
248
  public void handle( final TextEditorFocusEvent event ) {
249
    mTextEditor.set( event.get() );
250
  }
251
252
  @Subscribe
253
  public void handle( final TextDefinitionFocusEvent event ) {
254
    mDefinitionEditor.set( event.get() );
255
  }
256
257
  /**
258
   * Typically called when a file name is clicked in the preview panel.
259
   *
260
   * @param event The event to process, must contain a valid file reference.
261
   */
262
  @Subscribe
263
  public void handle( final FileOpenEvent event ) {
264
    final File eventFile;
265
    final var eventUri = event.getUri();
266
267
    if( eventUri.isAbsolute() ) {
268
      eventFile = new File( eventUri.getPath() );
269
    }
270
    else {
271
      final var activeFile = getTextEditor().getFile();
272
      final var parent = activeFile.getParentFile();
273
274
      if( parent == null ) {
275
        clue( new FileNotFoundException( eventUri.getPath() ) );
276
        return;
277
      }
278
      else {
279
        final var parentPath = parent.getAbsolutePath();
280
        eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
281
      }
282
    }
283
284
    runLater( () -> open( eventFile ) );
285
  }
286
287
  @Subscribe
288
  public void handle( final CaretNavigationEvent event ) {
289
    runLater( () -> {
290
      final var textArea = getTextEditor();
291
      textArea.moveTo( event.getOffset() );
292
      textArea.requestFocus();
293
    } );
294
  }
295
296
  @Subscribe
297
  public void handle( final InsertDefinitionEvent<String> event ) {
298
    final var leaf = event.getLeaf();
299
    final var editor = mTextEditor.get();
300
301
    mVariableNameInjector.insert( editor, leaf );
302
  }
303
304
  private void initAutosave( final Workspace workspace ) {
305
    final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
306
307
    rate.addListener(
308
      ( c, o, n ) -> {
309
        final var taskRef = mSaveTask.get();
310
311
        // Prevent multiple autosaves from running.
312
        if( taskRef != null ) {
313
          taskRef.cancel( false );
314
        }
315
316
        initAutosave( rate );
317
      }
318
    );
319
320
    // Start the save listener (avoids duplicating some code).
321
    initAutosave( rate );
322
  }
323
324
  private void initAutosave( final IntegerProperty rate ) {
325
    mSaveTask.set(
326
      mSaver.scheduleAtFixedRate(
327
        () -> {
328
          if( getTextEditor().isModified() ) {
329
            // Ensure the modified indicator is cleared by running on EDT.
330
            runLater( this::save );
331
          }
332
        }, 0, rate.intValue(), SECONDS
333
      )
334
    );
335
  }
336
337
  /**
338
   * TODO: Load divider positions from exported settings, see
339
   *   {@link #collect(SetProperty)} comment.
340
   */
341
  private double[] calculateDividerPositions() {
342
    final var ratio = 100f / getItems().size() / 100;
343
    final var positions = getDividerPositions();
344
345
    for( int i = 0; i < positions.length; i++ ) {
346
      positions[ i ] = ratio * i;
347
    }
348
349
    return positions;
350
  }
351
352
  /**
353
   * Opens all the files into the application, provided the paths are unique.
354
   * This may only be called for any type of files that a user can edit
355
   * (i.e., update and persist), such as definitions and text files.
356
   *
357
   * @param files The list of files to open.
358
   */
359
  public void open( final List<File> files ) {
360
    files.forEach( this::open );
361
  }
362
363
  /**
364
   * This opens the given file. Since the preview pane is not a file that
365
   * can be opened, it is safe to add a listener to the detachable pane.
366
   * This will exit early if the given file is not a regular file (i.e., a
367
   * directory).
368
   *
369
   * @param inputFile The file to open.
370
   */
371
  private void open( final File inputFile ) {
372
    // Prevent opening directories (a non-existent "untitled.md" is fine).
373
    if( !inputFile.isFile() && inputFile.exists() ) {
374
      return;
375
    }
376
377
    final var tab = createTab( inputFile );
378
    final var node = tab.getContent();
379
    final var mediaType = MediaType.valueFrom( inputFile );
380
    final var tabPane = obtainTabPane( mediaType );
381
382
    tab.setTooltip( createTooltip( inputFile ) );
383
    tabPane.setFocusTraversable( false );
384
    tabPane.setTabClosingPolicy( ALL_TABS );
385
    tabPane.getTabs().add( tab );
386
387
    // Attach the tab scene factory for new tab panes.
388
    if( !getItems().contains( tabPane ) ) {
389
      addTabPane(
390
        node instanceof TextDefinition ? 0 : getItems().size(), tabPane
391
      );
392
    }
393
394
    if( inputFile.isFile() ) {
395
      getRecentFiles().add( inputFile.getAbsolutePath() );
396
    }
397
  }
398
399
  /**
400
   * Gives focus to the most recently edited document and attempts to move
401
   * the caret to the most recently known offset into said document.
402
   */
403
  private void restoreSession() {
404
    final var workspace = getWorkspace();
405
    final var file = workspace.fileProperty( KEY_UI_RECENT_DOCUMENT );
406
    final var offset = workspace.integerProperty( KEY_UI_RECENT_OFFSET );
407
408
    for( final var pane : mTabPanes ) {
409
      for( final var tab : pane.getTabs() ) {
410
        final var tooltip = tab.getTooltip();
411
412
        if( tooltip != null ) {
413
          final var tabName = tooltip.getText();
414
          final var fileName = file.getValue().toString();
415
416
          if( tabName.equalsIgnoreCase( fileName ) ) {
417
            final var node = tab.getContent();
418
419
            pane.getSelectionModel().select( tab );
420
            node.requestFocus();
421
422
            if( node instanceof TextEditor editor ) {
423
              editor.moveTo( offset.getValue() );
424
            }
425
426
            break;
427
          }
428
        }
429
      }
430
    }
431
  }
432
433
  /**
434
   * Sets the focus to the middle pane, which contains the text editor tabs.
435
   */
436
  private void restoreFocus() {
437
    // Work around a bug where focusing directly on the middle pane results
438
    // in the R engine not loading variables properly.
439
    mTabPanes.get( 0 ).requestFocus();
440
441
    // This is the only line that should be required.
442
    mTabPanes.get( 1 ).requestFocus();
443
  }
444
445
  /**
446
   * Opens a new text editor document using the default document file name.
447
   */
448
  public void newTextEditor() {
449
    open( DOCUMENT_DEFAULT );
450
  }
451
452
  /**
453
   * Opens a new definition editor document using the default definition
454
   * file name.
455
   */
456
  public void newDefinitionEditor() {
457
    open( DEFINITION_DEFAULT );
458
  }
459
460
  /**
461
   * Iterates over all tab panes to find all {@link TextEditor}s and request
462
   * that they save themselves.
463
   */
464
  public void saveAll() {
465
    iterateEditors( this::save );
466
  }
467
468
  /**
469
   * Requests that the active {@link TextEditor} saves itself. Don't bother
470
   * checking if modified first because if the user swaps external media from
471
   * an external source (e.g., USB thumb drive), save should not second-guess
472
   * the user: save always re-saves. Also, it's less code.
473
   */
474
  public void save() {
475
    save( getTextEditor() );
476
  }
477
478
  /**
479
   * Saves the active {@link TextEditor} under a new name.
480
   *
481
   * @param files The new active editor {@link File} reference, must contain
482
   *              at least one element.
483
   */
484
  public void saveAs( final List<File> files ) {
485
    assert files != null;
486
    assert !files.isEmpty();
487
    final var editor = getTextEditor();
488
    final var tab = getTab( editor );
489
    final var file = files.get( 0 );
490
491
    editor.rename( file );
492
    tab.ifPresent( t -> {
493
      t.setText( editor.getFilename() );
494
      t.setTooltip( createTooltip( file ) );
495
    } );
496
497
    save();
498
  }
499
500
  /**
501
   * Saves the given {@link TextResource} to a file. This is typically used
502
   * to save either an instance of {@link TextEditor} or {@link TextDefinition}.
503
   *
504
   * @param resource The resource to export.
505
   */
506
  private void save( final TextResource resource ) {
507
    try {
508
      resource.save();
509
    } catch( final Exception ex ) {
510
      clue( ex );
511
      sNotifier.alert(
512
        getWindow(), resource.getPath(), "TextResource.saveFailed", ex
513
      );
514
    }
515
  }
516
517
  /**
518
   * Closes all open {@link TextEditor}s; all {@link TextDefinition}s stay open.
519
   *
520
   * @return {@code true} when all editors, modified or otherwise, were
521
   * permitted to close; {@code false} when one or more editors were modified
522
   * and the user requested no closing.
523
   */
524
  public boolean closeAll() {
525
    var closable = true;
526
527
    for( final var tabPane : mTabPanes ) {
528
      final var tabIterator = tabPane.getTabs().iterator();
529
530
      while( tabIterator.hasNext() ) {
531
        final var tab = tabIterator.next();
532
        final var resource = tab.getContent();
533
534
        // The definition panes auto-save, so being specific here prevents
535
        // closing the definitions in the situation where the user wants to
536
        // continue editing (i.e., possibly save unsaved work).
537
        if( !(resource instanceof TextEditor) ) {
538
          continue;
539
        }
540
541
        if( canClose( (TextEditor) resource ) ) {
542
          tabIterator.remove();
543
          close( tab );
544
        }
545
        else {
546
          closable = false;
547
        }
548
      }
549
    }
550
551
    return closable;
552
  }
553
554
  /**
555
   * Calls the tab's {@link Tab#getOnClosed()} handler to carry out a close
556
   * event.
557
   *
558
   * @param tab The {@link Tab} that was closed.
559
   */
560
  private void close( final Tab tab ) {
561
    assert tab != null;
562
563
    final var handler = tab.getOnClosed();
564
565
    if( handler != null ) {
566
      handler.handle( new ActionEvent() );
567
    }
568
  }
569
570
  /**
571
   * Closes the active tab; delegates to {@link #canClose(TextResource)}.
572
   */
573
  public void close() {
574
    final var editor = getTextEditor();
575
576
    if( canClose( editor ) ) {
577
      close( editor );
578
    }
579
  }
580
581
  /**
582
   * Closes the given {@link TextResource}. This must not be called from within
583
   * a loop that iterates over the tab panes using {@code forEach}, lest a
584
   * concurrent modification exception be thrown.
585
   *
586
   * @param resource The {@link TextResource} to close, without confirming with
587
   *                 the user.
588
   */
589
  private void close( final TextResource resource ) {
590
    getTab( resource ).ifPresent(
591
      tab -> {
592
        close( tab );
593
        tab.getTabPane().getTabs().remove( tab );
594
      }
595
    );
596
  }
597
598
  /**
599
   * Answers whether the given {@link TextResource} may be closed.
600
   *
601
   * @param editor The {@link TextResource} to try closing.
602
   * @return {@code true} when the editor may be closed; {@code false} when
603
   * the user has requested to keep the editor open.
604
   */
605
  private boolean canClose( final TextResource editor ) {
606
    final var editorTab = getTab( editor );
607
    final var canClose = new AtomicBoolean( true );
608
609
    if( editor.isModified() ) {
610
      final var filename = new StringBuilder();
611
      editorTab.ifPresent( tab -> filename.append( tab.getText() ) );
612
613
      final var message = sNotifier.createNotification(
614
        Messages.get( "Alert.file.close.title" ),
615
        Messages.get( "Alert.file.close.text" ),
616
        filename.toString()
617
      );
618
619
      final var dialog = sNotifier.createConfirmation( getWindow(), message );
620
621
      dialog.showAndWait().ifPresent(
622
        save -> canClose.set( save == YES ? editor.save() : save == NO )
623
      );
624
    }
625
626
    return canClose.get();
627
  }
628
629
  private void iterateEditors( final Consumer<TextEditor> consumer ) {
630
    mTabPanes.forEach(
631
      tp -> tp.getTabs().forEach( tab -> {
632
        final var node = tab.getContent();
633
634
        if( node instanceof final TextEditor editor ) {
635
          consumer.accept( editor );
636
        }
637
      } )
638
    );
639
  }
640
641
  private ObjectProperty<TextEditor> createActiveTextEditor() {
642
    final var editor = new SimpleObjectProperty<TextEditor>();
643
644
    editor.addListener( ( c, o, n ) -> {
645
      if( n != null ) {
646
        mPreview.setBaseUri( n.getPath() );
647
        process( n );
648
      }
649
    } );
650
651
    return editor;
652
  }
653
654
  /**
655
   * Adds the HTML preview tab to its own, singular tab pane.
656
   */
657
  public void viewPreview() {
658
    viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
659
  }
660
661
  /**
662
   * Adds the document outline tab to its own, singular tab pane.
663
   */
664
  public void viewOutline() {
665
    viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
666
  }
667
668
  public void viewStatistics() {
669
    viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
670
  }
671
672
  public void viewFiles() {
673
    try {
674
      final var factory = new FilePickerFactory( getWorkspace() );
675
      final var fileManager = factory.createModeless();
676
      viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
677
    } catch( final Exception ex ) {
678
      clue( ex );
679
    }
680
  }
681
682
  private void viewTab(
683
    final Node node, final MediaType mediaType, final String key ) {
684
    final var tabPane = obtainTabPane( mediaType );
685
686
    for( final var tab : tabPane.getTabs() ) {
687
      if( tab.getContent() == node ) {
688
        return;
689
      }
690
    }
691
692
    tabPane.getTabs().add( createTab( get( key ), node ) );
693
    addTabPane( tabPane );
694
  }
695
696
  public void viewRefresh() {
697
    mPreview.refresh();
698
    Engine.clear();
699
    mRBootstrapController.update();
700
  }
701
702
  /**
703
   * Returns the tab that contains the given {@link TextEditor}.
704
   *
705
   * @param editor The {@link TextEditor} instance to find amongst the tabs.
706
   * @return The first tab having content that matches the given tab.
707
   */
708
  private Optional<Tab> getTab( final TextResource editor ) {
709
    return mTabPanes.stream()
710
                    .flatMap( pane -> pane.getTabs().stream() )
711
                    .filter( tab -> editor.equals( tab.getContent() ) )
712
                    .findFirst();
713
  }
714
715
  /**
716
   * Creates a new {@link DefinitionEditor} wrapped in a listener that
717
   * is used to detect when the active {@link DefinitionEditor} has changed.
718
   * Upon changing, the variables are interpolated and the active text editor
719
   * is refreshed.
720
   *
721
   * @param textEditor Text editor to update with the revised resolved map.
722
   * @return A newly configured property that represents the active
723
   * {@link DefinitionEditor}, never null.
724
   */
725
  private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
726
    final ObjectProperty<TextEditor> textEditor ) {
727
    final var defEditor = new SimpleObjectProperty<>(
728
      createDefinitionEditor()
729
    );
730
731
    defEditor.addListener( ( c, o, n ) -> {
732
      final var editor = textEditor.get();
733
734
      if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
735
        // Initialize R before the editor is added.
736
        mRBootstrapController.update();
737
      }
738
739
      process( editor );
740
    } );
741
742
    return defEditor;
743
  }
744
745
  private Tab createTab( final String filename, final Node node ) {
746
    return new DetachableTab( filename, node );
747
  }
748
749
  private Tab createTab( final File file ) {
750
    final var r = createTextResource( file );
751
    final var tab = createTab( r.getFilename(), r.getNode() );
752
753
    r.modifiedProperty().addListener(
754
      ( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
755
    );
756
757
    // This is called when either the tab is closed by the user clicking on
758
    // the tab's close icon or when closing (all) from the file menu.
759
    tab.setOnClosed(
760
      __ -> getRecentFiles().remove( file.getAbsolutePath() )
761
    );
762
763
    // When closing a tab, give focus to the newly revealed tab.
764
    tab.selectedProperty().addListener( ( c, o, n ) -> {
765
      if( n != null && n ) {
766
        final var pane = tab.getTabPane();
767
768
        if( pane != null ) {
769
          pane.requestFocus();
770
        }
771
      }
772
    } );
773
774
    tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
775
      if( nPane != null ) {
776
        nPane.focusedProperty().addListener( ( c, o, n ) -> {
777
          if( n != null && n ) {
778
            final var selected = nPane.getSelectionModel().getSelectedItem();
779
            final var node = selected.getContent();
780
            node.requestFocus();
781
          }
782
        } );
783
      }
784
    } );
785
786
    return tab;
787
  }
788
789
  /**
790
   * Creates bins for the different {@link MediaType}s, which eventually are
791
   * added to the UI as separate tab panes. If ever a general-purpose scene
792
   * exporter is developed to serialize a scene to an FXML file, this could
793
   * be replaced by such a class.
794
   * <p>
795
   * When binning the files, this makes sure that at least one file exists
796
   * for every type. If the user has opted to close a particular type (such
797
   * as the definition pane), the view will suppressed elsewhere.
798
   * </p>
799
   * <p>
800
   * The order that the binned files are returned will be reflected in the
801
   * order that the corresponding panes are rendered in the UI.
802
   * </p>
803
   *
804
   * @param paths The file paths to bin according to their type.
805
   * @return An in-order list of files, first by structured definition files,
806
   * then by plain text documents.
807
   */
808
  private List<File> collect( final SetProperty<String> paths ) {
809
    // Treat all files destined for the text editor as plain text documents
810
    // so that they are added to the same pane. Grouping by TEXT_PLAIN is a
811
    // bit arbitrary, but means explicitly capturing TEXT_PLAIN isn't needed.
812
    final Function<MediaType, MediaType> bin =
813
      m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
814
815
    // Create two groups: YAML files and plain text files. The order that
816
    // the elements are listed in the enumeration for media types determines
817
    // what files are loaded first. Variable definitions come before all other
818
    // plain text documents.
819
    final var bins = paths
820
      .stream()
821
      .collect(
822
        groupingBy(
823
          path -> bin.apply( MediaType.fromFilename( path ) ),
824
          () -> new TreeMap<>( Enum::compareTo ),
825
          Collectors.toList()
826
        )
827
      );
828
829
    bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
830
    bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
831
832
    final var result = new LinkedList<File>();
833
834
    // Ensure that the same types are listed together (keep insertion order).
835
    bins.forEach( ( mediaType, files ) -> result.addAll(
836
      files.stream().map( File::new ).toList() )
837
    );
838
839
    return result;
840
  }
841
842
  /**
843
   * Force the active editor to update, which will cause the processor
844
   * to re-evaluate the interpolated definition map thereby updating the
845
   * preview pane.
846
   *
847
   * @param editor Contains the source document to update in the preview pane.
848
   */
849
  private void process( final TextEditor editor ) {
850
    // Ensure processing does not run on the JavaFX thread, which frees the
851
    // text editor immediately for caret movement. The preview will have a
852
    // slight delay when catching up to the caret position.
853
    final var task = new Task<Void>() {
854
      @Override
855
      public Void call() {
856
        try {
857
          final var p = mProcessors.getOrDefault( editor, IDENTITY );
858
          p.apply( editor == null ? "" : editor.getText() );
859
        } catch( final Exception ex ) {
860
          clue( ex );
861
        }
862
863
        return null;
864
      }
865
    };
866
867
    // TODO: Each time the editor successfully runs the processor the task is
868
    //   considered successful. Due to the rapid-fire nature of processing
869
    //   (e.g., keyboard navigation, fast typing), it isn't necessary to
870
    //   scroll each time.
871
    //   The algorithm:
872
    //   1. Peek at the oldest time.
873
    //   2. If the difference between the oldest time and current time exceeds
874
    //      250 milliseconds, then invoke the scrolling.
875
    //   3. Insert the current time into the circular queue.
876
    task.setOnSucceeded(
877
      e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
878
    );
879
880
    // Prevents multiple process requests from executing simultaneously (due
881
    // to having a restricted queue size).
882
    sExecutor.execute( task );
883
  }
884
885
  /**
886
   * Lazily creates a {@link TabPane} configured to listen for tab select
887
   * events. The tab pane is associated with a given media type so that
888
   * similar files can be grouped together.
889
   *
890
   * @param mediaType The media type to associate with the tab pane.
891
   * @return An instance of {@link TabPane} that will handle tab docking.
892
   */
893
  private TabPane obtainTabPane( final MediaType mediaType ) {
894
    for( final var pane : mTabPanes ) {
895
      for( final var tab : pane.getTabs() ) {
896
        final var node = tab.getContent();
897
898
        if( node instanceof TextResource r && r.supports( mediaType ) ) {
899
          return pane;
900
        }
901
      }
902
    }
903
904
    final var pane = createTabPane();
905
    mTabPanes.add( pane );
906
    return pane;
907
  }
908
909
  /**
910
   * Creates an initialized {@link TabPane} instance.
911
   *
912
   * @return A new {@link TabPane} with all listeners configured.
913
   */
914
  private TabPane createTabPane() {
915
    final var tabPane = new DetachableTabPane();
916
917
    initStageOwnerFactory( tabPane );
918
    initTabListener( tabPane );
919
920
    return tabPane;
921
  }
922
923
  /**
924
   * When any {@link DetachableTabPane} is detached from the main window,
925
   * the stage owner factory must be given its parent window, which will
926
   * own the child window. The parent window is the {@link MainPane}'s
927
   * {@link Scene}'s {@link Window} instance.
928
   *
929
   * <p>
930
   * This will derives the new title from the main window title, incrementing
931
   * the window count to help uniquely identify the child windows.
932
   * </p>
933
   *
934
   * @param tabPane A new {@link DetachableTabPane} to configure.
935
   */
936
  private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
937
    tabPane.setStageOwnerFactory( stage -> {
938
      final var title = get(
939
        "Detach.tab.title",
940
        ((Stage) getWindow()).getTitle(), ++mWindowCount
941
      );
942
      stage.setTitle( title );
943
944
      return getScene().getWindow();
945
    } );
946
  }
947
948
  /**
949
   * Responsible for configuring the content of each {@link DetachableTab} when
950
   * it is added to the given {@link DetachableTabPane} instance.
951
   * <p>
952
   * For {@link TextEditor} contents, an instance of {@link ScrollEventHandler}
953
   * is initialized to perform synchronized scrolling between the editor and
954
   * its preview window. Additionally, the last tab in the tab pane's list of
955
   * tabs is given focus.
956
   * </p>
957
   * <p>
958
   * Note that multiple tabs can be added simultaneously.
959
   * </p>
960
   *
961
   * @param tabPane A new {@link TabPane} to configure.
962
   */
963
  private void initTabListener( final TabPane tabPane ) {
964
    tabPane.getTabs().addListener(
965
      ( final ListChangeListener.Change<? extends Tab> listener ) -> {
966
        while( listener.next() ) {
967
          if( listener.wasAdded() ) {
968
            final var tabs = listener.getAddedSubList();
969
970
            tabs.forEach( tab -> {
971
              final var node = tab.getContent();
972
973
              if( node instanceof TextEditor ) {
974
                initScrollEventListener( tab );
975
              }
976
            } );
977
978
            // Select and give focus to the last tab opened.
979
            final var index = tabs.size() - 1;
980
            if( index >= 0 ) {
981
              final var tab = tabs.get( index );
982
              tabPane.getSelectionModel().select( tab );
983
              tab.getContent().requestFocus();
984
            }
985
          }
986
        }
987
      }
988
    );
989
  }
990
991
  /**
992
   * Synchronizes scrollbar positions between the given {@link Tab} that
993
   * contains an instance of {@link TextEditor} and {@link HtmlPreview} pane.
994
   *
995
   * @param tab The container for an instance of {@link TextEditor}.
996
   */
997
  private void initScrollEventListener( final Tab tab ) {
998
    final var editor = (TextEditor) tab.getContent();
999
    final var scrollPane = editor.getScrollPane();
1000
    final var scrollBar = mPreview.getVerticalScrollBar();
1001
    final var handler = new ScrollEventHandler( scrollPane, scrollBar );
1002
1003
    handler.enabledProperty().bind( tab.selectedProperty() );
1004
  }
1005
1006
  private void addTabPane( final int index, final TabPane tabPane ) {
1007
    final var items = getItems();
1008
1009
    if( !items.contains( tabPane ) ) {
1010
      items.add( index, tabPane );
1011
    }
1012
  }
1013
1014
  private void addTabPane( final TabPane tabPane ) {
1015
    addTabPane( getItems().size(), tabPane );
1016
  }
1017
1018
  private GenericBuilder<Mutator, ProcessorContext> processorContextBuilder() {
1019
    final var w = getWorkspace();
1020
1021
    return builder()
1022
      .with( Mutator::setDefinitions, this::getDefinitions )
1023
      .with( Mutator::setLocale, w::getLocale )
1024
      .with( Mutator::setMetadata, w::getMetadata )
1025
      .with( Mutator::setThemesPath, w::getThemesPath )
1026
      .with( Mutator::setCachesPath,
1027
             () -> w.getFile( KEY_CACHES_DIR ) )
1028
      .with( Mutator::setImagesPath,
1029
             () -> w.getFile( KEY_IMAGES_DIR ) )
1030
      .with( Mutator::setImageOrder,
1031
             () -> w.getString( KEY_IMAGES_ORDER ) )
1032
      .with( Mutator::setImageServer,
1033
             () -> w.getString( KEY_IMAGES_SERVER ) )
1034
      .with( Mutator::setFontsPath,
1035
             () -> w.getFile( KEY_TYPESET_CONTEXT_FONTS_DIR ) )
1036
      .with( Mutator::setCaret,
1037
             () -> getTextEditor().getCaret() )
1038
      .with( Mutator::setSigilBegan,
1039
             () -> w.getString( KEY_DEF_DELIM_BEGAN ) )
1040
      .with( Mutator::setSigilEnded,
1041
             () -> w.getString( KEY_DEF_DELIM_ENDED ) )
1042
      .with( Mutator::setRScript,
1043
             () -> w.getString( KEY_R_SCRIPT ) )
1044
      .with( Mutator::setRWorkingDir,
1045
             () -> w.getFile( KEY_R_DIR ).toPath() )
1046
      .with( Mutator::setCurlQuotes,
1047
             () -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
1048
      .with( Mutator::setAutoRemove,
1049
             () -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
1050
  }
1051
1052
  public ProcessorContext createProcessorContext() {
1053
    return createProcessorContext( null, NONE );
1054
  }
1055
1056
  /**
1057
   * @param targetPath Used when exporting to a PDF file (binary).
1058
   * @param format     Used when processors export to a new text format.
1059
   * @return A new {@link ProcessorContext} to use when creating an instance of
1060
   * {@link Processor}.
1061
   */
1062
  public ProcessorContext createProcessorContext(
1063
    final Path targetPath, final ExportFormat format ) {
1064
    final var textEditor = getTextEditor();
1065
    final var sourcePath = textEditor.getPath();
1066
1067
    return processorContextBuilder()
1068
      .with( Mutator::setSourcePath, sourcePath )
1069
      .with( Mutator::setTargetPath, targetPath )
1070
      .with( Mutator::setExportFormat, format )
1071
      .build();
1072
  }
1073
1074
  /**
1075
   * @param sourcePath Used by {@link ProcessorFactory} to determine
1076
   *                   {@link Processor} type to create based on file type.
1077
   * @return A new {@link ProcessorContext} to use when creating an instance of
1078
   * {@link Processor}.
1079
   */
1080
  private ProcessorContext createProcessorContext( final Path sourcePath ) {
1081
    return processorContextBuilder()
1082
      .with( Mutator::setSourcePath, sourcePath )
11151083
      .with( Mutator::setExportFormat, NONE )
11161084
      .build();
M src/main/java/com/keenwrite/Messages.java
77
import com.keenwrite.sigils.SigilKeyOperator;
88
9
import java.net.URI;
910
import java.text.MessageFormat;
1011
import java.util.ResourceBundle;
...
7172
  public static String get( final String key, final Object... args ) {
7273
    return MessageFormat.format( get( key ), args );
74
  }
75
76
  public static int getInt( final String key, final int defaultValue ) {
77
    try {
78
      return Integer.parseInt( get( key ) );
79
    } catch( final NumberFormatException ignored ) {
80
      return defaultValue;
81
    }
82
  }
83
84
  public static URI getUri( final String key ) {
85
    return URI.create( get( key ) );
7386
  }
7487
M src/main/java/com/keenwrite/cmdline/Arguments.java
7575
    required = true
7676
  )
77
  private Path mPathInput;
77
  private Path mSourcePath;
7878
7979
  @CommandLine.Option(
8080
    names = {"--format-subtype"},
8181
    description =
8282
      "Export TeX subtype for HTML formats: svg, delimited",
8383
    defaultValue = "svg",
8484
    paramLabel = "String"
8585
  )
8686
  private String mFormatSubtype;
87
88
  @CommandLine.Option(
89
    names = {"--cache-dir"},
90
    description =
91
      "Directory to store remote resources",
92
    paramLabel = "DIR"
93
  )
94
  private File mCachesDir;
8795
8896
  @CommandLine.Option(
8997
    names = {"--image-dir"},
9098
    description =
9199
      "Directory containing images",
92100
    paramLabel = "DIR"
93101
  )
94
  private File mImageDir;
102
  private File mImagesDir;
95103
96104
  @CommandLine.Option(
...
137145
    required = true
138146
  )
139
  private Path mPathOutput;
147
  private Path mTargetPath;
140148
141149
  @CommandLine.Option(
...
187195
    paramLabel = "DIR"
188196
  )
189
  private Path mDirTheme;
197
  private Path mThemesDir;
190198
191199
  @CommandLine.Option(
...
206214
    throws IOException {
207215
    final var definitions = parse( mPathVariables );
208
    final var format = ExportFormat.valueFrom( mPathOutput, mFormatSubtype );
216
    final var format = ExportFormat.valueFrom( mTargetPath, mFormatSubtype );
209217
    final var locale = lookupLocale( mLocale );
210218
    final var rScript = read( mRScriptPath );
211219
212220
    return ProcessorContext
213221
      .builder()
214
      .with( Mutator::setInputPath, mPathInput )
215
      .with( Mutator::setOutputPath, mPathOutput )
222
      .with( Mutator::setSourcePath, mSourcePath )
223
      .with( Mutator::setTargetPath, mTargetPath )
224
      .with( Mutator::setThemesPath, () -> mThemesDir )
225
      .with( Mutator::setCachesPath, () -> mCachesDir )
226
      .with( Mutator::setImagesPath, () -> mImagesDir )
227
      .with( Mutator::setImageServer, () -> mImageServer )
228
      .with( Mutator::setImageOrder, () -> mImageOrder )
216229
      .with( Mutator::setExportFormat, format )
217230
      .with( Mutator::setDefinitions, () -> definitions )
218231
      .with( Mutator::setMetadata, () -> mMetadata )
219232
      .with( Mutator::setLocale, () -> locale )
220
      .with( Mutator::setThemePath, () -> mDirTheme )
221233
      .with( Mutator::setConcatenate, mConcatenate )
222
      .with( Mutator::setImageDir, () -> mImageDir )
223
      .with( Mutator::setImageServer, () -> mImageServer )
224
      .with( Mutator::setImageOrder, () -> mImageOrder )
225234
      .with( Mutator::setSigilBegan, () -> mSigilBegan )
226235
      .with( Mutator::setSigilEnded, () -> mSigilEnded )
227236
      .with( Mutator::setRWorkingDir, () -> mRWorkingDir )
228237
      .with( Mutator::setRScript, () -> rScript )
229238
      .with( Mutator::setCurlQuotes, () -> mCurlQuotes )
230
      .with( Mutator::setAutoClean, () -> !mKeepFiles )
239
      .with( Mutator::setAutoRemove, () -> !mKeepFiles )
231240
      .build();
232241
  }
M src/main/java/com/keenwrite/collections/CircularQueue.java
99
 * Responsible for maintaining a circular queue where newly added items will
1010
 * overwrite existing items.
11
 *
11
 * <p>
1212
 * <strong>Warning:</strong> This class is not thread-safe.
13
 * </p>
1314
 *
1415
 * @param <E> The type of elements to store in this collection.
M src/main/java/com/keenwrite/constants/Constants.java
1212
1313
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
14
import static com.keenwrite.Bootstrap.USER_DATA_DIR;
1415
import static com.keenwrite.preferences.LocaleScripts.withScript;
1516
import static java.io.File.separator;
1617
import static java.lang.String.format;
1718
import static java.lang.System.getProperty;
19
import static org.apache.commons.lang3.SystemUtils.*;
1820
1921
/**
...
266268
      APP_TITLE_LOWERCASE
267269
    ) );
270
  }
271
272
  /**
273
   * Tries to get a system-independent path to the user's fonts directory.
274
   */
275
  public static File getFontDirectory() {
276
    final var FONT_PATH = Path.of( "fonts" );
277
    final var USER_HOME = System.getProperty( "user.home" );
278
279
    final String fontBase;
280
    final Path fontUser;
281
282
    if( IS_OS_WINDOWS ) {
283
      fontBase = System.getenv( "WINDIR" );
284
      fontUser = FONT_PATH;
285
    }
286
    else if( IS_OS_MAC ) {
287
      fontBase = USER_HOME;
288
      fontUser = Path.of( "Library", "Fonts" );
289
    }
290
    else if( IS_OS_UNIX ) {
291
      fontBase = USER_HOME;
292
      fontUser = Path.of( ".fonts" );
293
    }
294
    else {
295
      fontBase = USER_DATA_DIR.toString();
296
      fontUser = FONT_PATH;
297
    }
298
299
    return (fontBase == null
300
      ? USER_DATA_DIR.relativize( fontUser )
301
      : Path.of( fontBase ).resolve( fontUser )).toFile();
268302
  }
269303
}
M src/main/java/com/keenwrite/events/StatusEvent.java
6565
   * optional because usually a status message isn't an application error.
6666
   *
67
   * @return Optional stack trace to pin-point the problem area in the code.
67
   * @return Optional stack trace to pinpoint the problem area in the code.
6868
   */
6969
  public String getProblem() {
...
9494
  }
9595
96
  /**
97
   * Returns {@code true} to allow the {@link StackTraceElement} to pass
98
   * through the filter.
99
   *
100
   * @param e The element to check against the filter.
101
   */
96102
  private static boolean filter( final StackTraceElement e ) {
97103
    final var clazz = e.getClassName();
A src/main/java/com/keenwrite/io/CommandNotFoundException.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import java.io.File;
5
import java.io.FileNotFoundException;
6
7
/**
8
 * Indicates a command could not be found to run.
9
 */
10
public class CommandNotFoundException extends FileNotFoundException {
11
  /**
12
   * Creates a new exception indicating that the given command could not be
13
   * found (or executed).
14
   *
15
   * @param command The binary file's command name that could not be run.
16
   */
17
  public CommandNotFoundException( final String command ) {
18
    super( command );
19
  }
20
21
  /**
22
   * Creates a new exception indicating that the given command could not be
23
   * found (or executed).
24
   *
25
   * @param file The binary file's command name that could not be run.
26
   */
27
  public CommandNotFoundException( final File file ) {
28
    this( file.getAbsolutePath() );
29
  }
30
}
131
M src/main/java/com/keenwrite/io/FileEvent.java
1313
   * Constructs a new event that indicates the source of a file system event.
1414
   *
15
   * @param file The {@link File} that has succumb to a file system event.
15
   * @param file The {@link File} that has succumbed to a file system event.
1616
   */
1717
  public FileEvent( final File file ) {
D src/main/java/com/keenwrite/io/HttpFacade.java
1
package com.keenwrite.io;
2
3
import java.io.BufferedInputStream;
4
import java.io.Closeable;
5
import java.io.IOException;
6
import java.io.InputStream;
7
import java.net.HttpURLConnection;
8
import java.net.URI;
9
import java.net.URL;
10
import java.net.URLConnection;
11
import java.util.zip.GZIPInputStream;
12
13
import static com.keenwrite.events.StatusEvent.clue;
14
import static java.lang.System.getProperty;
15
import static java.lang.System.setProperty;
16
import static java.net.HttpURLConnection.HTTP_OK;
17
import static java.net.HttpURLConnection.setFollowRedirects;
18
19
/**
20
 * Responsible for making HTTP requests, a thin wrapper around the
21
 * {@link URLConnection} class. This will attempt to use compression.
22
 * <p>
23
 * This class must be used within a try-with-resources block to ensure all
24
 * resources are released, even if only calling {@link Response#getMediaType()}.
25
 * </p>
26
 */
27
public class HttpFacade {
28
  static {
29
    setProperty( "http.keepAlive", "false" );
30
    setFollowRedirects( true );
31
  }
32
33
  /**
34
   * Sends an HTTP GET request to a server.
35
   *
36
   * @param url The remote resource to fetch.
37
   * @return The server response.
38
   */
39
  public static Response httpGet( final URL url ) throws Exception {
40
    return new Response( url );
41
  }
42
43
  /**
44
   * Convenience method to send an HTTP GET request to a server.
45
   *
46
   * @param uri The remote resource to fetch.
47
   * @return The server response.
48
   * @see #httpGet(URL)
49
   */
50
  public static Response httpGet( final URI uri ) throws Exception {
51
    return httpGet( uri.toURL() );
52
  }
53
54
  /**
55
   * Convenience method to send an HTTP GET request to a server.
56
   *
57
   * @param url The remote resource to fetch.
58
   * @return The server response.
59
   * @see #httpGet(URL)
60
   */
61
  public static Response httpGet( final String url ) throws Exception {
62
    return httpGet( new URL( url ) );
63
  }
64
65
  /**
66
   * Callers are responsible for closing the response.
67
   */
68
  public static final class Response implements Closeable {
69
    private final HttpURLConnection mConn;
70
    private final BufferedInputStream mStream;
71
72
    private Response( final URL url ) throws IOException {
73
      assert url != null;
74
75
      clue( "Main.status.image.request.init" );
76
77
      if( url.openConnection() instanceof HttpURLConnection conn ) {
78
        conn.setUseCaches( false );
79
        conn.setInstanceFollowRedirects( true );
80
        conn.setRequestProperty( "Accept-Encoding", "gzip" );
81
        conn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) );
82
        conn.setRequestMethod( "GET" );
83
        conn.setConnectTimeout( 30000 );
84
        conn.setRequestProperty( "connection", "close" );
85
        conn.connect();
86
87
        clue( "Main.status.image.request.fetch", url.getHost() );
88
89
        final var code = conn.getResponseCode();
90
91
        // Even though there are other "okay" error codes, tell the user when
92
        // a resource has changed in any unexpected way.
93
        if( code != HTTP_OK ) {
94
          throw new IOException( url + " [HTTP " + code + "]" );
95
        }
96
97
        mConn = conn;
98
        mStream = openBufferedInputStream();
99
      }
100
      else {
101
        throw new UnsupportedOperationException( url.toString() );
102
      }
103
    }
104
105
    /**
106
     * Returns the {@link MediaType} based on the resulting HTTP content type
107
     * provided by the server. If the content type from the server is not
108
     * found, this will probe the first several bytes to determine the type.
109
     *
110
     * @return The stream's IANA-defined {@link MediaType}.
111
     */
112
    public MediaType getMediaType() throws IOException {
113
      final var contentType = mConn.getContentType();
114
      var mediaType = MediaType.valueFrom( contentType );
115
116
      if( mediaType.isUndefined() ) {
117
        mediaType = MediaTypeSniffer.getMediaType( mStream );
118
      }
119
120
      clue( "Main.status.image.request.success", mediaType );
121
      return mediaType;
122
    }
123
124
    /**
125
     * Returns the stream opened using an HTTP connection, decompressing if
126
     * the server supports gzip compression. The caller must close the stream
127
     * by calling {@link #close()} on this object.
128
     *
129
     * @return The stream representing the content at the URL used to
130
     * construct the {@link HttpFacade}.
131
     */
132
    public InputStream getInputStream() throws IOException {
133
      return mStream;
134
    }
135
136
    /**
137
     * This will disconnect the HTTP request and close the associated stream.
138
     */
139
    @Override
140
    public void close() {
141
      mConn.disconnect();
142
    }
143
144
    /**
145
     * Opens the connection for reading. It is an error to call this more than
146
     * once. This may use gzip compression. A {@link BufferedInputStream} is
147
     * returned to allow peeking at the stream when checking the content
148
     * type.
149
     *
150
     * @return The {@link InputStream} containing content from an HTTP request.
151
     * @throws IOException Could not open the stream.
152
     */
153
    private BufferedInputStream openBufferedInputStream() throws IOException {
154
      final var encoding = mConn.getContentEncoding();
155
      final var is = mConn.getInputStream();
156
157
      return new BufferedInputStream(
158
        "gzip".equalsIgnoreCase( encoding ) ? new GZIPInputStream( is ) : is );
159
    }
160
  }
161
}
1621
M src/main/java/com/keenwrite/io/MediaType.java
1818
 * Media Types</a>
1919
 */
20
public enum MediaType {
21
  APP_DOCUMENT_OUTLINE( APPLICATION, "x-document-outline" ),
22
  APP_DOCUMENT_STATISTICS( APPLICATION, "x-document-statistics" ),
23
  APP_FILE_MANAGER( APPLICATION, "x-file-manager" ),
24
25
  APP_ACAD( APPLICATION, "acad" ),
26
  APP_JAVA_OBJECT( APPLICATION, "x-java-serialized-object" ),
27
  APP_JAVA( APPLICATION, "java" ),
28
  APP_PS( APPLICATION, "postscript" ),
29
  APP_EPS( APPLICATION, "eps" ),
30
  APP_PDF( APPLICATION, "pdf" ),
31
  APP_ZIP( APPLICATION, "zip" ),
32
33
  /*
34
   * Standard font types.
35
   */
36
  FONT_OTF( "otf" ),
37
  FONT_TTF( "ttf" ),
38
39
  /*
40
   * Standard image types.
41
   */
42
  IMAGE_APNG( "apng" ),
43
  IMAGE_ACES( "aces" ),
44
  IMAGE_AVCI( "avci" ),
45
  IMAGE_AVCS( "avcs" ),
46
  IMAGE_BMP( "bmp" ),
47
  IMAGE_CGM( "cgm" ),
48
  IMAGE_DICOM_RLE( "dicom_rle" ),
49
  IMAGE_EMF( "emf" ),
50
  IMAGE_EXAMPLE( "example" ),
51
  IMAGE_FITS( "fits" ),
52
  IMAGE_G3FAX( "g3fax" ),
53
  IMAGE_GIF( "gif" ),
54
  IMAGE_HEIC( "heic" ),
55
  IMAGE_HEIF( "heif" ),
56
  IMAGE_HEJ2K( "hej2k" ),
57
  IMAGE_HSJ2( "hsj2" ),
58
  IMAGE_X_ICON( "x-icon" ),
59
  IMAGE_JLS( "jls" ),
60
  IMAGE_JP2( "jp2" ),
61
  IMAGE_JPEG( "jpeg" ),
62
  IMAGE_JPH( "jph" ),
63
  IMAGE_JPHC( "jphc" ),
64
  IMAGE_JPM( "jpm" ),
65
  IMAGE_JPX( "jpx" ),
66
  IMAGE_JXR( "jxr" ),
67
  IMAGE_JXRA( "jxrA" ),
68
  IMAGE_JXRS( "jxrS" ),
69
  IMAGE_JXS( "jxs" ),
70
  IMAGE_JXSC( "jxsc" ),
71
  IMAGE_JXSI( "jxsi" ),
72
  IMAGE_JXSS( "jxss" ),
73
  IMAGE_KTX( "ktx" ),
74
  IMAGE_KTX2( "ktx2" ),
75
  IMAGE_NAPLPS( "naplps" ),
76
  IMAGE_PNG( "png" ),
77
  IMAGE_PHOTOSHOP( "photoshop" ),
78
  IMAGE_SVG_XML( "svg+xml" ),
79
  IMAGE_T38( "t38" ),
80
  IMAGE_TIFF( "tiff" ),
81
  IMAGE_WEBP( "webp" ),
82
  IMAGE_WMF( "wmf" ),
83
  IMAGE_X_BITMAP( "x-xbitmap" ),
84
  IMAGE_X_PIXMAP( "x-xpixmap" ),
85
86
  /*
87
   * Standard audio types.
88
   */
89
  AUDIO_SIMPLE( AUDIO, "basic" ),
90
  AUDIO_MP3( AUDIO, "mp3" ),
91
  AUDIO_WAV( AUDIO, "x-wav" ),
92
93
  /*
94
   * Standard video types.
95
   */
96
  VIDEO_MNG( VIDEO, "x-mng" ),
97
98
  /*
99
   * Document types for editing or displaying documents, mix of standard and
100
   * application-specific. The order that these are declared reflect in the
101
   * ordinal value used during comparisons.
102
   */
103
  TEXT_YAML( TEXT, "yaml" ),
104
  TEXT_PLAIN( TEXT, "plain" ),
105
  TEXT_MARKDOWN( TEXT, "markdown" ),
106
  TEXT_R_MARKDOWN( TEXT, "R+markdown" ),
107
  TEXT_PROPERTIES( TEXT, "x-java-properties" ),
108
  TEXT_HTML( TEXT, "html" ),
109
  TEXT_XHTML( TEXT, "xhtml+xml" ),
110
  TEXT_XML( TEXT, "xml" ),
111
112
  /*
113
   * When all other lights go out.
114
   */
115
  UNDEFINED( TypeName.UNDEFINED, "undefined" );
116
117
  /**
118
   * The IANA-defined types.
119
   */
120
  public enum TypeName {
121
    APPLICATION,
122
    AUDIO,
123
    IMAGE,
124
    TEXT,
125
    UNDEFINED,
126
    VIDEO
127
  }
128
129
  /**
130
   * The fully qualified IANA-defined media type.
131
   */
132
  private final String mMediaType;
133
134
  /**
135
   * The IANA-defined type name.
136
   */
137
  private final TypeName mTypeName;
138
139
  /**
140
   * The IANA-defined subtype name.
141
   */
142
  private final String mSubtype;
143
144
  /**
145
   * Constructs an instance using the default type name of "image".
146
   *
147
   * @param subtype The image subtype name.
148
   */
149
  MediaType( final String subtype ) {
150
    this( IMAGE, subtype );
151
  }
152
153
  /**
154
   * Constructs an instance using an IANA-defined type and subtype pair.
155
   *
156
   * @param typeName The media type's type name.
157
   * @param subtype  The media type's subtype name.
158
   */
159
  MediaType( final TypeName typeName, final String subtype ) {
160
    mTypeName = typeName;
161
    mSubtype = subtype;
162
    mMediaType = typeName.toString().toLowerCase() + '/' + subtype;
163
  }
164
165
  /**
166
   * Returns the {@link MediaType} associated with the given file.
167
   *
168
   * @param file Has a file name that may contain an extension associated with
169
   *             a known {@link MediaType}.
170
   * @return {@link MediaType#UNDEFINED} if the extension has not been
171
   * assigned, otherwise the {@link MediaType} associated with this
172
   * {@link File}'s file name extension.
173
   */
174
  public static MediaType valueFrom( final File file ) {
175
    assert file != null;
176
    return fromFilename( file.getName() );
177
  }
178
179
  /**
180
   * Returns the {@link MediaType} associated with the given file name.
181
   *
182
   * @param filename The file name that may contain an extension associated
183
   *                 with a known {@link MediaType}.
184
   * @return {@link MediaType#UNDEFINED} if the extension has not been
185
   * assigned, otherwise the {@link MediaType} associated with this
186
   * {@link URL}'s file name extension.
187
   */
188
  public static MediaType fromFilename( final String filename ) {
189
    assert filename != null;
190
    return fromExtension( getExtension( filename ) );
191
  }
192
193
  /**
194
   * Returns the {@link MediaType} associated with the path to a file.
195
   *
196
   * @param path Has a file name that may contain an extension associated with
197
   *             a known {@link MediaType}.
198
   * @return {@link MediaType#UNDEFINED} if the extension has not been
199
   * assigned, otherwise the {@link MediaType} associated with this
200
   * {@link File}'s file name extension.
201
   */
202
  public static MediaType valueFrom( final Path path ) {
203
    assert path != null;
204
    return valueFrom( path.toFile() );
205
  }
206
207
  /**
208
   * Determines the media type an IANA-defined, semi-colon-separated string.
209
   * This is often used after making an HTTP request to extract the type
210
   * and subtype from the content-type.
211
   *
212
   * @param header The content-type header value, may be {@code null}.
213
   * @return The data type for the resource or {@link MediaType#UNDEFINED} if
214
   * unmapped.
215
   */
216
  public static MediaType valueFrom( String header ) {
217
    if( header == null || header.isBlank() ) {
218
      return UNDEFINED;
219
    }
220
221
    // Trim off the character encoding.
222
    var i = header.indexOf( ';' );
223
    header = header.substring( 0, i == -1 ? header.length() : i );
224
225
    // Split the type and subtype.
226
    i = header.indexOf( '/' );
227
    i = i == -1 ? header.length() : i;
228
    final var type = header.substring( 0, i );
229
    final var subtype = header.substring( i + 1 );
230
231
    return valueFrom( type, subtype );
232
  }
233
234
  /**
235
   * Returns the {@link MediaType} for the given type and subtype names.
236
   *
237
   * @param type    The IANA-defined type name.
238
   * @param subtype The IANA-defined subtype name.
239
   * @return {@link MediaType#UNDEFINED} if there is no {@link MediaType} that
240
   * matches the given type and subtype names.
241
   */
242
  public static MediaType valueFrom(
243
    final String type, final String subtype ) {
244
    assert type != null;
245
    assert subtype != null;
246
247
    for( final var mediaType : values() ) {
248
      if( mediaType.equals( type, subtype ) ) {
249
        return mediaType;
250
      }
251
    }
252
253
    return UNDEFINED;
254
  }
255
256
  /**
257
   * Answers whether the given type and subtype names equal this enumerated
258
   * value. This performs a case-insensitive comparison.
259
   *
260
   * @param type    The type name to compare against this {@link MediaType}.
261
   * @param subtype The subtype name to compare against this {@link MediaType}.
262
   * @return {@code true} when the type and subtype name match.
263
   */
264
  public boolean equals( final String type, final String subtype ) {
265
    assert type != null;
266
    assert subtype != null;
267
268
    return mTypeName.name().equalsIgnoreCase( type ) &&
269
      mSubtype.equalsIgnoreCase( subtype );
270
  }
271
272
  /**
273
   * Answers whether the given {@link TypeName} matches this type name.
274
   *
275
   * @param typeName The {@link TypeName} to compare against the internal value.
276
   * @return {@code true} if the given value is the same IANA-defined type name.
277
   */
278
  public boolean isType( final TypeName typeName ) {
279
    return mTypeName == typeName;
280
  }
281
282
  /**
283
   * Answers whether this instance is a scalable vector graphic.
284
   *
285
   * @return {@code true} if this instance represents an SVG object.
286
   */
287
  public boolean isSvg() {
288
    return this == IMAGE_SVG_XML;
289
  }
290
291
  public boolean isUndefined() {
292
    return this == UNDEFINED;
293
  }
294
295
  /**
296
   * Returns the IANA-defined subtype classification. Primarily used by
297
   * {@link MediaTypeExtension} to initialize associations where the subtype
298
   * name and the file name extension have a 1:1 mapping.
299
   *
300
   * @return The IANA subtype value.
301
   */
302
  public String getSubtype() {
303
    return mSubtype;
304
  }
305
306
  /**
307
   * Creates a temporary {@link File} that starts with the given prefix.
308
   *
309
   * @param prefix The file name begins with this string (may be empty).
310
   * @return The fully qualified path to the temporary file.
311
   * @throws IOException Could not create the temporary file.
312
   */
313
  public Path createTempFile( final String prefix ) throws IOException {
314
    return createTempFile( prefix, false );
315
  }
316
317
  /**
318
   * Creates a temporary {@link File} that starts with the given prefix.
319
   *
320
   * @param prefix The file name begins with this string (may be empty).
321
   * @param purge  Set to {@code true} to delete the file on exit.
322
   * @return The fully qualified path to the temporary file.
323
   * @throws IOException Could not create the temporary file.
324
   */
325
  public Path createTempFile(
326
    final String prefix, final boolean purge ) throws IOException {
327
    assert prefix != null;
328
329
    final var file = File.createTempFile(
330
      prefix, '.' + MediaTypeExtension.valueFrom( this ).getExtension() );
331
332
    if( purge ) {
333
      file.deleteOnExit();
334
    }
335
336
    return file.toPath();
337
  }
338
339
  /**
340
   * Returns the IANA-defined type and sub-type.
20
@SuppressWarnings( "SpellCheckingInspection" )
21
public enum MediaType {
22
  APP_DOCUMENT_OUTLINE( APPLICATION, "x-document-outline" ),
23
  APP_DOCUMENT_STATISTICS( APPLICATION, "x-document-statistics" ),
24
  APP_FILE_MANAGER( APPLICATION, "x-file-manager" ),
25
26
  APP_ACAD( APPLICATION, "acad" ),
27
  APP_JAVA_OBJECT( APPLICATION, "x-java-serialized-object" ),
28
  APP_JAVA( APPLICATION, "java" ),
29
  APP_PS( APPLICATION, "postscript" ),
30
  APP_EPS( APPLICATION, "eps" ),
31
  APP_PDF( APPLICATION, "pdf" ),
32
  APP_ZIP( APPLICATION, "zip" ),
33
34
  /*
35
   * Standard font types.
36
   */
37
  FONT_OTF( "otf" ),
38
  FONT_TTF( "ttf" ),
39
40
  /*
41
   * Standard image types.
42
   */
43
  IMAGE_APNG( "apng" ),
44
  IMAGE_ACES( "aces" ),
45
  IMAGE_AVCI( "avci" ),
46
  IMAGE_AVCS( "avcs" ),
47
  IMAGE_BMP( "bmp" ),
48
  IMAGE_CGM( "cgm" ),
49
  IMAGE_DICOM_RLE( "dicom_rle" ),
50
  IMAGE_EMF( "emf" ),
51
  IMAGE_EXAMPLE( "example" ),
52
  IMAGE_FITS( "fits" ),
53
  IMAGE_G3FAX( "g3fax" ),
54
  IMAGE_GIF( "gif" ),
55
  IMAGE_HEIC( "heic" ),
56
  IMAGE_HEIF( "heif" ),
57
  IMAGE_HEJ2K( "hej2k" ),
58
  IMAGE_HSJ2( "hsj2" ),
59
  IMAGE_X_ICON( "x-icon" ),
60
  IMAGE_JLS( "jls" ),
61
  IMAGE_JP2( "jp2" ),
62
  IMAGE_JPEG( "jpeg" ),
63
  IMAGE_JPH( "jph" ),
64
  IMAGE_JPHC( "jphc" ),
65
  IMAGE_JPM( "jpm" ),
66
  IMAGE_JPX( "jpx" ),
67
  IMAGE_JXR( "jxr" ),
68
  IMAGE_JXRA( "jxrA" ),
69
  IMAGE_JXRS( "jxrS" ),
70
  IMAGE_JXS( "jxs" ),
71
  IMAGE_JXSC( "jxsc" ),
72
  IMAGE_JXSI( "jxsi" ),
73
  IMAGE_JXSS( "jxss" ),
74
  IMAGE_KTX( "ktx" ),
75
  IMAGE_KTX2( "ktx2" ),
76
  IMAGE_NAPLPS( "naplps" ),
77
  IMAGE_PNG( "png" ),
78
  IMAGE_PHOTOSHOP( "photoshop" ),
79
  IMAGE_SVG_XML( "svg+xml" ),
80
  IMAGE_T38( "t38" ),
81
  IMAGE_TIFF( "tiff" ),
82
  IMAGE_WEBP( "webp" ),
83
  IMAGE_WMF( "wmf" ),
84
  IMAGE_X_BITMAP( "x-xbitmap" ),
85
  IMAGE_X_PIXMAP( "x-xpixmap" ),
86
87
  /*
88
   * Standard audio types.
89
   */
90
  AUDIO_SIMPLE( AUDIO, "basic" ),
91
  AUDIO_MP3( AUDIO, "mp3" ),
92
  AUDIO_WAV( AUDIO, "x-wav" ),
93
94
  /*
95
   * Standard video types.
96
   */
97
  VIDEO_MNG( VIDEO, "x-mng" ),
98
99
  /*
100
   * Document types for editing or displaying documents, mix of standard and
101
   * application-specific. The order that these are declared reflect in the
102
   * ordinal value used during comparisons.
103
   */
104
  TEXT_YAML( TEXT, "yaml" ),
105
  TEXT_PLAIN( TEXT, "plain" ),
106
  TEXT_MARKDOWN( TEXT, "markdown" ),
107
  TEXT_R_MARKDOWN( TEXT, "R+markdown" ),
108
  TEXT_PROPERTIES( TEXT, "x-java-properties" ),
109
  TEXT_HTML( TEXT, "html" ),
110
  TEXT_XHTML( TEXT, "xhtml+xml" ),
111
  TEXT_XML( TEXT, "xml" ),
112
113
  /*
114
   * When all other lights go out.
115
   */
116
  UNDEFINED( TypeName.UNDEFINED, "undefined" );
117
118
  /**
119
   * The IANA-defined types.
120
   */
121
  public enum TypeName {
122
    APPLICATION,
123
    AUDIO,
124
    IMAGE,
125
    TEXT,
126
    UNDEFINED,
127
    VIDEO
128
  }
129
130
  /**
131
   * The fully qualified IANA-defined media type.
132
   */
133
  private final String mMediaType;
134
135
  /**
136
   * The IANA-defined type name.
137
   */
138
  private final TypeName mTypeName;
139
140
  /**
141
   * The IANA-defined subtype name.
142
   */
143
  private final String mSubtype;
144
145
  /**
146
   * Constructs an instance using the default type name of "image".
147
   *
148
   * @param subtype The image subtype name.
149
   */
150
  MediaType( final String subtype ) {
151
    this( IMAGE, subtype );
152
  }
153
154
  /**
155
   * Constructs an instance using an IANA-defined type and subtype pair.
156
   *
157
   * @param typeName The media type's type name.
158
   * @param subtype  The media type's subtype name.
159
   */
160
  MediaType( final TypeName typeName, final String subtype ) {
161
    mTypeName = typeName;
162
    mSubtype = subtype;
163
    mMediaType = typeName.toString().toLowerCase() + '/' + subtype;
164
  }
165
166
  /**
167
   * Returns the {@link MediaType} associated with the given file.
168
   *
169
   * @param file Has a file name that may contain an extension associated with
170
   *             a known {@link MediaType}.
171
   * @return {@link MediaType#UNDEFINED} if the extension has not been
172
   * assigned, otherwise the {@link MediaType} associated with this
173
   * {@link File}'s file name extension.
174
   */
175
  public static MediaType valueFrom( final File file ) {
176
    assert file != null;
177
    return fromFilename( file.getName() );
178
  }
179
180
  /**
181
   * Returns the {@link MediaType} associated with the given file name.
182
   *
183
   * @param filename The file name that may contain an extension associated
184
   *                 with a known {@link MediaType}.
185
   * @return {@link MediaType#UNDEFINED} if the extension has not been
186
   * assigned, otherwise the {@link MediaType} associated with this
187
   * {@link URL}'s file name extension.
188
   */
189
  public static MediaType fromFilename( final String filename ) {
190
    assert filename != null;
191
    return fromExtension( getExtension( filename ) );
192
  }
193
194
  /**
195
   * Returns the {@link MediaType} associated with the path to a file.
196
   *
197
   * @param path Has a file name that may contain an extension associated with
198
   *             a known {@link MediaType}.
199
   * @return {@link MediaType#UNDEFINED} if the extension has not been
200
   * assigned, otherwise the {@link MediaType} associated with this
201
   * {@link File}'s file name extension.
202
   */
203
  public static MediaType valueFrom( final Path path ) {
204
    assert path != null;
205
    return valueFrom( path.toFile() );
206
  }
207
208
  /**
209
   * Determines the media type an IANA-defined, semi-colon-separated string.
210
   * This is often used after making an HTTP request to extract the type
211
   * and subtype from the content-type.
212
   *
213
   * @param header The content-type header value, may be {@code null}.
214
   * @return The data type for the resource or {@link MediaType#UNDEFINED} if
215
   * unmapped.
216
   */
217
  public static MediaType valueFrom( String header ) {
218
    if( header == null || header.isBlank() ) {
219
      return UNDEFINED;
220
    }
221
222
    // Trim off the character encoding.
223
    var i = header.indexOf( ';' );
224
    header = header.substring( 0, i == -1 ? header.length() : i );
225
226
    // Split the type and subtype.
227
    i = header.indexOf( '/' );
228
    i = i == -1 ? header.length() : i;
229
    final var type = header.substring( 0, i );
230
    final var subtype = header.substring( i + 1 );
231
232
    return valueFrom( type, subtype );
233
  }
234
235
  /**
236
   * Returns the {@link MediaType} for the given type and subtype names.
237
   *
238
   * @param type    The IANA-defined type name.
239
   * @param subtype The IANA-defined subtype name.
240
   * @return {@link MediaType#UNDEFINED} if there is no {@link MediaType} that
241
   * matches the given type and subtype names.
242
   */
243
  public static MediaType valueFrom(
244
    final String type, final String subtype ) {
245
    assert type != null;
246
    assert subtype != null;
247
248
    for( final var mediaType : values() ) {
249
      if( mediaType.equals( type, subtype ) ) {
250
        return mediaType;
251
      }
252
    }
253
254
    return UNDEFINED;
255
  }
256
257
  /**
258
   * Answers whether the given type and subtype names equal this enumerated
259
   * value. This performs a case-insensitive comparison.
260
   *
261
   * @param type    The type name to compare against this {@link MediaType}.
262
   * @param subtype The subtype name to compare against this {@link MediaType}.
263
   * @return {@code true} when the type and subtype name match.
264
   */
265
  public boolean equals( final String type, final String subtype ) {
266
    assert type != null;
267
    assert subtype != null;
268
269
    return mTypeName.name().equalsIgnoreCase( type ) &&
270
      mSubtype.equalsIgnoreCase( subtype );
271
  }
272
273
  /**
274
   * Answers whether the given {@link TypeName} matches this type name.
275
   *
276
   * @param typeName The {@link TypeName} to compare against the internal value.
277
   * @return {@code true} if the given value is the same IANA-defined type name.
278
   */
279
  @SuppressWarnings( "unused" )
280
  public boolean isType( final TypeName typeName ) {
281
    return mTypeName == typeName;
282
  }
283
284
  /**
285
   * Answers whether this instance is a scalable vector graphic.
286
   *
287
   * @return {@code true} if this instance represents an SVG object.
288
   */
289
  public boolean isSvg() {
290
    return equals( IMAGE_SVG_XML );
291
  }
292
293
  public boolean isUndefined() {
294
    return equals( UNDEFINED );
295
  }
296
297
  /**
298
   * Returns the IANA-defined subtype classification. Primarily used by
299
   * {@link MediaTypeExtension} to initialize associations where the subtype
300
   * name and the file name extension have a 1:1 mapping.
301
   *
302
   * @return The IANA subtype value.
303
   */
304
  public String getSubtype() {
305
    return mSubtype;
306
  }
307
308
  /**
309
   * Creates a temporary {@link File} that starts with the given prefix.
310
   *
311
   * @param prefix    The file name begins with this string (empty is allowed).
312
   * @param directory The directory wherein the file is created.
313
   * @return The fully qualified path to the temporary file.
314
   * @throws IOException Could not create the temporary file.
315
   */
316
  public Path createTempFile(
317
    final String prefix,
318
    final Path directory ) throws IOException {
319
    return createTempFile( prefix, directory, false );
320
  }
321
322
  /**
323
   * Creates a temporary {@link File} that starts with the given prefix.
324
   *
325
   * @param prefix    The file name begins with this string (empty is allowed).
326
   * @param directory The directory wherein the file is created.
327
   * @param purge     Set to {@code true} to delete the file on exit.
328
   * @return The fully qualified path to the temporary file.
329
   * @throws IOException Could not create the temporary file.
330
   */
331
  public Path createTempFile(
332
    final String prefix,
333
    final Path directory,
334
    final boolean purge )
335
    throws IOException {
336
    assert prefix != null;
337
338
    final var suffix = '.' + MediaTypeExtension
339
      .valueFrom( this )
340
      .getExtension();
341
342
    final var file = File.createTempFile( prefix, suffix, directory.toFile() );
343
344
    if( purge ) {
345
      file.deleteOnExit();
346
    }
347
348
    return file.toPath();
349
  }
350
351
  /**
352
   * Returns the IANA-defined type and subtype.
341353
   *
342354
   * @return The unique media type identifier.
M src/main/java/com/keenwrite/io/MediaTypeSniffer.java
33
44
import java.io.*;
5
import java.nio.file.Path;
65
import java.util.LinkedHashMap;
76
import java.util.Map;
87
98
import static com.keenwrite.io.MediaType.*;
109
import static java.lang.System.arraycopy;
1110
1211
/**
1312
 * Associates file signatures with IANA-defined {@link MediaType}s. See:
1413
 * <a href="https://www.garykessler.net/library/file_sigs.html">
15
 * Kessler's List
14
 * Gary Kessler's List
1615
 * </a>,
1716
 * <a href="https://en.wikipedia.org/wiki/List_of_file_signatures">
...
2827
  private static final Map<int[], MediaType> FORMAT = new LinkedHashMap<>();
2928
30
  private static void add( final int[] data, final MediaType mediaType ) {
29
  private static void put( final int[] data, final MediaType mediaType ) {
3130
    FORMAT.put( data, mediaType );
3231
  }
3332
3433
  static {
3534
    //@formatter:off
36
    add( ints( 0x3C, 0x73, 0x76, 0x67, 0x20 ), IMAGE_SVG_XML );
37
    add( ints( 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ), IMAGE_PNG );
38
    add( ints( 0xFF, 0xD8, 0xFF, 0xE0 ), IMAGE_JPEG );
39
    add( ints( 0xFF, 0xD8, 0xFF, 0xEE ), IMAGE_JPEG );
40
    add( ints( 0xFF, 0xD8, 0xFF, 0xE1, -1, -1, 0x45, 0x78, 0x69, 0x66, 0x00 ), IMAGE_JPEG );
41
    add( ints( 0x49, 0x49, 0x2A, 0x00 ), IMAGE_TIFF );
42
    add( ints( 0x4D, 0x4D, 0x00, 0x2A ), IMAGE_TIFF );
43
    add( ints( 0x47, 0x49, 0x46, 0x38 ), IMAGE_GIF );
44
    add( ints( 0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50 ), IMAGE_WEBP );
45
    add( ints( 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E ), APP_PDF );
46
    add( ints( 0x25, 0x21, 0x50, 0x53, 0x2D, 0x41, 0x64, 0x6F, 0x62, 0x65, 0x2D ), APP_EPS );
47
    add( ints( 0x25, 0x21, 0x50, 0x53 ), APP_PS );
48
    add( ints( 0x38, 0x42, 0x50, 0x53, 0x00, 0x01 ), IMAGE_PHOTOSHOP );
49
    add( ints( 0x8A, 0x4D, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ), VIDEO_MNG );
50
    add( ints( 0x42, 0x4D ), IMAGE_BMP );
51
    add( ints( 0xFF, 0xFB, 0x30 ), AUDIO_MP3 );
52
    add( ints( 0x49, 0x44, 0x33 ), AUDIO_MP3 );
53
    add( ints( 0x3C, 0x21 ), TEXT_HTML );
54
    add( ints( 0x3C, 0x68, 0x74, 0x6D, 0x6C ), TEXT_HTML );
55
    add( ints( 0x3C, 0x68, 0x65, 0x61, 0x64 ), TEXT_HTML );
56
    add( ints( 0x3C, 0x62, 0x6F, 0x64, 0x79 ), TEXT_HTML );
57
    add( ints( 0x3C, 0x48, 0x54, 0x4D, 0x4C ), TEXT_HTML );
58
    add( ints( 0x3C, 0x48, 0x45, 0x41, 0x44 ), TEXT_HTML );
59
    add( ints( 0x3C, 0x42, 0x4F, 0x44, 0x59 ), TEXT_HTML );
60
    add( ints( 0x3C, 0x3F, 0x78, 0x6D, 0x6C, 0x20 ), TEXT_XML );
61
    add( ints( 0xFE, 0xFF, 0x00, 0x3C, 0x00, 0x3f, 0x00, 0x78 ), TEXT_XML );
62
    add( ints( 0xFF, 0xFE, 0x3C, 0x00, 0x3F, 0x00, 0x78, 0x00 ), TEXT_XML );
63
    add( ints( 0x23, 0x64, 0x65, 0x66 ), IMAGE_X_BITMAP );
64
    add( ints( 0x21, 0x20, 0x58, 0x50, 0x4D, 0x32 ), IMAGE_X_PIXMAP );
65
    add( ints( 0x2E, 0x73, 0x6E, 0x64 ), AUDIO_SIMPLE );
66
    add( ints( 0x64, 0x6E, 0x73, 0x2E ), AUDIO_SIMPLE );
67
    add( ints( 0x52, 0x49, 0x46, 0x46 ), AUDIO_WAV );
68
    add( ints( 0x50, 0x4B ), APP_ZIP );
69
    add( ints( 0x41, 0x43, -1, -1, -1, -1, 0x00, 0x00, 0x00, 0x00, 0x00 ), APP_ACAD );
70
    add( ints( 0xCA, 0xFE, 0xBA, 0xBE ), APP_JAVA );
71
    add( ints( 0xAC, 0xED ), APP_JAVA_OBJECT );
35
    put( ints( 0x3C, 0x73, 0x76, 0x67, 0x20 ), IMAGE_SVG_XML );
36
    put( ints( 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ), IMAGE_PNG );
37
    put( ints( 0xFF, 0xD8, 0xFF, 0xE0 ), IMAGE_JPEG );
38
    put( ints( 0xFF, 0xD8, 0xFF, 0xEE ), IMAGE_JPEG );
39
    put( ints( 0xFF, 0xD8, 0xFF, 0xE1, -1, -1, 0x45, 0x78, 0x69, 0x66, 0x00 ), IMAGE_JPEG );
40
    put( ints( 0x49, 0x49, 0x2A, 0x00 ), IMAGE_TIFF );
41
    put( ints( 0x4D, 0x4D, 0x00, 0x2A ), IMAGE_TIFF );
42
    put( ints( 0x47, 0x49, 0x46, 0x38 ), IMAGE_GIF );
43
    put( ints( 0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50 ), IMAGE_WEBP );
44
    put( ints( 0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E ), APP_PDF );
45
    put( ints( 0x25, 0x21, 0x50, 0x53, 0x2D, 0x41, 0x64, 0x6F, 0x62, 0x65, 0x2D ), APP_EPS );
46
    put( ints( 0x25, 0x21, 0x50, 0x53 ), APP_PS );
47
    put( ints( 0x38, 0x42, 0x50, 0x53, 0x00, 0x01 ), IMAGE_PHOTOSHOP );
48
    put( ints( 0x8A, 0x4D, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ), VIDEO_MNG );
49
    put( ints( 0x42, 0x4D ), IMAGE_BMP );
50
    put( ints( 0xFF, 0xFB, 0x30 ), AUDIO_MP3 );
51
    put( ints( 0x49, 0x44, 0x33 ), AUDIO_MP3 );
52
    put( ints( 0x3C, 0x21 ), TEXT_HTML );
53
    put( ints( 0x3C, 0x68, 0x74, 0x6D, 0x6C ), TEXT_HTML );
54
    put( ints( 0x3C, 0x68, 0x65, 0x61, 0x64 ), TEXT_HTML );
55
    put( ints( 0x3C, 0x62, 0x6F, 0x64, 0x79 ), TEXT_HTML );
56
    put( ints( 0x3C, 0x48, 0x54, 0x4D, 0x4C ), TEXT_HTML );
57
    put( ints( 0x3C, 0x48, 0x45, 0x41, 0x44 ), TEXT_HTML );
58
    put( ints( 0x3C, 0x42, 0x4F, 0x44, 0x59 ), TEXT_HTML );
59
    put( ints( 0x3C, 0x3F, 0x78, 0x6D, 0x6C, 0x20 ), TEXT_XML );
60
    put( ints( 0xFE, 0xFF, 0x00, 0x3C, 0x00, 0x3f, 0x00, 0x78 ), TEXT_XML );
61
    put( ints( 0xFF, 0xFE, 0x3C, 0x00, 0x3F, 0x00, 0x78, 0x00 ), TEXT_XML );
62
    put( ints( 0x23, 0x64, 0x65, 0x66 ), IMAGE_X_BITMAP );
63
    put( ints( 0x21, 0x20, 0x58, 0x50, 0x4D, 0x32 ), IMAGE_X_PIXMAP );
64
    put( ints( 0x2E, 0x73, 0x6E, 0x64 ), AUDIO_SIMPLE );
65
    put( ints( 0x64, 0x6E, 0x73, 0x2E ), AUDIO_SIMPLE );
66
    put( ints( 0x52, 0x49, 0x46, 0x46 ), AUDIO_WAV );
67
    put( ints( 0x50, 0x4B ), APP_ZIP );
68
    put( ints( 0x41, 0x43, -1, -1, -1, -1, 0x00, 0x00, 0x00, 0x00, 0x00 ), APP_ACAD );
69
    put( ints( 0xCA, 0xFE, 0xBA, 0xBE ), APP_JAVA );
70
    put( ints( 0xAC, 0xED ), APP_JAVA_OBJECT );
7271
    //@formatter:on
7372
  }
...
105104
106105
    return UNDEFINED;
107
  }
108
109
  /**
110
   * Convenience method to return the probed media type for the given
111
   * {@link Path} instance by delegating to {@link #getMediaType(InputStream)}.
112
   *
113
   * @param path Path to ascertain the {@link MediaType}.
114
   * @return The IANA-defined {@link MediaType}, or
115
   * {@link MediaType#UNDEFINED} if indeterminate.
116
   * @throws IOException Could not read from the {@link SysFile}.
117
   */
118
  public static MediaType getMediaType( final Path path ) throws IOException {
119
    return getMediaType( path.toFile() );
120106
  }
121107
...
128114
   * @return The IANA-defined {@link MediaType}, or
129115
   * {@link MediaType#UNDEFINED} if indeterminate.
130
   * @throws IOException Could not read from the {@link SysFile}.
116
   * @throws IOException Could not read from the {@link File}.
131117
   */
132118
  public static MediaType getMediaType( final File file )
...
147133
   * @return The IANA-defined {@link MediaType}, or
148134
   * {@link MediaType#UNDEFINED} if indeterminate.
149
   * @throws IOException Could not read from the {@link SysFile}.
135
   * @throws IOException Could not read from the stream.
150136
   */
151137
  public static MediaType getMediaType( final BufferedInputStream bis )
...
159145
160146
  /**
161
   * Helper method to return the probed media type for the given
162
   * {@link InputStream} instance. The caller is responsible for closing
163
   * the stream. <strong>This advances the stream pointer.</strong>
147
   * Returns the probed media type for the given {@link InputStream} instance.
148
   * The caller is responsible for closing the stream. <strong>This advances
149
   * the stream.</strong> Use {@link #getMediaType(BufferedInputStream)} to
150
   * perform a non-destructive read.
164151
   *
165152
   * @param is Data source to ascertain the {@link MediaType}.
166153
   * @return The IANA-defined {@link MediaType}, or
167154
   * {@link MediaType#UNDEFINED} if indeterminate.
168155
   * @throws IOException Could not read from the {@link InputStream}.
169
   * @see #getMediaType(BufferedInputStream) to perform a non-destructive
170
   * read.
171156
   */
172157
  private static MediaType getMediaType( final InputStream is )
...
185170
186171
  /**
187
   * Creates an array of integers from the given data, padded with {@link
188
   * #END_OF_DATA} values up to {@link #FORMAT_LENGTH}.
172
   * Creates integer array from the given data, padded with
173
   * {@link #END_OF_DATA} values up to {@link #FORMAT_LENGTH}.
189174
   *
190175
   * @param data The input byte values to pad.
191176
   * @return The data with padding.
192177
   */
193178
  private static int[] ints( final int... data ) {
194
    final var magic = new int[ FORMAT_LENGTH ];
179
    final var magic = new int[ FORMAT_LENGTH + 1 ];
195180
    int i = -1;
196181
A src/main/java/com/keenwrite/io/StreamGobbler.java
1
package com.keenwrite.io;
2
3
import java.io.BufferedReader;
4
import java.io.IOException;
5
import java.io.InputStream;
6
import java.io.InputStreamReader;
7
import java.util.concurrent.Callable;
8
import java.util.function.Consumer;
9
10
import static java.util.concurrent.Executors.newFixedThreadPool;
11
12
/**
13
 * Consumes the standard output of a {@link Process} created from a
14
 * {@link ProcessBuilder}. Directs the output to a {@link Consumer} of
15
 * strings. This will run on its own thread and close the stream when
16
 * no more data can be processed.
17
 * <p>
18
 * <strong>Warning:</strong> Do not use this with binary data, it is only
19
 * meant for text streams, such as standard out from running command-line
20
 * applications.
21
 * </p>
22
 */
23
public class StreamGobbler implements Callable<Boolean> {
24
  private final InputStream mInput;
25
  private final Consumer<String> mConsumer;
26
27
  /**
28
   * Constructs a new instance of {@link StreamGobbler} that is capable of
29
   * reading an {@link InputStream} and passing each line of textual data from
30
   * that stream over to a string {@link Consumer}.
31
   *
32
   * @param input    The stream having input to pass to the consumer.
33
   * @param consumer The {@link Consumer} that receives each line.
34
   */
35
  private StreamGobbler(
36
    final InputStream input,
37
    final Consumer<String> consumer ) {
38
    assert input != null;
39
    assert consumer != null;
40
41
    mInput = input;
42
    mConsumer = consumer;
43
  }
44
45
  /**
46
   * Consumes the input until no more data is available. Closes the stream.
47
   *
48
   * @return {@link Boolean#TRUE} always.
49
   * @throws IOException Could not read from the stream.
50
   */
51
  @Override
52
  public Boolean call() throws IOException {
53
    try( final var input = new InputStreamReader( mInput );
54
         final var buffer = new BufferedReader( input ) ) {
55
      buffer.lines().forEach( mConsumer );
56
    }
57
58
    return Boolean.TRUE;
59
  }
60
61
  /**
62
   * Reads the given {@link InputStream} on a separate thread and passes
63
   * each line of text input to the given {@link Consumer}.
64
   *
65
   * @param inputStream The stream having input to pass to the consumer.
66
   * @param consumer    The {@link Consumer} that receives each line.
67
   */
68
  public static void gobble(
69
    final InputStream inputStream, final Consumer<String> consumer ) {
70
    try( final var executor = newFixedThreadPool( 1 ) ) {
71
      executor.submit( new StreamGobbler( inputStream, consumer ) );
72
    }
73
  }
74
}
175
M src/main/java/com/keenwrite/io/SysFile.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.io;
33
4
import java.io.File;
5
import java.io.FileInputStream;
6
import java.io.IOException;
47
import java.nio.file.Path;
5
import java.nio.file.Paths;
6
import java.util.stream.Stream;
8
import java.security.MessageDigest;
9
import java.security.NoSuchAlgorithmException;
10
import java.util.Optional;
11
import java.util.function.Function;
12
import java.util.regex.Pattern;
713
14
import static com.keenwrite.util.DataTypeConverter.toHex;
815
import static java.lang.System.getenv;
916
import static java.nio.file.Files.isExecutable;
17
import static java.util.regex.Pattern.compile;
1018
import static java.util.regex.Pattern.quote;
19
import static org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS;
1120
1221
/**
1322
 * Responsible for file-related functionality.
1423
 */
15
public class SysFile extends java.io.File {
24
public final class SysFile extends java.io.File {
1625
  /**
17
   * For finding executable programs.
26
   * For finding executable programs. These are used in an O( n^2 ) search,
27
   * so don't add more entries than necessary.
1828
   */
1929
  private static final String[] EXTENSIONS = new String[]
20
    {"", ".com", ".exe", ".bat", ".cmd"};
30
    {"", ".exe", ".bat", ".cmd", ".msi", ".com"};
31
32
  /**
33
   * Number of bytes to read at a time when computing this file's checksum.
34
   */
35
  private static final int BUFFER_SIZE = 16384;
36
37
  //@formatter:off
38
  private static final String SYS_KEY =
39
    "HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
40
  private static final String USR_KEY =
41
    "HKEY_CURRENT_USER\\Environment";
42
  //@formatter:on
43
44
  /**
45
   * Regular expression pattern for matching %VARIABLE% names.
46
   */
47
  private static final String VAR_REGEX = "%.*?%";
48
  private static final Pattern VAR_PATTERN = compile( VAR_REGEX );
49
50
  private static final String REG_REGEX = "\\s*path\\s+REG_EXPAND_SZ\\s+(.*)";
51
  private static final Pattern REG_PATTERN = compile( REG_REGEX );
2152
2253
  /**
2354
   * Creates a new instance for a given file name.
2455
   *
25
   * @param pathname File name to represent for subsequent operations.
56
   * @param filename Filename to query existence as executable.
2657
   */
27
  public SysFile( final String pathname ) {
28
    super( pathname );
58
  public SysFile( final String filename ) {
59
    super( filename );
60
  }
61
62
  /**
63
   * Creates a new instance for a given {@link File}. This is useful for
64
   * validating checksums against an existing {@link File} instance that
65
   * may optionally exist in a directory listed in the PATH environment
66
   * variable.
67
   *
68
   * @param file The file to change into a "system file".
69
   */
70
  public SysFile( final File file ) {
71
    super( file.getAbsolutePath() );
72
  }
73
74
  /**
75
   * Answers whether the path returned from {@link #locate()} is an executable
76
   * that can be run using a {@link ProcessBuilder}.
77
   */
78
  public boolean canRun() {
79
    return locate().isPresent();
2980
  }
3081
3182
  /**
3283
   * For a file name that represents an executable (without an extension)
33
   * file, this determines whether the executable is found in the PATH
84
   * file, this determines the first matching executable found in the PATH
3485
   * environment variable. This will search the PATH each time the method
3586
   * is invoked, triggering a full directory scan for all paths listed in
3687
   * the environment variable. The result is not cached, so avoid calling
3788
   * this in a critical loop.
89
   * <p>
90
   * After installing software, the software might be located in the PATH,
91
   * but not available to run by its name alone. In such cases, we need the
92
   * absolute path to the executable to run it. This will always return
93
   * the fully qualified path, otherwise an empty result.
3894
   *
39
   * @return {@code true} when the given file name references an executable
40
   * file located in the PATH environment variable.
95
   * @param map The mapping function of registry variable names to values.
96
   * @return The fully qualified {@link Path} to the executable filename
97
   * provided at construction time.
4198
   */
42
  public boolean canRun() {
99
  public Optional<Path> locate( final Function<String, String> map ) {
43100
    final var exe = getName();
44
    final var paths = getenv( "PATH" ).split( quote( pathSeparator ) );
101
    final var paths = paths( map ).split( quote( pathSeparator ) );
45102
46
    return Stream.of( paths ).map( Paths::get ).anyMatch(
47
      path -> {
48
        final var p = path.resolve( exe );
103
    for( final var path : paths ) {
104
      final var p = Path.of( path ).resolve( exe );
49105
50
        for( final var extension : EXTENSIONS ) {
51
          if( isExecutable( Path.of( p + extension ) ) ) {
52
            return true;
53
          }
106
      for( final var extension : EXTENSIONS ) {
107
        final var filename = Path.of( p + extension );
108
109
        if( isExecutable( filename ) ) {
110
          return Optional.of( filename );
54111
        }
112
      }
113
    }
55114
56
        return false;
115
    return Optional.empty();
116
  }
117
118
  /**
119
   * Convenience method that locates a binary executable file in the path
120
   * by using {@link System#getenv(String)} to retrieve environment variables
121
   * that are expanded when parsing the PATH.
122
   *
123
   * @see #locate(Function)
124
   */
125
  public Optional<Path> locate() {
126
    return locate( System::getenv );
127
  }
128
129
  /**
130
   * Changes to the PATH environment variable aren't reflected for the
131
   * currently running task. The registry, however, contains the updated
132
   * value. Reading the registry is a hack.
133
   *
134
   * @param map The mapping function of registry variable names to values.
135
   * @return The revised PATH variables as stored in the registry.
136
   */
137
  private String paths( final Function<String, String> map ) {
138
    return IS_OS_WINDOWS ? pathsWindows( map ) : pathsSane();
139
  }
140
141
  private String pathsSane() {
142
    return getenv( "PATH" );
143
  }
144
145
  private String pathsWindows( final Function<String, String> map ) {
146
    try {
147
      final var hklm = query( SYS_KEY );
148
      final var hkcu = query( USR_KEY );
149
150
      return expand( hklm, map ) + pathSeparator + expand( hkcu, map );
151
    } catch( final IOException ex ) {
152
      // Return the PATH environment variable if the registry query fails.
153
      return pathsSane();
154
    }
155
  }
156
157
  /**
158
   * Queries a registry key PATH value.
159
   *
160
   * @param key The registry key name to look up.
161
   * @return The value for the registry key.
162
   */
163
  private String query( final String key ) throws IOException {
164
    final var regVarName = "path";
165
    final var args = new String[]{"reg", "query", key, "/v", regVarName};
166
    final var process = Runtime.getRuntime().exec( args );
167
    final var stream = process.getInputStream();
168
    final var regValue = new StringBuffer( 1024 );
169
170
    StreamGobbler.gobble( stream, text -> {
171
      if( text.contains( regVarName ) ) {
172
        regValue.append( parseRegEntry( text ) );
57173
      }
58
    );
174
    } );
175
176
    try {
177
      process.waitFor();
178
    } catch( final InterruptedException ex ) {
179
      throw new IOException( ex );
180
    } finally {
181
      process.destroy();
182
    }
183
184
185
    return regValue.toString();
186
  }
187
188
  String parseRegEntry( final String text ) {
189
    assert text != null;
190
191
    final var matcher = REG_PATTERN.matcher( text );
192
    return matcher.find() ? matcher.group( 1 ) : text.trim();
193
  }
194
195
  /**
196
   * PATH environment variables returned from the registry have unexpanded
197
   * variables of the form %VARIABLE%. This method will expand those values,
198
   * if possible, from the environment. This will only perform a single
199
   * expansion, which should be adequate for most needs.
200
   *
201
   * @param s The %VARIABLE%-encoded value to expand.
202
   * @return The given value with all encoded values expanded.
203
   */
204
  String expand( final String s, final Function<String, String> map ) {
205
    // Assigned to the unexpanded string, initially.
206
    String expanded = s;
207
208
    final var matcher = VAR_PATTERN.matcher( expanded );
209
210
    while( matcher.find() ) {
211
      final var match = matcher.group( 0 );
212
      String value = map.apply( match );
213
214
      if( value == null ) {
215
        value = "";
216
      }
217
      else {
218
        value = value.replace( "\\", "\\\\" );
219
      }
220
221
      final var subexpr = compile( quote( match ) );
222
      expanded = subexpr.matcher( expanded ).replaceAll( value );
223
    }
224
225
    return expanded;
226
  }
227
228
  /**
229
   * Answers whether this file's SHA-256 checksum equals the given
230
   * hexadecimal-encoded checksum string.
231
   *
232
   * @param hex The string to compare against the checksum for this file.
233
   * @return {@code true} if the checksums match; {@code false} on any
234
   * error or checksums don't match.
235
   */
236
  public boolean isChecksum( final String hex ) {
237
    assert hex != null;
238
239
    try {
240
      return checksum( "SHA-256" ).equalsIgnoreCase( hex );
241
    } catch( final Exception ex ) {
242
      return false;
243
    }
244
  }
245
246
  /**
247
   * Returns the hash code for this file.
248
   *
249
   * @return The hex-encoded hash code for the file contents.
250
   */
251
  @SuppressWarnings( "SameParameterValue" )
252
  private String checksum( final String algorithm )
253
    throws NoSuchAlgorithmException, IOException {
254
    final var digest = MessageDigest.getInstance( algorithm );
255
256
    try( final var in = new FileInputStream( this ) ) {
257
      final var bytes = new byte[ BUFFER_SIZE ];
258
      int count;
259
260
      while( (count = in.read( bytes )) != -1 ) {
261
        digest.update( bytes, 0, count );
262
      }
263
264
      return toHex( digest.digest() );
265
    }
59266
  }
60267
}
A src/main/java/com/keenwrite/io/UserDataDir.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import java.nio.file.Path;
5
6
import static java.lang.System.getProperty;
7
import static java.lang.System.getenv;
8
import static org.apache.commons.lang3.SystemUtils.*;
9
10
/**
11
 * Responsible for determining the directory to write application data, across
12
 * multiple platforms. See also:
13
 *
14
 * <ul>
15
 * <li>
16
 *   <a href="https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html">
17
 *     Linux: XDG Base Directory Specification
18
 *   </a>
19
 * </li>
20
 * <li>
21
 *   <a href="https://learn.microsoft.com/en-us/windows/deployment/usmt/usmt-recognized-environment-variables">
22
 *     Windows: Recognized environment variables
23
 *   </a>
24
 * </li>
25
 * <li>
26
 *   <a href="https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html">
27
 *     macOS: File System Programming Guide
28
 *   </a>
29
 * </li>
30
 * </ul>
31
 * </p>
32
 */
33
public final class UserDataDir {
34
35
  private static final Path UNDEFINED = Path.of( "/" );
36
37
  private static final String PROP_USER_HOME = getProperty( "user.home" );
38
  private static final String PROP_USER_DIR = getProperty( "user.dir" );
39
  private static final String PROP_OS_VERSION = getProperty( "os.version" );
40
  private static final String ENV_APPDATA = getenv( "AppData" );
41
  private static final String ENV_XDG_DATA_HOME = getenv( "XDG_DATA_HOME" );
42
43
  private UserDataDir() { }
44
45
  /**
46
   * Makes a valiant attempt at determining where to create application-specific
47
   * files, regardless of operating system.
48
   *
49
   * @param appName The application name that seeks to create files.
50
   * @return A fully qualified {@link Path} to a directory wherein files may
51
   * be created that are user- and application-specific.
52
   */
53
  public static Path getAppPath( final String appName ) {
54
    final var osPath = isWindows()
55
      ? getWinAppPath()
56
      : isMacOs()
57
      ? getMacAppPath()
58
      : isUnix()
59
      ? getUnixAppPath()
60
      : UNDEFINED;
61
62
    final var path = osPath.equals( UNDEFINED )
63
      ? getDefaultAppPath( appName )
64
      : osPath.resolve( appName );
65
66
    final var alternate = Path.of( PROP_USER_DIR, appName );
67
68
    return ensureExists( path )
69
      ? path
70
      : ensureExists( alternate )
71
      ? alternate
72
      : Path.of( PROP_USER_DIR );
73
  }
74
75
  private static Path getWinAppPath() {
76
    return isValid( ENV_APPDATA )
77
      ? Path.of( ENV_APPDATA )
78
      : home( getWinVerAppPath() );
79
  }
80
81
  /**
82
   * Gets the application path with respect to the Windows version.
83
   *
84
   * @return The directory name paths relative to the user's home directory.
85
   */
86
  private static String[] getWinVerAppPath() {
87
    return PROP_OS_VERSION.startsWith( "5." )
88
      ? new String[]{"Application Data"}
89
      : new String[]{"AppData", "Roaming"};
90
  }
91
92
  private static Path getMacAppPath() {
93
    final var path = home( "Library", "Application Support" );
94
95
    return ensureExists( path ) ? path : UNDEFINED;
96
  }
97
98
  private static Path getUnixAppPath() {
99
    // Fallback in case the XDG data directory is undefined.
100
    var path = home( ".local", "share" );
101
102
    if( isValid( ENV_XDG_DATA_HOME ) ) {
103
      final var xdgPath = Path.of( ENV_XDG_DATA_HOME );
104
105
      path = ensureExists( xdgPath ) ? xdgPath : path;
106
    }
107
108
    return path;
109
  }
110
111
  /**
112
   * Returns a hidden directory relative to the user's home directory.
113
   *
114
   * @param appName The application name.
115
   * @return A suitable directory for storing application files.
116
   */
117
  private static Path getDefaultAppPath( final String appName ) {
118
    return home( '.' + appName );
119
  }
120
121
  private static Path home( final String... paths ) {
122
    return Path.of( PROP_USER_HOME, paths );
123
  }
124
125
  /**
126
   * Verifies whether the path exists or was created.
127
   *
128
   * @param path The directory to verify.
129
   * @return {@code true} if the path already exists or was created,
130
   * {@code false} if the directory doesn't exist and couldn't be created.
131
   */
132
  private static boolean ensureExists( final Path path ) {
133
    final var file = path.toFile();
134
    return file.exists() || file.mkdirs();
135
  }
136
137
  /**
138
   * Answers whether the given string contains content.
139
   *
140
   * @param s The string to check, may be {@code null}.
141
   * @return {@code true} if the string is neither {@code null} nor blank.
142
   */
143
  private static boolean isValid( final String s ) {
144
    return !(s == null || s.isBlank());
145
  }
146
147
  private static boolean isWindows() {
148
    return IS_OS_WINDOWS;
149
  }
150
151
  private static boolean isMacOs() {
152
    return IS_OS_MAC;
153
  }
154
155
  private static boolean isUnix() {
156
    return IS_OS_UNIX;
157
  }
158
}
1159
A src/main/java/com/keenwrite/io/Zip.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import java.io.IOException;
5
import java.io.UncheckedIOException;
6
import java.nio.charset.StandardCharsets;
7
import java.nio.file.Files;
8
import java.nio.file.Path;
9
import java.util.concurrent.atomic.AtomicReference;
10
import java.util.function.BiConsumer;
11
import java.util.zip.ZipEntry;
12
import java.util.zip.ZipFile;
13
14
import static java.nio.file.Files.createDirectories;
15
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
16
17
/**
18
 * Responsible for managing zipped archive files. Does not handle archives
19
 * within archives.
20
 */
21
public final class Zip {
22
  /**
23
   * Extracts the contents of the zip archive into its current directory. The
24
   * contents of the archive must be {@link StandardCharsets#UTF_8}. For
25
   * example, if the {@link Path} is <code>/tmp/filename.zip</code>, then
26
   * the contents of the file will be extracted into <code>/tmp</code>.
27
   *
28
   * @param zipPath The {@link Path} to the zip file to extract.
29
   * @throws IOException Could not extract the zip file, zip entries, or find
30
   *                     the parent directory that contains the path to the
31
   *                     zip archive.
32
   */
33
  public static void extract( final Path zipPath ) throws IOException {
34
    final var path = zipPath.getParent().normalize();
35
36
    iterate( zipPath, ( zipFile, zipEntry ) -> {
37
      // Determine the directory name where the zip archive resides. Files will
38
      // be extracted relative to that directory.
39
      final var zipEntryPath = path.resolve( zipEntry.getName() );
40
41
      // Guard against zip slip.
42
      if( zipEntryPath.normalize().startsWith( path ) ) {
43
        try {
44
          extract( zipFile, zipEntry, zipEntryPath );
45
        } catch( final IOException ex ) {
46
          throw new UncheckedIOException( ex );
47
        }
48
      }
49
    } );
50
  }
51
52
  /**
53
   * Returns the first root-level directory found in the zip archive. Only call
54
   * this function if you know there is exactly one top-level directory in the
55
   * zip archive. If there are multiple top-level directories, one of the
56
   * directories will be returned, albeit indeterminately. No files are
57
   * extracted when calling this function.
58
   *
59
   * @param zipPath The path to the zip archive to process.
60
   * @return The fully qualified root-level directory resolved relatively to
61
   * the zip archive itself.
62
   * @throws IOException Could not process the zip archive.
63
   */
64
  public static Path root( final Path zipPath ) throws IOException {
65
    // Directory that contains the zip archive file.
66
    final var zipParent = zipPath.getParent();
67
68
    if( zipParent == null ) {
69
      throw new IOException( zipPath + " has no parent" );
70
    }
71
72
    final var result = new AtomicReference<>( zipParent );
73
74
    iterate( zipPath, ( zipFile, zipEntry ) -> {
75
      final var zipEntryPath = Path.of( zipEntry.getName() );
76
77
      // The first entry without a parent is considered the root-level entry.
78
      // Return the relative directory path to that entry.
79
      if( zipEntryPath.getParent() == null ) {
80
        result.set( zipParent.resolve( zipEntryPath ) );
81
      }
82
    } );
83
84
    // The zip file doesn't have a sane folder structure, so return the
85
    // directory where the zip file was found.
86
    return result.get();
87
  }
88
89
  /**
90
   * Processes each entry in the zip archive.
91
   *
92
   * @param zipPath  The path to the zip file being processed.
93
   * @param consumer The {@link BiConsumer} that receives each entry in the
94
   *                 zip archive.
95
   * @throws IOException Could not extract zip file entries.
96
   */
97
  private static void iterate(
98
    final Path zipPath,
99
    final BiConsumer<ZipFile, ZipEntry> consumer )
100
    throws IOException {
101
    assert zipPath.toFile().isFile();
102
103
    try( final var zipFile = new ZipFile( zipPath.toFile() ) ) {
104
      final var entries = zipFile.entries();
105
106
      while( entries.hasMoreElements() ) {
107
        consumer.accept( zipFile, entries.nextElement() );
108
      }
109
    }
110
  }
111
112
  /**
113
   * Extracts a single entry of a zip file to a given directory. This will
114
   * create the necessary directory path if it doesn't exist. Empty
115
   * directories are not re-created.
116
   *
117
   * @param zipFile      The zip archive to extract.
118
   * @param zipEntry     An entry in the zip archive.
119
   * @param zipEntryPath The file location to write the zip entry.
120
   * @throws IOException Could not extract the zip file entry.
121
   */
122
  private static void extract(
123
    final ZipFile zipFile,
124
    final ZipEntry zipEntry,
125
    final Path zipEntryPath ) throws IOException {
126
    // Only extract files, skip empty directories.
127
    if( !zipEntry.isDirectory() ) {
128
      createDirectories( zipEntryPath.getParent() );
129
130
      try( final var in = zipFile.getInputStream( zipEntry ) ) {
131
        Files.copy( in, zipEntryPath, REPLACE_EXISTING );
132
      }
133
    }
134
  }
135
}
1136
A src/main/java/com/keenwrite/io/downloads/DownloadManager.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io.downloads;
3
4
import com.keenwrite.io.MediaType;
5
import com.keenwrite.io.MediaTypeSniffer;
6
7
import java.io.*;
8
import java.net.HttpURLConnection;
9
import java.net.URI;
10
import java.net.URL;
11
import java.time.Duration;
12
import java.util.zip.GZIPInputStream;
13
14
import static java.lang.Math.toIntExact;
15
import static java.lang.String.format;
16
import static java.lang.System.*;
17
import static java.net.HttpURLConnection.HTTP_OK;
18
import static java.net.HttpURLConnection.setFollowRedirects;
19
20
/**
21
 * Responsible for downloading files and publishing status updates. This will
22
 * download a resource provided by an instance of {@link URL} into a given
23
 * {@link OutputStream}.
24
 */
25
public final class DownloadManager {
26
  static {
27
    setProperty( "http.keepAlive", "false" );
28
    setFollowRedirects( true );
29
  }
30
31
  /**
32
   * Number of bytes to read at a time.
33
   */
34
  private static final int BUFFER_SIZE = 16384;
35
36
  /**
37
   * HTTP request timeout.
38
   */
39
  private static final Duration TIMEOUT = Duration.ofSeconds( 30 );
40
41
  @FunctionalInterface
42
  public interface ProgressListener {
43
    /**
44
     * Called when a chunk of data has been read. This is called synchronously
45
     * when downloading the data; do not execute long-running tasks in this
46
     * method (a few milliseconds is fine).
47
     *
48
     * @param percentage A value between 0 and 100, inclusive, represents the
49
     *                   percentage of bytes downloaded relative to the total.
50
     *                   A value of -1 means the total number of bytes to
51
     *                   download is unknown.
52
     * @param bytes      When {@code percentage} is greater than or equal to
53
     *                   zero, this is the total number of bytes. When {@code
54
     *                   percentage} equals -1, this is the number of bytes
55
     *                   read so far.
56
     */
57
    void update( int percentage, long bytes );
58
  }
59
60
  /**
61
   * Callers may check the value of isSuccessful
62
   */
63
  public static final class DownloadToken implements Closeable {
64
    private final HttpURLConnection mConn;
65
    private final BufferedInputStream mInput;
66
    private final MediaType mMediaType;
67
    private final long mBytesTotal;
68
69
    private DownloadToken(
70
      final HttpURLConnection conn,
71
      final BufferedInputStream input,
72
      final MediaType mediaType
73
    ) {
74
      assert conn != null;
75
      assert input != null;
76
      assert mediaType != null;
77
78
      mConn = conn;
79
      mInput = input;
80
      mMediaType = mediaType;
81
      mBytesTotal = conn.getContentLength();
82
    }
83
84
    /**
85
     * Provides the ability to download remote files asynchronously while
86
     * being updated regarding the download progress. The given
87
     * {@link OutputStream} will be closed after downloading is complete.
88
     *
89
     * @param output   Where to write the file contents.
90
     * @param listener Receives download progress status updates.
91
     * @return A {@link Runnable} task that can be executed in the background
92
     * to download the resource for this {@link DownloadToken}.
93
     */
94
    public Runnable download(
95
      final OutputStream output,
96
      final ProgressListener listener ) {
97
      return () -> {
98
        final var buffer = new byte[ BUFFER_SIZE ];
99
        final var stream = getInputStream();
100
        final var bytesTotal = mBytesTotal;
101
102
        long bytesTally = 0;
103
        int bytesRead;
104
105
        try( output ) {
106
          while( (bytesRead = stream.read( buffer )) != -1 ) {
107
            if( Thread.currentThread().isInterrupted() ) {
108
              throw new InterruptedException();
109
            }
110
111
            bytesTally += bytesRead;
112
113
            if( bytesTotal > 0 ) {
114
              listener.update(
115
                toIntExact( bytesTally * 100 / bytesTotal ),
116
                bytesTotal
117
              );
118
            }
119
            else {
120
              listener.update( -1, bytesRead );
121
            }
122
123
            output.write( buffer, 0, bytesRead );
124
          }
125
        } catch( final Exception ex ) {
126
          throw new RuntimeException( ex );
127
        } finally {
128
          close();
129
        }
130
      };
131
    }
132
133
    public void close() {
134
      try {
135
        getInputStream().close();
136
      } catch( final Exception ignored ) {
137
      } finally {
138
        mConn.disconnect();
139
      }
140
    }
141
142
    /**
143
     * Returns the input stream to the resource to download.
144
     *
145
     * @return The stream to read.
146
     */
147
    public BufferedInputStream getInputStream() {
148
      return mInput;
149
    }
150
151
    public MediaType getMediaType() {
152
      return mMediaType;
153
    }
154
155
    /**
156
     * Answers whether the type of content associated with the download stream
157
     * is a scalable vector graphic.
158
     *
159
     * @return {@code true} if the given {@link MediaType} has SVG contents.
160
     */
161
    public boolean isSvg() {
162
      return getMediaType().isSvg();
163
    }
164
  }
165
166
  /**
167
   * Opens the input stream for the resource to download.
168
   *
169
   * @param url The {@link URL} resource to download.
170
   * @return A token that can be used for downloading the content with
171
   * periodic updates or retrieving the stream for downloading the content.
172
   * @throws IOException The stream could not be opened.
173
   */
174
  public static DownloadToken open( final String url ) throws IOException {
175
    // Pass an undefined media type so that any type of file can be retrieved.
176
    return open( new URL( url ) );
177
  }
178
179
  public static DownloadToken open( final URI uri )
180
    throws IOException {
181
    return open( uri.toURL() );
182
  }
183
184
  /**
185
   * Opens the input stream for the resource to download and verifies that
186
   * the given {@link MediaType} matches the requested type. Callers are
187
   * responsible for closing the {@link DownloadManager} to close the
188
   * underlying stream and the HTTP connection. Connections must be closed by
189
   * callers if {@link DownloadToken#download(OutputStream, ProgressListener)}
190
   * isn't called (i.e., {@link DownloadToken#getMediaType()} is called
191
   * after the transport layer's Content-Type is requested but not contents
192
   * are downloaded).
193
   *
194
   * @param url The {@link URL} resource to download.
195
   * @return A token that can be used for downloading the content with
196
   * periodic updates or retrieving the stream for downloading the content.
197
   * @throws IOException The resource could not be downloaded.
198
   */
199
  public static DownloadToken open( final URL url ) throws IOException {
200
    final var conn = connect( url );
201
202
    MediaType contentType;
203
204
    try {
205
      contentType = MediaType.valueFrom( conn.getContentType() );
206
    } catch( final Exception ex ) {
207
      // If the media type couldn't be detected, try using the stream.
208
      contentType = MediaType.UNDEFINED;
209
    }
210
211
    final var input = open( conn );
212
213
    // Peek at the magic header bytes to determine the media type.
214
    final var magicType = MediaTypeSniffer.getMediaType( input );
215
216
    // If the transport protocol's Content-Type doesn't align with the
217
    // media type for the magic header, defer to the transport protocol.
218
    final MediaType mediaType =
219
      !contentType.equals( magicType ) && !magicType.isUndefined()
220
        ? contentType
221
        : magicType;
222
223
    return new DownloadToken( conn, input, mediaType );
224
  }
225
226
  /**
227
   * Establishes a connection to the remote {@link URL} resource.
228
   *
229
   * @param url The {@link URL} representing a resource to download.
230
   * @return The connection manager for the {@link URL}.
231
   * @throws IOException         Could not establish a connection.
232
   * @throws ArithmeticException Could not compute a timeout value (this
233
   *                             should never happen because the timeout is
234
   *                             less than a minute).
235
   * @see #TIMEOUT
236
   */
237
  private static HttpURLConnection connect( final URL url )
238
    throws IOException, ArithmeticException {
239
    // Both HTTP and HTTPS are covered by this condition.
240
    if( url.openConnection() instanceof HttpURLConnection conn ) {
241
      conn.setUseCaches( false );
242
      conn.setInstanceFollowRedirects( true );
243
      conn.setRequestProperty( "Accept-Encoding", "gzip" );
244
      conn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) );
245
      conn.setRequestMethod( "GET" );
246
      conn.setConnectTimeout( toIntExact( TIMEOUT.toMillis() ) );
247
      conn.setRequestProperty( "connection", "close" );
248
      conn.connect();
249
250
      final var code = conn.getResponseCode();
251
252
      if( code != HTTP_OK ) {
253
        final var message = format(
254
          "%s [HTTP %d: %s]",
255
          url.getFile(),
256
          code,
257
          conn.getResponseMessage()
258
        );
259
260
        throw new IOException( message );
261
      }
262
263
      return conn;
264
    }
265
266
    throw new UnsupportedOperationException( url.toString() );
267
  }
268
269
  /**
270
   * Returns a stream in an open state. Callers are responsible for closing.
271
   *
272
   * @param conn The connection to open, which could be compressed.
273
   * @return The open stream.
274
   * @throws IOException Could not open the stream.
275
   */
276
  private static BufferedInputStream open( final HttpURLConnection conn )
277
    throws IOException {
278
    return open( conn.getContentEncoding(), conn.getInputStream() );
279
  }
280
281
  /**
282
   * Returns a stream in an open state. Callers are responsible for closing.
283
   * The input stream may be compressed.
284
   *
285
   * @param encoding The content encoding for the stream.
286
   * @param is       The stream to wrap with a suitable decoder.
287
   * @return The open stream, with any gzip content-encoding decoded.
288
   * @throws IOException Could not open the stream.
289
   */
290
  private static BufferedInputStream open(
291
    final String encoding, final InputStream is ) throws IOException {
292
    return new BufferedInputStream(
293
      "gzip".equalsIgnoreCase( encoding )
294
        ? new GZIPInputStream( is )
295
        : is
296
    );
297
  }
298
}
1299
A src/main/java/com/keenwrite/io/downloads/events/DownloadConnectionFailedEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io.downloads.events;
3
4
import java.net.URL;
5
6
/**
7
 * Collates information about an HTTP connection that could not be established.
8
 */
9
public class DownloadConnectionFailedEvent extends DownloadEvent {
10
11
  private final Exception mEx;
12
13
  /**
14
   * Constructs a new event that tracks the status of downloading a file.
15
   *
16
   * @param url The {@link URL} that has triggered a download event.
17
   * @param ex  The reason the connection failed.
18
   */
19
  public DownloadConnectionFailedEvent(
20
    final URL url, final Exception ex ) {
21
    super( url );
22
    mEx = ex;
23
  }
24
25
  public static void fire( final URL url, final Exception ex ) {
26
    new DownloadConnectionFailedEvent( url, ex ).publish();
27
  }
28
29
  /**
30
   * Returns the {@link Exception} that caused this event to be published.
31
   *
32
   * @return The {@link Exception} encountered when establishing a connection.
33
   */
34
  public Exception getException() {
35
    return mEx;
36
  }
37
}
138
A src/main/java/com/keenwrite/io/downloads/events/DownloadEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io.downloads.events;
3
4
import com.keenwrite.events.AppEvent;
5
6
import java.net.URL;
7
import java.time.Instant;
8
9
/**
10
 * The parent class to all download-related status events.
11
 */
12
public class DownloadEvent implements AppEvent {
13
14
  private final Instant mInstant = Instant.now();
15
  private final URL mUrl;
16
17
  /**
18
   * Constructs a new event that tracks the status of downloading a file.
19
   *
20
   * @param url The {@link URL} that has triggered a download event.
21
   */
22
  public DownloadEvent( final URL url ) {
23
    mUrl = url;
24
  }
25
26
  /**
27
   * Returns the download link as an instance of {@link URL}.
28
   *
29
   * @return The {@link URL} being downloaded.
30
   */
31
  public URL getUrl() {
32
    return mUrl;
33
  }
34
35
  /**
36
   * Returns the moment in time that this event was published.
37
   *
38
   * @return The published date and time.
39
   */
40
  public Instant when() {
41
    return mInstant;
42
  }
43
}
144
A src/main/java/com/keenwrite/io/downloads/events/DownloadFailedEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io.downloads.events;
3
4
import java.net.URL;
5
6
public class DownloadFailedEvent extends DownloadEvent {
7
8
  private final int mResponseCode;
9
10
  /**
11
   * Constructs a new event that indicates downloading a file was not
12
   * successful.
13
   *
14
   * @param url          The {@link URL} that has triggered a download event.
15
   * @param responseCode The HTTP response code associated with the failure.
16
   */
17
  public DownloadFailedEvent( final URL url, final int responseCode ) {
18
    super( url );
19
20
    mResponseCode = responseCode;
21
  }
22
23
  public static void fire( final URL url, final int responseCode ) {
24
    new DownloadFailedEvent( url, responseCode ).publish();
25
  }
26
27
  /**
28
   * Returns the HTTP response code for a failed download.
29
   *
30
   * @return An HTTP response code.
31
   */
32
  public int getResponseCode() {
33
    return mResponseCode;
34
  }
35
}
136
A src/main/java/com/keenwrite/io/downloads/events/DownloadStartedEvent.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io.downloads.events;
3
4
import java.net.URL;
5
6
/**
7
 * Collates information about a document that has started downloading.
8
 */
9
public class DownloadStartedEvent extends DownloadEvent {
10
11
  public DownloadStartedEvent( final URL url ) {
12
    super( url );
13
  }
14
15
  public static void fire( final URL url ) {
16
    new DownloadStartedEvent( url ).publish();
17
  }
18
}
119
M src/main/java/com/keenwrite/preferences/AppKeys.java
2929
  public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" );
3030
31
3132
  public static final Key KEY_IMAGES = key( KEY_ROOT, "images" );
33
  public static final Key KEY_CACHES_DIR = key( KEY_IMAGES, "cache" );
3234
  public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" );
3335
  public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" );
...
8385
  public static final Key KEY_TYPESET = key( KEY_ROOT, "typeset" );
8486
  public static final Key KEY_TYPESET_CONTEXT = key( KEY_TYPESET, "context" );
87
  public static final Key KEY_TYPESET_CONTEXT_FONTS = key( KEY_TYPESET_CONTEXT, "fonts" );
88
  public static final Key KEY_TYPESET_CONTEXT_FONTS_DIR = key( KEY_TYPESET_CONTEXT_FONTS, "dir" );
8589
  public static final Key KEY_TYPESET_CONTEXT_THEMES = key( KEY_TYPESET_CONTEXT, "themes" );
8690
  public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" );
M src/main/java/com/keenwrite/preferences/PreferencesController.java
2222
import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED;
2323
import static com.keenwrite.Messages.get;
24
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
25
import static com.keenwrite.preferences.AppKeys.*;
26
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
27
import static com.keenwrite.preferences.SkinProperty.skinListProperty;
28
import static com.keenwrite.preferences.TableField.ofListType;
29
import static javafx.scene.control.ButtonType.CANCEL;
30
import static javafx.scene.control.ButtonType.OK;
31
32
/**
33
 * Provides the ability for users to configure their preferences. This links
34
 * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
35
 */
36
@SuppressWarnings( "SameParameterValue" )
37
public final class PreferencesController {
38
39
  private final Workspace mWorkspace;
40
  private final PreferencesFx mPreferencesFx;
41
42
  public PreferencesController( final Workspace workspace ) {
43
    mWorkspace = workspace;
44
45
    // Order matters: set the workspace before creating the dialog.
46
    mPreferencesFx = createPreferencesFx();
47
48
    initKeyEventHandler( mPreferencesFx );
49
    initSaveEventHandler( mPreferencesFx );
50
  }
51
52
  /**
53
   * Display the user preferences settings dialog (non-modal).
54
   */
55
  public void show() {
56
    mPreferencesFx.show( false );
57
  }
58
59
  private StringField createFontNameField(
60
    final StringProperty fontName, final DoubleProperty fontSize ) {
61
    final var control = new SimpleFontControl( "Change" );
62
63
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
64
      if( n != null ) {
65
        fontSize.set( n.doubleValue() );
66
      }
67
    } );
68
69
    return ofStringType( fontName ).render( control );
70
  }
71
72
  /**
73
   * Convenience method to create a helper class for the user interface. This
74
   * establishes a key-value pair for the view.
75
   *
76
   * @param persist A reference to the values that will be persisted.
77
   * @param <K>     The type of key, usually a string.
78
   * @param <V>     The type of value, usually a string.
79
   * @return UI data model container that may update the persistent state.
80
   */
81
  private <K, V> TableField<Entry<K, V>> createTableField(
82
    final ListProperty<Entry<K, V>> persist ) {
83
    return ofListType( persist ).render( new SimpleTableControl<>() );
84
  }
85
86
  /**
87
   * Creates the preferences dialog based using
88
   * {@link SkeletonStorageHandler} and
89
   * numerous {@link Category} objects.
90
   *
91
   * @return A component for editing preferences.
92
   * @throws RuntimeException Could not construct the {@link PreferencesFx}
93
   *                          object (e.g., illegal access permissions,
94
   *                          unmapped XML resource).
95
   */
96
  private PreferencesFx createPreferencesFx() {
97
    return PreferencesFx.of( createStorageHandler(), createCategories() )
98
                        .instantPersistent( false )
99
                        .dialogIcon( ICON_DIALOG );
100
  }
101
102
  /**
103
   * Override the {@link PreferencesFx} storage handler to perform no actions.
104
   * Persistence is accomplished using the {@link XmlStore}.
105
   *
106
   * @return A no-op {@link StorageHandler} implementation.
107
   */
108
  private StorageHandler createStorageHandler() {
109
    return new SkeletonStorageHandler();
110
  }
111
112
  private Category[] createCategories() {
113
    return new Category[]{
114
      Category.of(
115
        get( KEY_DOC ),
116
        Group.of(
117
          get( KEY_DOC_META ),
118
          Setting.of( label( KEY_DOC_META ) ),
119
          Setting.of( title( KEY_DOC_META ),
120
                      createTableField( listEntryProperty( KEY_DOC_META ) ),
121
                      listEntryProperty( KEY_DOC_META ) )
122
        )
123
      ),
124
      Category.of(
125
        get( KEY_TYPESET ),
126
        Group.of(
127
          get( KEY_TYPESET_CONTEXT ),
128
          Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
129
          Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
130
                      fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
131
          Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
132
          Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
133
                      booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
134
        ),
135
        Group.of(
136
          get( KEY_TYPESET_TYPOGRAPHY ),
137
          Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ),
138
          Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
139
                      booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
140
        )
141
      ),
142
      Category.of(
143
        get( KEY_EDITOR ),
144
        Group.of(
145
          get( KEY_EDITOR_AUTOSAVE ),
146
          Setting.of( label( KEY_EDITOR_AUTOSAVE ) ),
147
          Setting.of( title( KEY_EDITOR_AUTOSAVE ),
148
                      integerProperty( KEY_EDITOR_AUTOSAVE ) )
149
        )
150
      ),
151
      Category.of(
152
        get( KEY_R ),
153
        Group.of(
154
          get( KEY_R_DIR ),
155
          Setting.of( label( KEY_R_DIR ) ),
156
          Setting.of( title( KEY_R_DIR ),
157
                      fileProperty( KEY_R_DIR ), true )
158
        ),
159
        Group.of(
160
          get( KEY_R_SCRIPT ),
161
          Setting.of( label( KEY_R_SCRIPT ) ),
162
          createMultilineSetting( "Script", KEY_R_SCRIPT )
163
        ),
164
        Group.of(
165
          get( KEY_R_DELIM_BEGAN ),
166
          Setting.of( label( KEY_R_DELIM_BEGAN ) ),
167
          Setting.of( title( KEY_R_DELIM_BEGAN ),
168
                      stringProperty( KEY_R_DELIM_BEGAN ) )
169
        ),
170
        Group.of(
171
          get( KEY_R_DELIM_ENDED ),
172
          Setting.of( label( KEY_R_DELIM_ENDED ) ),
173
          Setting.of( title( KEY_R_DELIM_ENDED ),
174
                      stringProperty( KEY_R_DELIM_ENDED ) )
175
        )
176
      ),
177
      Category.of(
178
        get( KEY_IMAGES ),
179
        Group.of(
180
          get( KEY_IMAGES_DIR ),
181
          Setting.of( label( KEY_IMAGES_DIR ) ),
182
          Setting.of( title( KEY_IMAGES_DIR ),
183
                      fileProperty( KEY_IMAGES_DIR ), true )
184
        ),
185
        Group.of(
186
          get( KEY_IMAGES_ORDER ),
187
          Setting.of( label( KEY_IMAGES_ORDER ) ),
188
          Setting.of( title( KEY_IMAGES_ORDER ),
189
                      stringProperty( KEY_IMAGES_ORDER ) )
190
        ),
191
        Group.of(
192
          get( KEY_IMAGES_RESIZE ),
193
          Setting.of( label( KEY_IMAGES_RESIZE ) ),
194
          Setting.of( title( KEY_IMAGES_RESIZE ),
195
                      booleanProperty( KEY_IMAGES_RESIZE ) )
196
        ),
197
        Group.of(
198
          get( KEY_IMAGES_SERVER ),
199
          Setting.of( label( KEY_IMAGES_SERVER ) ),
200
          Setting.of( title( KEY_IMAGES_SERVER ),
201
                      stringProperty( KEY_IMAGES_SERVER ) )
202
        )
203
      ),
204
      Category.of(
205
        get( KEY_DEF ),
206
        Group.of(
207
          get( KEY_DEF_PATH ),
208
          Setting.of( label( KEY_DEF_PATH ) ),
209
          Setting.of( title( KEY_DEF_PATH ),
210
                      fileProperty( KEY_DEF_PATH ), false )
211
        ),
212
        Group.of(
213
          get( KEY_DEF_DELIM_BEGAN ),
214
          Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
215
          Setting.of( title( KEY_DEF_DELIM_BEGAN ),
216
                      stringProperty( KEY_DEF_DELIM_BEGAN ) )
217
        ),
218
        Group.of(
219
          get( KEY_DEF_DELIM_ENDED ),
220
          Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
221
          Setting.of( title( KEY_DEF_DELIM_ENDED ),
222
                      stringProperty( KEY_DEF_DELIM_ENDED ) )
223
        )
224
      ),
225
      Category.of(
226
        get( KEY_UI_FONT ),
227
        Group.of(
228
          get( KEY_UI_FONT_EDITOR ),
229
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
230
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
231
                      createFontNameField(
232
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
233
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
234
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
235
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
236
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
237
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
238
        ),
239
        Group.of(
240
          get( KEY_UI_FONT_PREVIEW ),
241
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
242
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
243
                      createFontNameField(
244
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
245
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
246
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
247
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
248
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
249
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
250
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
251
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
252
                      createFontNameField(
253
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
254
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
255
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
256
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
257
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
258
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
259
        )
260
      ),
261
      Category.of(
262
        get( KEY_UI_SKIN ),
263
        Group.of(
264
          get( KEY_UI_SKIN_SELECTION ),
265
          Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
266
          Setting.of( title( KEY_UI_SKIN_SELECTION ),
267
                      skinListProperty(),
268
                      skinProperty( KEY_UI_SKIN_SELECTION ) )
269
        ),
270
        Group.of(
271
          get( KEY_UI_SKIN_CUSTOM ),
272
          Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
273
          Setting.of( title( KEY_UI_SKIN_CUSTOM ),
274
                      fileProperty( KEY_UI_SKIN_CUSTOM ), false )
275
        )
276
      ),
277
      Category.of(
278
        get( KEY_UI_PREVIEW ),
279
        Group.of(
280
          get( KEY_UI_PREVIEW_STYLESHEET ),
281
          Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ),
282
          Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ),
283
                      fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false )
284
        )
285
      ),
286
      Category.of(
287
        get( KEY_LANGUAGE ),
288
        Group.of(
289
          get( KEY_LANGUAGE_LOCALE ),
290
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
291
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
292
                      localeListProperty(),
293
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
294
        )
295
      )
296
    };
297
  }
298
299
  @SuppressWarnings( "unchecked" )
300
  private Setting<StringField, StringProperty> createMultilineSetting(
301
    final String description, final Key property ) {
302
    final Setting<StringField, StringProperty> setting =
303
      Setting.of( description, stringProperty( property ) );
304
    final var field = setting.getElement();
305
    field.multiline( true );
306
307
    return setting;
308
  }
309
310
  /**
311
   * Map ENTER and ESCAPE keys to OK and CANCEL buttons, respectively.
312
   */
313
  private void initKeyEventHandler( final PreferencesFx preferences ) {
314
    final var view = preferences.getView();
315
    final var nodes = view.getChildrenUnmodifiable();
316
    final var master = (MasterDetailPane) nodes.get( 0 );
317
    final var detail = (NavigationView) master.getDetailNode();
318
    final var pane = (DialogPane) view.getParent();
319
320
    detail.setOnKeyReleased( key -> {
321
      switch( key.getCode() ) {
322
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
323
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
324
      }
325
    } );
326
  }
327
328
  /**
329
   * Called when the user clicks the APPLY or OK buttons in the dialog.
330
   *
331
   * @param preferences Preferences widget.
332
   */
333
  private void initSaveEventHandler( final PreferencesFx preferences ) {
334
    preferences.addEventHandler(
335
      EVENT_PREFERENCES_SAVED, event -> mWorkspace.save()
336
    );
337
  }
338
339
  /**
340
   * Creates a label for the given key after interpolating its value.
341
   *
342
   * @param key The key to find in the resource bundle.
343
   * @return The value of the key as a label.
344
   */
345
  private Node label( final Key key ) {
346
    return label( key, (String[]) null );
347
  }
348
349
  private Node label( final Key key, final String... values ) {
350
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
351
  }
352
353
  private String title( final Key key ) {
354
    return get( key.toString() + ".title" );
24
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
25
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
26
import static com.keenwrite.preferences.AppKeys.*;
27
import static com.keenwrite.preferences.LocaleProperty.localeListProperty;
28
import static com.keenwrite.preferences.SkinProperty.skinListProperty;
29
import static com.keenwrite.preferences.TableField.ofListType;
30
import static javafx.scene.control.ButtonType.CANCEL;
31
import static javafx.scene.control.ButtonType.OK;
32
33
/**
34
 * Provides the ability for users to configure their preferences. This links
35
 * the {@link Workspace} model with the {@link PreferencesFx} view, in MVC.
36
 */
37
@SuppressWarnings( "SameParameterValue" )
38
public final class PreferencesController {
39
40
  private final Workspace mWorkspace;
41
  private final PreferencesFx mPreferencesFx;
42
43
  public PreferencesController( final Workspace workspace ) {
44
    mWorkspace = workspace;
45
46
    // Order matters: set the workspace before creating the dialog.
47
    mPreferencesFx = createPreferencesFx();
48
49
    initKeyEventHandler( mPreferencesFx );
50
    initSaveEventHandler( mPreferencesFx );
51
  }
52
53
  /**
54
   * Display the user preferences settings dialog (non-modal).
55
   */
56
  public void show() {
57
    mPreferencesFx.show( false );
58
  }
59
60
  private StringField createFontNameField(
61
    final StringProperty fontName, final DoubleProperty fontSize ) {
62
    final var control = new SimpleFontControl( "Change" );
63
64
    control.fontSizeProperty().addListener( ( c, o, n ) -> {
65
      if( n != null ) {
66
        fontSize.set( n.doubleValue() );
67
      }
68
    } );
69
70
    return ofStringType( fontName ).render( control );
71
  }
72
73
  /**
74
   * Convenience method to create a helper class for the user interface. This
75
   * establishes a key-value pair for the view.
76
   *
77
   * @param persist A reference to the values that will be persisted.
78
   * @param <K>     The type of key, usually a string.
79
   * @param <V>     The type of value, usually a string.
80
   * @return UI data model container that may update the persistent state.
81
   */
82
  private <K, V> TableField<Entry<K, V>> createTableField(
83
    final ListProperty<Entry<K, V>> persist ) {
84
    return ofListType( persist ).render( new SimpleTableControl<>() );
85
  }
86
87
  /**
88
   * Creates the preferences dialog based using
89
   * {@link SkeletonStorageHandler} and
90
   * numerous {@link Category} objects.
91
   *
92
   * @return A component for editing preferences.
93
   * @throws RuntimeException Could not construct the {@link PreferencesFx}
94
   *                          object (e.g., illegal access permissions,
95
   *                          unmapped XML resource).
96
   */
97
  private PreferencesFx createPreferencesFx() {
98
    return PreferencesFx.of( createStorageHandler(), createCategories() )
99
                        .instantPersistent( false )
100
                        .dialogIcon( ICON_DIALOG );
101
  }
102
103
  /**
104
   * Override the {@link PreferencesFx} storage handler to perform no actions.
105
   * Persistence is accomplished using the {@link XmlStore}.
106
   *
107
   * @return A no-op {@link StorageHandler} implementation.
108
   */
109
  private StorageHandler createStorageHandler() {
110
    return new SkeletonStorageHandler();
111
  }
112
113
  private Category[] createCategories() {
114
    return new Category[]{
115
      Category.of(
116
        get( KEY_DOC ),
117
        Group.of(
118
          get( KEY_DOC_META ),
119
          Setting.of( label( KEY_DOC_META ) ),
120
          Setting.of( title( KEY_DOC_META ),
121
                      createTableField( listEntryProperty( KEY_DOC_META ) ),
122
                      listEntryProperty( KEY_DOC_META ) )
123
        )
124
      ),
125
      Category.of(
126
        get( KEY_TYPESET ),
127
        Group.of(
128
          get( KEY_TYPESET_CONTEXT ),
129
          Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
130
          Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
131
                      directoryProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ),
132
                      true ),
133
          Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
134
          Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
135
                      booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
136
        ),
137
        Group.of(
138
          get( KEY_TYPESET_CONTEXT_FONTS ),
139
          Setting.of( label( KEY_TYPESET_CONTEXT_FONTS_DIR ) ),
140
          Setting.of( title( KEY_TYPESET_CONTEXT_FONTS_DIR ),
141
                      directoryProperty( KEY_TYPESET_CONTEXT_FONTS_DIR ),
142
                      true )
143
        ),
144
        Group.of(
145
          get( KEY_TYPESET_TYPOGRAPHY ),
146
          Setting.of( label( KEY_TYPESET_TYPOGRAPHY_QUOTES ) ),
147
          Setting.of( title( KEY_TYPESET_TYPOGRAPHY_QUOTES ),
148
                      booleanProperty( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
149
        )
150
      ),
151
      Category.of(
152
        get( KEY_EDITOR ),
153
        Group.of(
154
          get( KEY_EDITOR_AUTOSAVE ),
155
          Setting.of( label( KEY_EDITOR_AUTOSAVE ) ),
156
          Setting.of( title( KEY_EDITOR_AUTOSAVE ),
157
                      integerProperty( KEY_EDITOR_AUTOSAVE ) )
158
        )
159
      ),
160
      Category.of(
161
        get( KEY_R ),
162
        Group.of(
163
          get( KEY_R_DIR ),
164
          Setting.of( label( KEY_R_DIR ) ),
165
          Setting.of( title( KEY_R_DIR ),
166
                      directoryProperty( KEY_R_DIR ),
167
                      true )
168
        ),
169
        Group.of(
170
          get( KEY_R_SCRIPT ),
171
          Setting.of( label( KEY_R_SCRIPT ) ),
172
          createMultilineSetting( "Script", KEY_R_SCRIPT )
173
        ),
174
        Group.of(
175
          get( KEY_R_DELIM_BEGAN ),
176
          Setting.of( label( KEY_R_DELIM_BEGAN ) ),
177
          Setting.of( title( KEY_R_DELIM_BEGAN ),
178
                      stringProperty( KEY_R_DELIM_BEGAN ) )
179
        ),
180
        Group.of(
181
          get( KEY_R_DELIM_ENDED ),
182
          Setting.of( label( KEY_R_DELIM_ENDED ) ),
183
          Setting.of( title( KEY_R_DELIM_ENDED ),
184
                      stringProperty( KEY_R_DELIM_ENDED ) )
185
        )
186
      ),
187
      Category.of(
188
        get( KEY_IMAGES ),
189
        Group.of(
190
          get( KEY_IMAGES_DIR ),
191
          Setting.of( label( KEY_IMAGES_DIR ) ),
192
          Setting.of( title( KEY_IMAGES_DIR ),
193
                      directoryProperty( KEY_IMAGES_DIR ),
194
                      true ),
195
          Setting.of( label( KEY_CACHES_DIR ) ),
196
          Setting.of( title( KEY_CACHES_DIR ),
197
                      directoryProperty( KEY_CACHES_DIR ),
198
                      true )
199
        ),
200
        Group.of(
201
          get( KEY_IMAGES_ORDER ),
202
          Setting.of( label( KEY_IMAGES_ORDER ) ),
203
          Setting.of( title( KEY_IMAGES_ORDER ),
204
                      stringProperty( KEY_IMAGES_ORDER ) )
205
        ),
206
        Group.of(
207
          get( KEY_IMAGES_RESIZE ),
208
          Setting.of( label( KEY_IMAGES_RESIZE ) ),
209
          Setting.of( title( KEY_IMAGES_RESIZE ),
210
                      booleanProperty( KEY_IMAGES_RESIZE ) )
211
        ),
212
        Group.of(
213
          get( KEY_IMAGES_SERVER ),
214
          Setting.of( label( KEY_IMAGES_SERVER ) ),
215
          Setting.of( title( KEY_IMAGES_SERVER ),
216
                      stringProperty( KEY_IMAGES_SERVER ) )
217
        )
218
      ),
219
      Category.of(
220
        get( KEY_DEF ),
221
        Group.of(
222
          get( KEY_DEF_PATH ),
223
          Setting.of( label( KEY_DEF_PATH ) ),
224
          Setting.of( title( KEY_DEF_PATH ),
225
                      fileProperty( KEY_DEF_PATH ), false )
226
        ),
227
        Group.of(
228
          get( KEY_DEF_DELIM_BEGAN ),
229
          Setting.of( label( KEY_DEF_DELIM_BEGAN ) ),
230
          Setting.of( title( KEY_DEF_DELIM_BEGAN ),
231
                      stringProperty( KEY_DEF_DELIM_BEGAN ) )
232
        ),
233
        Group.of(
234
          get( KEY_DEF_DELIM_ENDED ),
235
          Setting.of( label( KEY_DEF_DELIM_ENDED ) ),
236
          Setting.of( title( KEY_DEF_DELIM_ENDED ),
237
                      stringProperty( KEY_DEF_DELIM_ENDED ) )
238
        )
239
      ),
240
      Category.of(
241
        get( KEY_UI_FONT ),
242
        Group.of(
243
          get( KEY_UI_FONT_EDITOR ),
244
          Setting.of( label( KEY_UI_FONT_EDITOR_NAME ) ),
245
          Setting.of( title( KEY_UI_FONT_EDITOR_NAME ),
246
                      createFontNameField(
247
                        stringProperty( KEY_UI_FONT_EDITOR_NAME ),
248
                        doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) ),
249
                      stringProperty( KEY_UI_FONT_EDITOR_NAME ) ),
250
          Setting.of( label( KEY_UI_FONT_EDITOR_SIZE ) ),
251
          Setting.of( title( KEY_UI_FONT_EDITOR_SIZE ),
252
                      doubleProperty( KEY_UI_FONT_EDITOR_SIZE ) )
253
        ),
254
        Group.of(
255
          get( KEY_UI_FONT_PREVIEW ),
256
          Setting.of( label( KEY_UI_FONT_PREVIEW_NAME ) ),
257
          Setting.of( title( KEY_UI_FONT_PREVIEW_NAME ),
258
                      createFontNameField(
259
                        stringProperty( KEY_UI_FONT_PREVIEW_NAME ),
260
                        doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
261
                      stringProperty( KEY_UI_FONT_PREVIEW_NAME ) ),
262
          Setting.of( label( KEY_UI_FONT_PREVIEW_SIZE ) ),
263
          Setting.of( title( KEY_UI_FONT_PREVIEW_SIZE ),
264
                      doubleProperty( KEY_UI_FONT_PREVIEW_SIZE ) ),
265
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
266
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_NAME ),
267
                      createFontNameField(
268
                        stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ),
269
                        doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
270
                      stringProperty( KEY_UI_FONT_PREVIEW_MONO_NAME ) ),
271
          Setting.of( label( KEY_UI_FONT_PREVIEW_MONO_SIZE ) ),
272
          Setting.of( title( KEY_UI_FONT_PREVIEW_MONO_SIZE ),
273
                      doubleProperty( KEY_UI_FONT_PREVIEW_MONO_SIZE ) )
274
        )
275
      ),
276
      Category.of(
277
        get( KEY_UI_SKIN ),
278
        Group.of(
279
          get( KEY_UI_SKIN_SELECTION ),
280
          Setting.of( label( KEY_UI_SKIN_SELECTION ) ),
281
          Setting.of( title( KEY_UI_SKIN_SELECTION ),
282
                      skinListProperty(),
283
                      skinProperty( KEY_UI_SKIN_SELECTION ) )
284
        ),
285
        Group.of(
286
          get( KEY_UI_SKIN_CUSTOM ),
287
          Setting.of( label( KEY_UI_SKIN_CUSTOM ) ),
288
          Setting.of( title( KEY_UI_SKIN_CUSTOM ),
289
                      fileProperty( KEY_UI_SKIN_CUSTOM ), false )
290
        )
291
      ),
292
      Category.of(
293
        get( KEY_UI_PREVIEW ),
294
        Group.of(
295
          get( KEY_UI_PREVIEW_STYLESHEET ),
296
          Setting.of( label( KEY_UI_PREVIEW_STYLESHEET ) ),
297
          Setting.of( title( KEY_UI_PREVIEW_STYLESHEET ),
298
                      fileProperty( KEY_UI_PREVIEW_STYLESHEET ), false )
299
        )
300
      ),
301
      Category.of(
302
        get( KEY_LANGUAGE ),
303
        Group.of(
304
          get( KEY_LANGUAGE_LOCALE ),
305
          Setting.of( label( KEY_LANGUAGE_LOCALE ) ),
306
          Setting.of( title( KEY_LANGUAGE_LOCALE ),
307
                      localeListProperty(),
308
                      localeProperty( KEY_LANGUAGE_LOCALE ) )
309
        )
310
      )
311
    };
312
  }
313
314
  @SuppressWarnings( "unchecked" )
315
  private Setting<StringField, StringProperty> createMultilineSetting(
316
    final String description, final Key property ) {
317
    final Setting<StringField, StringProperty> setting =
318
      Setting.of( description, stringProperty( property ) );
319
    final var field = setting.getElement();
320
    field.multiline( true );
321
322
    return setting;
323
  }
324
325
  /**
326
   * Map ENTER and ESCAPE keys to OK and CANCEL buttons, respectively.
327
   */
328
  private void initKeyEventHandler( final PreferencesFx preferences ) {
329
    final var view = preferences.getView();
330
    final var nodes = view.getChildrenUnmodifiable();
331
    final var master = (MasterDetailPane) nodes.get( 0 );
332
    final var detail = (NavigationView) master.getDetailNode();
333
    final var pane = (DialogPane) view.getParent();
334
335
    detail.setOnKeyReleased( key -> {
336
      switch( key.getCode() ) {
337
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
338
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
339
      }
340
    } );
341
  }
342
343
  /**
344
   * Called when the user clicks the APPLY or OK buttons in the dialog.
345
   *
346
   * @param preferences Preferences widget.
347
   */
348
  private void initSaveEventHandler( final PreferencesFx preferences ) {
349
    preferences.addEventHandler(
350
      EVENT_PREFERENCES_SAVED, event -> mWorkspace.save()
351
    );
352
  }
353
354
  /**
355
   * Creates a label for the given key after interpolating its value.
356
   *
357
   * @param key The key to find in the resource bundle.
358
   * @return The value of the key as a label.
359
   */
360
  private Node label( final Key key ) {
361
    return label( key, (String[]) null );
362
  }
363
364
  private Node label( final Key key, final String... values ) {
365
    return new Label( get( key.toString() + ".desc", (Object[]) values ) );
366
  }
367
368
  private String title( final Key key ) {
369
    return get( key.toString() + ".title" );
370
  }
371
372
  /**
373
   * Screens out non-existent directories to avoid throwing an exception caused
374
   * by
375
   * <a href="https://github.com/dlsc-software-consulting-gmbh/PreferencesFX/issues/441">
376
   * PreferencesFX issue #441
377
   * </a>.
378
   *
379
   * @param key Preference to pre-screen before creating a {@link FileProperty}.
380
   * @return The preferred value or the user's home directory if the directory
381
   * does not exist.
382
   */
383
  private ObjectProperty<File> directoryProperty( final Key key ) {
384
    final var property = mWorkspace.fileProperty( key );
385
    final var file = property.get();
386
387
    if( !file.exists() ) {
388
      property.set( USER_DIRECTORY );
389
    }
390
391
    return property;
355392
  }
356393
M src/main/java/com/keenwrite/preferences/Workspace.java
1414
import java.util.function.Function;
1515
16
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
17
import static com.keenwrite.Launcher.getVersion;
18
import static com.keenwrite.constants.Constants.*;
19
import static com.keenwrite.events.StatusEvent.clue;
20
import static com.keenwrite.preferences.AppKeys.*;
21
import static java.util.Map.entry;
22
import static javafx.application.Platform.runLater;
23
import static javafx.collections.FXCollections.observableArrayList;
24
import static javafx.collections.FXCollections.observableSet;
25
26
/**
27
 * Responsible for defining behaviours for separate projects. A workspace has
28
 * the ability to save and restore a session, including the window dimensions,
29
 * tab setup, files, and user preferences.
30
 * <p>
31
 * The configuration must support hierarchical (nested) configuration nodes
32
 * to persist the user interface state. Although possible with a flat
33
 * configuration file, it's not nearly as simple or elegant.
34
 * </p>
35
 * <p>
36
 * Neither JSON nor HOCON support schema validation and versioning, which makes
37
 * XML the more suitable configuration file format. Schema validation and
38
 * versioning provide future-proofing and ease of reading and upgrading previous
39
 * versions of the configuration file.
40
 * </p>
41
 * <p>
42
 * Persistent preferences may be set directly by the user or indirectly by
43
 * the act of using the application.
44
 * </p>
45
 * <p>
46
 * Note the following definitions:
47
 * </p>
48
 * <dl>
49
 *   <dt>File</dt>
50
 *   <dd>References a file name (no path), path, or directory.</dd>
51
 *   <dt>Path</dt>
52
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
53
 *   <dt>Dir</dt>
54
 *   <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
55
 * </dl>
56
 */
57
public final class Workspace {
58
59
  /**
60
   * Main configuration values, single text strings.
61
   */
62
  private final Map<Key, Property<?>> mValues = Map.ofEntries(
63
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
64
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
65
66
    entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
67
68
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
69
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
70
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
71
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
72
73
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
74
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
75
    entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
76
    entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
77
78
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
79
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
80
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
81
82
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
83
    entry( KEY_UI_RECENT_OFFSET, asIntegerProperty( DOCUMENT_OFFSET ) ),
84
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
85
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
86
    entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
87
88
    //@formatter:off
89
    entry(
90
      KEY_UI_FONT_EDITOR_NAME,
91
      asStringProperty( FONT_NAME_EDITOR_DEFAULT )
92
    ),
93
    entry(
94
     KEY_UI_FONT_EDITOR_SIZE,
95
     asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT )
96
    ),
97
    entry(
98
     KEY_UI_FONT_PREVIEW_NAME,
99
     asStringProperty( FONT_NAME_PREVIEW_DEFAULT )
100
    ),
101
    entry(
102
     KEY_UI_FONT_PREVIEW_SIZE,
103
     asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT )
104
    ),
105
    entry(
106
     KEY_UI_FONT_PREVIEW_MONO_NAME,
107
     asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT )
108
    ),
109
    entry(
110
     KEY_UI_FONT_PREVIEW_MONO_SIZE,
111
     asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT )
112
    ),
113
114
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
115
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
116
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
117
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
118
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
119
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
120
121
    entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
122
    entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
123
124
    entry(
125
      KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT )
126
    ),
127
128
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
129
130
    entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ),
131
    entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
132
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
133
    entry( KEY_TYPESET_CONTEXT_CHAPTERS, asStringProperty( "" ) ),
134
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
135
    //@formatter:on
136
  );
137
138
  /**
139
   * Sets of configuration values, all the same type (e.g., file names),
140
   * where the key name doesn't change per set.
141
   */
142
  private final Map<Key, SetProperty<?>> mSets = Map.ofEntries(
143
    entry(
144
      KEY_UI_RECENT_OPEN_PATH,
145
      createSetProperty( new HashSet<String>() )
146
    )
147
  );
148
149
  /**
150
   * Lists of configuration values, such as key-value pairs where both the
151
   * key name and the value must be preserved per list.
152
   */
153
  private final Map<Key, ListProperty<?>> mLists = Map.ofEntries(
154
    entry(
155
      KEY_DOC_META,
156
      createListProperty( new LinkedList<Entry<String, String>>() )
157
    )
158
  );
159
160
  /**
161
   * Helps instantiate {@link Property} instances for XML configuration items.
162
   */
163
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
164
    Map.of(
165
      LocaleProperty.class, LocaleProperty::parseLocale,
166
      SimpleBooleanProperty.class, Boolean::parseBoolean,
167
      SimpleIntegerProperty.class, Integer::parseInt,
168
      SimpleDoubleProperty.class, Double::parseDouble,
169
      SimpleFloatProperty.class, Float::parseFloat,
170
      SimpleStringProperty.class, String::new,
171
      SimpleObjectProperty.class, String::new,
172
      SkinProperty.class, String::new,
173
      FileProperty.class, File::new
174
    );
175
176
  /**
177
   * The asymmetry with respect to {@link #UNMARSHALL} is because most objects
178
   * can simply call {@link Object#toString()} to convert the value to a string.
179
   */
180
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
181
    Map.of(
182
      LocaleProperty.class, LocaleProperty::toLanguageTag
183
    );
184
185
  /**
186
   * Converts the given {@link Property} value to a string.
187
   *
188
   * @param property The {@link Property} to convert.
189
   * @return A string representation of the given property, or the empty
190
   * string if no conversion was possible.
191
   */
192
  private static String marshall( final Property<?> property ) {
193
    final var v = property.getValue();
194
195
    return v == null
196
      ? ""
197
      : MARSHALL
198
      .getOrDefault( property.getClass(), __ -> property.getValue() )
199
      .apply( v.toString() )
200
      .toString();
201
  }
202
203
  private static Object unmarshall(
204
    final Property<?> property, final Object configValue ) {
205
    final var v = configValue.toString();
206
207
    return UNMARSHALL
208
      .getOrDefault( property.getClass(), value -> property.getValue() )
209
      .apply( v );
210
  }
211
212
  /**
213
   * Creates an instance of {@link ObservableList} that is based on a
214
   * modifiable observable array list for the given items.
215
   *
216
   * @param items The items to wrap in an observable list.
217
   * @param <E>   The type of items to add to the list.
218
   * @return An observable property that can have its contents modified.
219
   */
220
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
221
    return new SimpleListProperty<>( observableArrayList( items ) );
222
  }
223
224
  private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
225
    return new SimpleSetProperty<>( observableSet( set ) );
226
  }
227
228
  private static <E> ListProperty<E> createListProperty( final List<E> list ) {
229
    return new SimpleListProperty<>( observableArrayList( list ) );
230
  }
231
232
  private static StringProperty asStringProperty( final String value ) {
233
    return new SimpleStringProperty( value );
234
  }
235
236
  private static BooleanProperty asBooleanProperty() {
237
    return new SimpleBooleanProperty();
238
  }
239
240
  /**
241
   * @param value Default value.
242
   */
243
  @SuppressWarnings( "SameParameterValue" )
244
  private static BooleanProperty asBooleanProperty( final boolean value ) {
245
    return new SimpleBooleanProperty( value );
246
  }
247
248
  /**
249
   * @param value Default value.
250
   */
251
  @SuppressWarnings( "SameParameterValue" )
252
  private static IntegerProperty asIntegerProperty( final int value ) {
253
    return new SimpleIntegerProperty( value );
254
  }
255
256
  /**
257
   * @param value Default value.
258
   */
259
  private static DoubleProperty asDoubleProperty( final double value ) {
260
    return new SimpleDoubleProperty( value );
261
  }
262
263
  /**
264
   * @param value Default value.
265
   */
266
  private static FileProperty asFileProperty( final File value ) {
267
    return new FileProperty( value );
268
  }
269
270
  /**
271
   * @param value Default value.
272
   */
273
  @SuppressWarnings( "SameParameterValue" )
274
  private static LocaleProperty asLocaleProperty( final Locale value ) {
275
    return new LocaleProperty( value );
276
  }
277
278
  /**
279
   * @param value Default value.
280
   */
281
  @SuppressWarnings( "SameParameterValue" )
282
  private static SkinProperty asSkinProperty( final String value ) {
283
    return new SkinProperty( value );
284
  }
285
286
  /**
287
   * Creates a new {@link Workspace} that will attempt to load the users'
288
   * preferences. If the configuration file cannot be loaded, the workspace
289
   * settings returns default values.
290
   */
291
  public Workspace() {
292
    load();
293
  }
294
295
  /**
296
   * Attempts to load the app's configuration file.
297
   */
298
  private void load() {
299
    final var store = createXmlStore();
300
    store.load( FILE_PREFERENCES );
301
302
    mValues.keySet().forEach( key -> {
303
      try {
304
        final var storeValue = store.getValue( key );
305
        final var property = valuesProperty( key );
306
307
        property.setValue( unmarshall( property, storeValue ) );
308
      } catch( final NoSuchElementException ignored ) {
309
        // When no configuration (item), use the default value.
310
      }
311
    } );
312
313
    mSets.keySet().forEach( key -> {
314
      final var set = store.getSet( key );
315
      final SetProperty<String> property = setsProperty( key );
316
317
      property.setValue( observableSet( set ) );
318
    } );
319
320
    mLists.keySet().forEach( key -> {
321
      final var map = store.getMap( key );
322
      final ListProperty<Entry<String, String>> property = listsProperty( key );
323
      final var list = map
324
        .entrySet()
325
        .stream()
326
        .toList();
327
328
      property.setValue( observableArrayList( list ) );
329
    } );
330
331
    WorkspaceLoadedEvent.fire( this );
332
  }
333
334
  /**
335
   * Saves the current workspace.
336
   */
337
  public void save() {
338
    final var store = createXmlStore();
339
340
    try {
341
      // Update the string values to include the application version.
342
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
343
344
      mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) );
345
      mSets.forEach( store::setSet );
346
      mLists.forEach( store::setMap );
347
348
      store.save( FILE_PREFERENCES );
349
    } catch( final Exception ex ) {
350
      clue( ex );
351
    }
352
  }
353
354
  /**
355
   * Returns a value that represents a setting in the application that the user
356
   * may configure, either directly or indirectly.
357
   *
358
   * @param key The reference to the users' preference stored in deference
359
   *            of app reëntrance.
360
   * @return An observable property to be persisted.
361
   */
362
  @SuppressWarnings( "unchecked" )
363
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
364
    assert key != null;
365
    return (U) mValues.get( key );
366
  }
367
368
  /**
369
   * Returns a set of values that represent a setting in the application that
370
   * the user may configure, either directly or indirectly. The property
371
   * returned is backed by a {@link Set}.
372
   *
373
   * @param key The {@link Key} associated with a preference value.
374
   * @return An observable property to be persisted.
375
   */
376
  @SuppressWarnings( "unchecked" )
377
  public <T> SetProperty<T> setsProperty( final Key key ) {
378
    assert key != null;
379
    return (SetProperty<T>) mSets.get( key );
380
  }
381
382
  /**
383
   * Returns a list of values that represent a setting in the application that
384
   * the user may configure, either directly or indirectly. The property
385
   * returned is backed by a mutable {@link List}.
386
   *
387
   * @param key The {@link Key} associated with a preference value.
388
   * @return An observable property to be persisted.
389
   */
390
  @SuppressWarnings( "unchecked" )
391
  public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) {
392
    assert key != null;
393
    return (ListProperty<Entry<K, V>>) mLists.get( key );
394
  }
395
396
  /**
397
   * Returns the {@link String} {@link Property} associated with the given
398
   * {@link Key} from the internal list of preference values. The caller
399
   * must be sure that the given {@link Key} is associated with a {@link File}
400
   * {@link Property}.
401
   *
402
   * @param key The {@link Key} associated with a preference value.
403
   * @return The value associated with the given {@link Key}.
404
   */
405
  public StringProperty stringProperty( final Key key ) {
406
    assert key != null;
407
    return valuesProperty( key );
408
  }
409
410
  /**
411
   * Returns the {@link Boolean} {@link Property} associated with the given
412
   * {@link Key} from the internal list of preference values. The caller
413
   * must be sure that the given {@link Key} is associated with a {@link File}
414
   * {@link Property}.
415
   *
416
   * @param key The {@link Key} associated with a preference value.
417
   * @return The value associated with the given {@link Key}.
418
   */
419
  public BooleanProperty booleanProperty( final Key key ) {
420
    assert key != null;
421
    return valuesProperty( key );
422
  }
423
424
  /**
425
   * Returns the {@link Integer} {@link Property} associated with the given
426
   * {@link Key} from the internal list of preference values. The caller
427
   * must be sure that the given {@link Key} is associated with a {@link File}
428
   * {@link Property}.
429
   *
430
   * @param key The {@link Key} associated with a preference value.
431
   * @return The value associated with the given {@link Key}.
432
   */
433
  public IntegerProperty integerProperty( final Key key ) {
434
    assert key != null;
435
    return valuesProperty( key );
436
  }
437
438
  /**
439
   * Returns the {@link Double} {@link Property} associated with the given
440
   * {@link Key} from the internal list of preference values. The caller
441
   * must be sure that the given {@link Key} is associated with a {@link File}
442
   * {@link Property}.
443
   *
444
   * @param key The {@link Key} associated with a preference value.
445
   * @return The value associated with the given {@link Key}.
446
   */
447
  public DoubleProperty doubleProperty( final Key key ) {
448
    assert key != null;
449
    return valuesProperty( key );
450
  }
451
452
  /**
453
   * Returns the {@link File} {@link Property} associated with the given
454
   * {@link Key} from the internal list of preference values. The caller
455
   * must be sure that the given {@link Key} is associated with a {@link File}
456
   * {@link Property}.
457
   *
458
   * @param key The {@link Key} associated with a preference value.
459
   * @return The value associated with the given {@link Key}.
460
   */
461
  public ObjectProperty<File> fileProperty( final Key key ) {
462
    assert key != null;
463
    return valuesProperty( key );
464
  }
465
466
  /**
467
   * Returns the {@link Locale} {@link Property} associated with the given
468
   * {@link Key} from the internal list of preference values. The caller
469
   * must be sure that the given {@link Key} is associated with a {@link File}
470
   * {@link Property}.
471
   *
472
   * @param key The {@link Key} associated with a preference value.
473
   * @return The value associated with the given {@link Key}.
474
   */
475
  public LocaleProperty localeProperty( final Key key ) {
476
    assert key != null;
477
    return valuesProperty( key );
478
  }
479
480
  public ObjectProperty<String> skinProperty( final Key key ) {
481
    assert key != null;
482
    return valuesProperty( key );
483
  }
484
485
  public String getString( final Key key ) {
486
    assert key != null;
487
    return stringProperty( key ).get();
488
  }
489
490
  /**
491
   * Returns the {@link Boolean} preference value associated with the given
492
   * {@link Key}. The caller must be sure that the given {@link Key} is
493
   * associated with a value that matches the return type.
494
   *
495
   * @param key The {@link Key} associated with a preference value.
496
   * @return The value associated with the given {@link Key}.
497
   */
498
  public boolean getBoolean( final Key key ) {
499
    assert key != null;
500
    return booleanProperty( key ).get();
501
  }
502
503
  /**
504
   * Returns the {@link Integer} preference value associated with the given
505
   * {@link Key}. The caller must be sure that the given {@link Key} is
506
   * associated with a value that matches the return type.
507
   *
508
   * @param key The {@link Key} associated with a preference value.
509
   * @return The value associated with the given {@link Key}.
510
   */
511
  public int getInteger( final Key key ) {
512
    assert key != null;
513
    return integerProperty( key ).get();
514
  }
515
516
  /**
517
   * Returns the {@link Double} preference value associated with the given
518
   * {@link Key}. The caller must be sure that the given {@link Key} is
519
   * associated with a value that matches the return type.
520
   *
521
   * @param key The {@link Key} associated with a preference value.
522
   * @return The value associated with the given {@link Key}.
523
   */
524
  public double getDouble( final Key key ) {
525
    assert key != null;
526
    return doubleProperty( key ).get();
527
  }
528
529
  /**
530
   * Returns the {@link File} preference value associated with the given
531
   * {@link Key}. The caller must be sure that the given {@link Key} is
532
   * associated with a value that matches the return type.
533
   *
534
   * @param key The {@link Key} associated with a preference value.
535
   * @return The value associated with the given {@link Key}.
536
   */
537
  public File getFile( final Key key ) {
538
    assert key != null;
539
    return fileProperty( key ).get();
540
  }
541
542
  /**
543
   * Returns the language locale setting for the
544
   * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
545
   *
546
   * @return The user's current locale setting.
547
   */
548
  public Locale getLocale() {
549
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
550
  }
551
552
  @SuppressWarnings( "unchecked" )
553
  public <K, V> Map<K, V> getMetadata() {
554
    final var metadata = listsProperty( KEY_DOC_META );
555
    final var map = new HashMap<K, V>( metadata.size() );
556
557
    metadata.forEach(
558
      entry -> map.put( (K) entry.getKey(), (V) entry.getValue() )
559
    );
560
561
    return map;
562
  }
563
564
  public Path getThemePath() {
16
import static com.keenwrite.Bootstrap.*;
17
import static com.keenwrite.Launcher.getVersion;
18
import static com.keenwrite.constants.Constants.*;
19
import static com.keenwrite.events.StatusEvent.clue;
20
import static com.keenwrite.preferences.AppKeys.*;
21
import static java.util.Map.entry;
22
import static javafx.application.Platform.runLater;
23
import static javafx.collections.FXCollections.observableArrayList;
24
import static javafx.collections.FXCollections.observableSet;
25
26
/**
27
 * Responsible for defining behaviours for separate projects. A workspace has
28
 * the ability to save and restore a session, including the window dimensions,
29
 * tab setup, files, and user preferences.
30
 * <p>
31
 * The configuration must support hierarchical (nested) configuration nodes
32
 * to persist the user interface state. Although possible with a flat
33
 * configuration file, it's not nearly as simple or elegant.
34
 * </p>
35
 * <p>
36
 * Neither JSON nor HOCON support schema validation and versioning, which makes
37
 * XML the more suitable configuration file format. Schema validation and
38
 * versioning provide future-proofing and ease of reading and upgrading previous
39
 * versions of the configuration file.
40
 * </p>
41
 * <p>
42
 * Persistent preferences may be set directly by the user or indirectly by
43
 * the act of using the application.
44
 * </p>
45
 * <p>
46
 * Note the following definitions:
47
 * </p>
48
 * <dl>
49
 *   <dt>File</dt>
50
 *   <dd>References a file name (no path), path, or directory.</dd>
51
 *   <dt>Path</dt>
52
 *   <dd>Fully qualified file name, which includes all parent directories.</dd>
53
 *   <dt>Dir</dt>
54
 *   <dd>Directory without file name ({@link File#isDirectory()} is true).</dd>
55
 * </dl>
56
 */
57
public final class Workspace {
58
59
  /**
60
   * Main configuration values, single text strings.
61
   */
62
  private final Map<Key, Property<?>> mValues = Map.ofEntries(
63
    entry( KEY_META_VERSION, asStringProperty( getVersion() ) ),
64
    entry( KEY_META_NAME, asStringProperty( "default" ) ),
65
66
    entry( KEY_EDITOR_AUTOSAVE, asIntegerProperty( 30 ) ),
67
68
    entry( KEY_R_SCRIPT, asStringProperty( "" ) ),
69
    entry( KEY_R_DIR, asFileProperty( USER_DIRECTORY ) ),
70
    entry( KEY_R_DELIM_BEGAN, asStringProperty( R_DELIM_BEGAN_DEFAULT ) ),
71
    entry( KEY_R_DELIM_ENDED, asStringProperty( R_DELIM_ENDED_DEFAULT ) ),
72
73
    entry( KEY_CACHES_DIR, asFileProperty( USER_CACHE_DIR ) ),
74
    entry( KEY_IMAGES_DIR, asFileProperty( USER_DIRECTORY ) ),
75
    entry( KEY_IMAGES_ORDER, asStringProperty( PERSIST_IMAGES_DEFAULT ) ),
76
    entry( KEY_IMAGES_RESIZE, asBooleanProperty( true ) ),
77
    entry( KEY_IMAGES_SERVER, asStringProperty( DIAGRAM_SERVER_NAME ) ),
78
79
    entry( KEY_DEF_PATH, asFileProperty( DEFINITION_DEFAULT ) ),
80
    entry( KEY_DEF_DELIM_BEGAN, asStringProperty( DEF_DELIM_BEGAN_DEFAULT ) ),
81
    entry( KEY_DEF_DELIM_ENDED, asStringProperty( DEF_DELIM_ENDED_DEFAULT ) ),
82
83
    entry( KEY_UI_RECENT_DIR, asFileProperty( USER_DIRECTORY ) ),
84
    entry( KEY_UI_RECENT_OFFSET, asIntegerProperty( DOCUMENT_OFFSET ) ),
85
    entry( KEY_UI_RECENT_DOCUMENT, asFileProperty( DOCUMENT_DEFAULT ) ),
86
    entry( KEY_UI_RECENT_DEFINITION, asFileProperty( DEFINITION_DEFAULT ) ),
87
    entry( KEY_UI_RECENT_EXPORT, asFileProperty( PDF_DEFAULT ) ),
88
89
    //@formatter:off
90
    entry(
91
      KEY_UI_FONT_EDITOR_NAME,
92
      asStringProperty( FONT_NAME_EDITOR_DEFAULT )
93
    ),
94
    entry(
95
     KEY_UI_FONT_EDITOR_SIZE,
96
     asDoubleProperty( FONT_SIZE_EDITOR_DEFAULT )
97
    ),
98
    entry(
99
     KEY_UI_FONT_PREVIEW_NAME,
100
     asStringProperty( FONT_NAME_PREVIEW_DEFAULT )
101
    ),
102
    entry(
103
     KEY_UI_FONT_PREVIEW_SIZE,
104
     asDoubleProperty( FONT_SIZE_PREVIEW_DEFAULT )
105
    ),
106
    entry(
107
     KEY_UI_FONT_PREVIEW_MONO_NAME,
108
     asStringProperty( FONT_NAME_PREVIEW_MONO_NAME_DEFAULT )
109
    ),
110
    entry(
111
     KEY_UI_FONT_PREVIEW_MONO_SIZE,
112
     asDoubleProperty( FONT_SIZE_PREVIEW_MONO_SIZE_DEFAULT )
113
    ),
114
115
    entry( KEY_UI_WINDOW_X, asDoubleProperty( WINDOW_X_DEFAULT ) ),
116
    entry( KEY_UI_WINDOW_Y, asDoubleProperty( WINDOW_Y_DEFAULT ) ),
117
    entry( KEY_UI_WINDOW_W, asDoubleProperty( WINDOW_W_DEFAULT ) ),
118
    entry( KEY_UI_WINDOW_H, asDoubleProperty( WINDOW_H_DEFAULT ) ),
119
    entry( KEY_UI_WINDOW_MAX, asBooleanProperty() ),
120
    entry( KEY_UI_WINDOW_FULL, asBooleanProperty() ),
121
122
    entry( KEY_UI_SKIN_SELECTION, asSkinProperty( SKIN_DEFAULT ) ),
123
    entry( KEY_UI_SKIN_CUSTOM, asFileProperty( SKIN_CUSTOM_DEFAULT ) ),
124
125
    entry(
126
      KEY_UI_PREVIEW_STYLESHEET, asFileProperty( PREVIEW_CUSTOM_DEFAULT )
127
    ),
128
129
    entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
130
131
    entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty( true ) ),
132
    entry( KEY_TYPESET_CONTEXT_FONTS_DIR, asFileProperty( getFontDirectory() ) ),
133
    entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
134
    entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) ),
135
    entry( KEY_TYPESET_CONTEXT_CHAPTERS, asStringProperty( "" ) ),
136
    entry( KEY_TYPESET_TYPOGRAPHY_QUOTES, asBooleanProperty( true ) )
137
    //@formatter:on
138
  );
139
140
  /**
141
   * Sets of configuration values, all the same type (e.g., file names),
142
   * where the key name doesn't change per set.
143
   */
144
  private final Map<Key, SetProperty<?>> mSets = Map.ofEntries(
145
    entry(
146
      KEY_UI_RECENT_OPEN_PATH,
147
      createSetProperty( new HashSet<String>() )
148
    )
149
  );
150
151
  /**
152
   * Lists of configuration values, such as key-value pairs where both the
153
   * key name and the value must be preserved per list.
154
   */
155
  private final Map<Key, ListProperty<?>> mLists = Map.ofEntries(
156
    entry(
157
      KEY_DOC_META,
158
      createListProperty( new LinkedList<Entry<String, String>>() )
159
    )
160
  );
161
162
  /**
163
   * Helps instantiate {@link Property} instances for XML configuration items.
164
   */
165
  private static final Map<Class<?>, Function<String, Object>> UNMARSHALL =
166
    Map.of(
167
      LocaleProperty.class, LocaleProperty::parseLocale,
168
      SimpleBooleanProperty.class, Boolean::parseBoolean,
169
      SimpleIntegerProperty.class, Integer::parseInt,
170
      SimpleDoubleProperty.class, Double::parseDouble,
171
      SimpleFloatProperty.class, Float::parseFloat,
172
      SimpleStringProperty.class, String::new,
173
      SimpleObjectProperty.class, String::new,
174
      SkinProperty.class, String::new,
175
      FileProperty.class, File::new
176
    );
177
178
  /**
179
   * The asymmetry with respect to {@link #UNMARSHALL} is because most objects
180
   * can simply call {@link Object#toString()} to convert the value to a string.
181
   */
182
  private static final Map<Class<?>, Function<String, Object>> MARSHALL =
183
    Map.of(
184
      LocaleProperty.class, LocaleProperty::toLanguageTag
185
    );
186
187
  /**
188
   * Converts the given {@link Property} value to a string.
189
   *
190
   * @param property The {@link Property} to convert.
191
   * @return A string representation of the given property, or the empty
192
   * string if no conversion was possible.
193
   */
194
  private static String marshall( final Property<?> property ) {
195
    final var v = property.getValue();
196
197
    return v == null
198
      ? ""
199
      : MARSHALL
200
      .getOrDefault( property.getClass(), __ -> property.getValue() )
201
      .apply( v.toString() )
202
      .toString();
203
  }
204
205
  private static Object unmarshall(
206
    final Property<?> property, final Object configValue ) {
207
    final var v = configValue.toString();
208
209
    return UNMARSHALL
210
      .getOrDefault( property.getClass(), value -> property.getValue() )
211
      .apply( v );
212
  }
213
214
  /**
215
   * Creates an instance of {@link ObservableList} that is based on a
216
   * modifiable observable array list for the given items.
217
   *
218
   * @param items The items to wrap in an observable list.
219
   * @param <E>   The type of items to add to the list.
220
   * @return An observable property that can have its contents modified.
221
   */
222
  public static <E> ObservableList<E> listProperty( final Set<E> items ) {
223
    return new SimpleListProperty<>( observableArrayList( items ) );
224
  }
225
226
  private static <E> SetProperty<E> createSetProperty( final Set<E> set ) {
227
    return new SimpleSetProperty<>( observableSet( set ) );
228
  }
229
230
  private static <E> ListProperty<E> createListProperty( final List<E> list ) {
231
    return new SimpleListProperty<>( observableArrayList( list ) );
232
  }
233
234
  private static StringProperty asStringProperty( final String value ) {
235
    return new SimpleStringProperty( value );
236
  }
237
238
  private static BooleanProperty asBooleanProperty() {
239
    return new SimpleBooleanProperty();
240
  }
241
242
  /**
243
   * @param value Default value.
244
   */
245
  @SuppressWarnings( "SameParameterValue" )
246
  private static BooleanProperty asBooleanProperty( final boolean value ) {
247
    return new SimpleBooleanProperty( value );
248
  }
249
250
  /**
251
   * @param value Default value.
252
   */
253
  @SuppressWarnings( "SameParameterValue" )
254
  private static IntegerProperty asIntegerProperty( final int value ) {
255
    return new SimpleIntegerProperty( value );
256
  }
257
258
  /**
259
   * @param value Default value.
260
   */
261
  private static DoubleProperty asDoubleProperty( final double value ) {
262
    return new SimpleDoubleProperty( value );
263
  }
264
265
  /**
266
   * @param value Default value.
267
   */
268
  private static FileProperty asFileProperty( final File value ) {
269
    return new FileProperty( value );
270
  }
271
272
  /**
273
   * @param value Default value.
274
   */
275
  @SuppressWarnings( "SameParameterValue" )
276
  private static LocaleProperty asLocaleProperty( final Locale value ) {
277
    return new LocaleProperty( value );
278
  }
279
280
  /**
281
   * @param value Default value.
282
   */
283
  @SuppressWarnings( "SameParameterValue" )
284
  private static SkinProperty asSkinProperty( final String value ) {
285
    return new SkinProperty( value );
286
  }
287
288
  /**
289
   * Creates a new {@link Workspace} that will attempt to load the users'
290
   * preferences. If the configuration file cannot be loaded, the workspace
291
   * settings returns default values.
292
   */
293
  public Workspace() {
294
    load();
295
  }
296
297
  /**
298
   * Attempts to load the app's configuration file.
299
   */
300
  private void load() {
301
    final var store = createXmlStore();
302
    store.load( FILE_PREFERENCES );
303
304
    mValues.keySet().forEach( key -> {
305
      try {
306
        final var storeValue = store.getValue( key );
307
        final var property = valuesProperty( key );
308
        final var unmarshalled = unmarshall( property, storeValue );
309
310
        property.setValue( unmarshalled );
311
      } catch( final NoSuchElementException ignored ) {
312
        // When no configuration (item), use the default value.
313
      }
314
    } );
315
316
    mSets.keySet().forEach( key -> {
317
      final var set = store.getSet( key );
318
      final SetProperty<String> property = setsProperty( key );
319
320
      property.setValue( observableSet( set ) );
321
    } );
322
323
    mLists.keySet().forEach( key -> {
324
      final var map = store.getMap( key );
325
      final ListProperty<Entry<String, String>> property = listsProperty( key );
326
      final var list = map
327
        .entrySet()
328
        .stream()
329
        .toList();
330
331
      property.setValue( observableArrayList( list ) );
332
    } );
333
334
    WorkspaceLoadedEvent.fire( this );
335
  }
336
337
  /**
338
   * Saves the current workspace.
339
   */
340
  public void save() {
341
    final var store = createXmlStore();
342
343
    try {
344
      // Update the string values to include the application version.
345
      valuesProperty( KEY_META_VERSION ).setValue( getVersion() );
346
347
      mValues.forEach( ( k, v ) -> store.setValue( k, marshall( v ) ) );
348
      mSets.forEach( store::setSet );
349
      mLists.forEach( store::setMap );
350
351
      store.save( FILE_PREFERENCES );
352
    } catch( final Exception ex ) {
353
      clue( ex );
354
    }
355
  }
356
357
  /**
358
   * Returns a value that represents a setting in the application that the user
359
   * may configure, either directly or indirectly.
360
   *
361
   * @param key The reference to the users' preference stored in deference
362
   *            of app reëntrance.
363
   * @return An observable property to be persisted.
364
   */
365
  @SuppressWarnings( "unchecked" )
366
  public <T, U extends Property<T>> U valuesProperty( final Key key ) {
367
    assert key != null;
368
    return (U) mValues.get( key );
369
  }
370
371
  /**
372
   * Returns a set of values that represent a setting in the application that
373
   * the user may configure, either directly or indirectly. The property
374
   * returned is backed by a {@link Set}.
375
   *
376
   * @param key The {@link Key} associated with a preference value.
377
   * @return An observable property to be persisted.
378
   */
379
  @SuppressWarnings( "unchecked" )
380
  public <T> SetProperty<T> setsProperty( final Key key ) {
381
    assert key != null;
382
    return (SetProperty<T>) mSets.get( key );
383
  }
384
385
  /**
386
   * Returns a list of values that represent a setting in the application that
387
   * the user may configure, either directly or indirectly. The property
388
   * returned is backed by a mutable {@link List}.
389
   *
390
   * @param key The {@link Key} associated with a preference value.
391
   * @return An observable property to be persisted.
392
   */
393
  @SuppressWarnings( "unchecked" )
394
  public <K, V> ListProperty<Entry<K, V>> listsProperty( final Key key ) {
395
    assert key != null;
396
    return (ListProperty<Entry<K, V>>) mLists.get( key );
397
  }
398
399
  /**
400
   * Returns the {@link String} {@link Property} associated with the given
401
   * {@link Key} from the internal list of preference values. The caller
402
   * must be sure that the given {@link Key} is associated with a {@link File}
403
   * {@link Property}.
404
   *
405
   * @param key The {@link Key} associated with a preference value.
406
   * @return The value associated with the given {@link Key}.
407
   */
408
  public StringProperty stringProperty( final Key key ) {
409
    assert key != null;
410
    return valuesProperty( key );
411
  }
412
413
  /**
414
   * Returns the {@link Boolean} {@link Property} associated with the given
415
   * {@link Key} from the internal list of preference values. The caller
416
   * must be sure that the given {@link Key} is associated with a {@link File}
417
   * {@link Property}.
418
   *
419
   * @param key The {@link Key} associated with a preference value.
420
   * @return The value associated with the given {@link Key}.
421
   */
422
  public BooleanProperty booleanProperty( final Key key ) {
423
    assert key != null;
424
    return valuesProperty( key );
425
  }
426
427
  /**
428
   * Returns the {@link Integer} {@link Property} associated with the given
429
   * {@link Key} from the internal list of preference values. The caller
430
   * must be sure that the given {@link Key} is associated with a {@link File}
431
   * {@link Property}.
432
   *
433
   * @param key The {@link Key} associated with a preference value.
434
   * @return The value associated with the given {@link Key}.
435
   */
436
  public IntegerProperty integerProperty( final Key key ) {
437
    assert key != null;
438
    return valuesProperty( key );
439
  }
440
441
  /**
442
   * Returns the {@link Double} {@link Property} associated with the given
443
   * {@link Key} from the internal list of preference values. The caller
444
   * must be sure that the given {@link Key} is associated with a {@link File}
445
   * {@link Property}.
446
   *
447
   * @param key The {@link Key} associated with a preference value.
448
   * @return The value associated with the given {@link Key}.
449
   */
450
  public DoubleProperty doubleProperty( final Key key ) {
451
    assert key != null;
452
    return valuesProperty( key );
453
  }
454
455
  /**
456
   * Returns the {@link File} {@link Property} associated with the given
457
   * {@link Key} from the internal list of preference values. The caller
458
   * must be sure that the given {@link Key} is associated with a {@link File}
459
   * {@link Property}.
460
   *
461
   * @param key The {@link Key} associated with a preference value.
462
   * @return The value associated with the given {@link Key}.
463
   */
464
  public ObjectProperty<File> fileProperty( final Key key ) {
465
    assert key != null;
466
    return valuesProperty( key );
467
  }
468
469
  /**
470
   * Returns the {@link Locale} {@link Property} associated with the given
471
   * {@link Key} from the internal list of preference values. The caller
472
   * must be sure that the given {@link Key} is associated with a {@link File}
473
   * {@link Property}.
474
   *
475
   * @param key The {@link Key} associated with a preference value.
476
   * @return The value associated with the given {@link Key}.
477
   */
478
  public LocaleProperty localeProperty( final Key key ) {
479
    assert key != null;
480
    return valuesProperty( key );
481
  }
482
483
  public ObjectProperty<String> skinProperty( final Key key ) {
484
    assert key != null;
485
    return valuesProperty( key );
486
  }
487
488
  public String getString( final Key key ) {
489
    assert key != null;
490
    return stringProperty( key ).get();
491
  }
492
493
  /**
494
   * Returns the {@link Boolean} preference value associated with the given
495
   * {@link Key}. The caller must be sure that the given {@link Key} is
496
   * associated with a value that matches the return type.
497
   *
498
   * @param key The {@link Key} associated with a preference value.
499
   * @return The value associated with the given {@link Key}.
500
   */
501
  public boolean getBoolean( final Key key ) {
502
    assert key != null;
503
    return booleanProperty( key ).get();
504
  }
505
506
  /**
507
   * Returns the {@link Integer} preference value associated with the given
508
   * {@link Key}. The caller must be sure that the given {@link Key} is
509
   * associated with a value that matches the return type.
510
   *
511
   * @param key The {@link Key} associated with a preference value.
512
   * @return The value associated with the given {@link Key}.
513
   */
514
  public int getInteger( final Key key ) {
515
    assert key != null;
516
    return integerProperty( key ).get();
517
  }
518
519
  /**
520
   * Returns the {@link Double} preference value associated with the given
521
   * {@link Key}. The caller must be sure that the given {@link Key} is
522
   * associated with a value that matches the return type.
523
   *
524
   * @param key The {@link Key} associated with a preference value.
525
   * @return The value associated with the given {@link Key}.
526
   */
527
  public double getDouble( final Key key ) {
528
    assert key != null;
529
    return doubleProperty( key ).get();
530
  }
531
532
  /**
533
   * Returns the {@link File} preference value associated with the given
534
   * {@link Key}. The caller must be sure that the given {@link Key} is
535
   * associated with a value that matches the return type.
536
   *
537
   * @param key The {@link Key} associated with a preference value.
538
   * @return The value associated with the given {@link Key}.
539
   */
540
  public File getFile( final Key key ) {
541
    assert key != null;
542
    return fileProperty( key ).get();
543
  }
544
545
  /**
546
   * Returns the language locale setting for the
547
   * {@link AppKeys#KEY_LANGUAGE_LOCALE} key.
548
   *
549
   * @return The user's current locale setting.
550
   */
551
  public Locale getLocale() {
552
    return localeProperty( KEY_LANGUAGE_LOCALE ).toLocale();
553
  }
554
555
  @SuppressWarnings( "unchecked" )
556
  public <K, V> Map<K, V> getMetadata() {
557
    final var metadata = listsProperty( KEY_DOC_META );
558
    final var map = new HashMap<K, V>( metadata.size() );
559
560
    metadata.forEach(
561
      entry -> map.put( (K) entry.getKey(), (V) entry.getValue() )
562
    );
563
564
    return map;
565
  }
566
567
  public Path getThemesPath() {
565568
    final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
566569
    final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
M src/main/java/com/keenwrite/preview/SvgReplacedElementFactory.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.preview;
33
...
1515
1616
import static com.keenwrite.events.StatusEvent.clue;
17
import static com.keenwrite.io.HttpFacade.httpGet;
17
import static com.keenwrite.io.downloads.DownloadManager.open;
1818
import static com.keenwrite.preview.MathRenderer.MATH_RENDERER;
1919
import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER;
...
5151
        case HTML_IMAGE -> {
5252
          final var source = e.getAttribute( HTML_IMAGE_SRC );
53
          var mediaType = MediaType.fromFilename( source );
53
5454
          URI uri = null;
5555
5656
          if( getProtocol( source ).isHttp() ) {
57
            if( mediaType.isSvg() || mediaType.isUndefined() ) {
58
              uri = new URI( source );
59
60
              try( final var response = httpGet( uri ) ) {
61
                mediaType = response.getMediaType();
57
            try( final var response = open( source ) ) {
58
              if( response.isSvg() ) {
59
                // Rasterize SVG from URL resource.
60
                raster = rasterize(
61
                  response.getInputStream(),
62
                  box.getContentWidth()
63
                );
6264
              }
6365
64
              // Attempt to rasterize SVG depending on URL resource content.
65
              if( !mediaType.isSvg() ) {
66
                uri = null;
67
              }
66
              clue( "Main.status.image.request.fetch", source );
6867
            }
6968
          }
70
          else if( mediaType.isSvg() ) {
69
          else if( MediaType.fromFilename( source ).isSvg() ) {
7170
            // Attempt to rasterize based on file name.
7271
            final var path = Path.of( new URI( source ).getPath() );
...
9897
    }
9998
100
    return image;
99
    return image == null ? BROKEN_IMAGE : image;
101100
  }
102101
M src/main/java/com/keenwrite/processors/PdfProcessor.java
44
import com.keenwrite.typesetting.Typesetter;
55
6
import java.io.IOException;
7
8
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
6
import static com.keenwrite.Bootstrap.APP_TITLE_ABBR;
97
import static com.keenwrite.events.StatusEvent.clue;
108
import static com.keenwrite.io.MediaType.TEXT_XML;
...
1816
 */
1917
public final class PdfProcessor extends ExecutorProcessor<String> {
20
  private final ProcessorContext mContext;
18
  private final ProcessorContext mProcessorContext;
2119
2220
  public PdfProcessor( final ProcessorContext context ) {
2321
    assert context != null;
24
    mContext = context;
22
    mProcessorContext = context;
2523
  }
2624
...
3634
    try {
3735
      clue( "Main.status.typeset.create" );
38
      final var context = mContext;
39
      final var document = TEXT_XML.createTempFile( APP_TITLE_LOWERCASE );
36
      final var context = mProcessorContext;
37
      final var parent = context.getTargetPath().getParent();
38
      final var document =
39
        TEXT_XML.createTempFile( APP_TITLE_ABBR, parent );
4040
      final var typesetter = Typesetter
4141
        .builder()
42
        .with( Mutator::setInputPath, writeString( document, xhtml ) )
43
        .with( Mutator::setOutputPath, context.getOutputPath() )
44
        .with( Mutator::setThemePath, context.getThemePath() )
45
        .with( Mutator::setAutoClean, context.getAutoClean() )
42
        .with( Mutator::setAutoRemove, context.getAutoRemove() )
43
        .with( Mutator::setSourcePath, writeString( document, xhtml ) )
44
        .with( Mutator::setTargetPath, context.getTargetPath() )
45
        .with( Mutator::setThemesPath, context.getThemesPath() )
46
        .with( Mutator::setImagesPath, context.getImagesPath() )
47
        .with( Mutator::setCachesPath, context.getCachesPath() )
48
        .with( Mutator::setFontsPath, context.getFontsPath() )
4649
        .build();
4750
4851
      typesetter.typeset();
4952
5053
      // Smote the temporary file after typesetting the document.
51
      if( typesetter.autoclean() ) {
54
      if( typesetter.autoRemove() ) {
5255
        deleteIfExists( document );
5356
      }
54
    } catch( final IOException | InterruptedException ex ) {
57
    } catch( final Exception ex ) {
5558
      // Typesetter runtime exceptions will pass up the call stack.
5659
      clue( "Main.status.typeset.failed", ex );
M src/main/java/com/keenwrite/processors/ProcessorContext.java
2020
import java.util.function.Supplier;
2121
22
import static com.keenwrite.constants.Constants.*;
23
import static com.keenwrite.io.FileType.UNKNOWN;
24
import static com.keenwrite.io.MediaType.TEXT_PROPERTIES;
25
import static com.keenwrite.io.MediaType.valueFrom;
26
import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
27
28
/**
29
 * Provides a context for configuring a chain of {@link Processor} instances.
30
 */
31
public final class ProcessorContext {
32
33
  private final Mutator mMutator;
34
35
  /**
36
   * Determines the file type from the path extension. This should only be
37
   * called when it is known that the file type won't be a definition file
38
   * (e.g., YAML or other definition source), but rather an editable file
39
   * (e.g., Markdown, R Markdown, etc.).
40
   *
41
   * @param path The path with a file name extension.
42
   * @return The FileType for the given path.
43
   */
44
  private static FileType lookup( final Path path ) {
45
    assert path != null;
46
47
    final var prefix = GLOB_PREFIX_FILE;
48
    final var keys = sSettings.getKeys( prefix );
49
50
    var found = false;
51
    var fileType = UNKNOWN;
52
53
    while( keys.hasNext() && !found ) {
54
      final var key = keys.next();
55
      final var patterns = sSettings.getStringSettingList( key );
56
      final var predicate = createFileTypePredicate( patterns );
57
58
      if( predicate.test( path.toFile() ) ) {
59
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
60
        // to a standard name (as defined in the settings.properties file).
61
        final String suffix = key.replace( prefix + '.', "" );
62
        fileType = FileType.from( suffix );
63
        found = true;
64
      }
65
    }
66
67
    return fileType;
68
  }
69
70
  public boolean isExportFormat( final ExportFormat exportFormat ) {
71
    return mMutator.mExportFormat == exportFormat;
72
  }
73
74
  /**
75
   * Responsible for populating the instance variables required by the
76
   * context.
77
   */
78
  public static class Mutator {
79
    private Path mInputPath;
80
    private Path mOutputPath;
81
    private ExportFormat mExportFormat;
82
    private boolean mConcatenate;
83
84
    private Supplier<Path> mThemePath;
85
    private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
86
87
    private Supplier<Map<String, String>> mDefinitions = HashMap::new;
88
    private Supplier<Map<String, String>> mMetadata = HashMap::new;
89
    private Supplier<Caret> mCaret = () -> Caret.builder().build();
90
91
    private Supplier<Path> mImageDir;
92
    private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
93
    private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
94
95
    private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
96
    private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
97
98
    private Supplier<Path> mRWorkingDir;
99
    private Supplier<String> mRScript = () -> "";
100
101
    private Supplier<Boolean> mCurlQuotes = () -> true;
102
    private Supplier<Boolean> mAutoClean = () -> true;
103
104
    public void setInputPath( final Path inputPath ) {
105
      assert inputPath != null;
106
      mInputPath = inputPath;
107
    }
108
109
    public void setOutputPath( final Path outputPath ) {
110
      assert outputPath != null;
111
      mOutputPath = outputPath;
112
    }
113
114
    public void setOutputPath( final File outputPath ) {
115
      assert outputPath != null;
116
      setOutputPath( outputPath.toPath() );
117
    }
118
119
    public void setExportFormat( final ExportFormat exportFormat ) {
120
      assert exportFormat != null;
121
      mExportFormat = exportFormat;
122
    }
123
124
    public void setConcatenate( final boolean concatenate ) {
125
      mConcatenate = concatenate;
126
    }
127
128
    public void setLocale( final Supplier<Locale> locale ) {
129
      assert locale != null;
130
      mLocale = locale;
131
    }
132
133
    public void setThemePath( final Supplier<Path> themePath ) {
134
      assert themePath != null;
135
      mThemePath = themePath;
136
    }
137
138
    /**
139
     * Sets the list of fully interpolated key-value pairs to use when
140
     * substituting variable names back into the document as variable values.
141
     * This uses a {@link Callable} reference so that GUI and command-line
142
     * usage can insert their respective behaviours. That is, this method
143
     * prevents coupling the GUI to the CLI.
144
     *
145
     * @param supplier Defines how to retrieve the definitions.
146
     */
147
    public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
148
      assert supplier != null;
149
      mDefinitions = supplier;
150
    }
151
152
    public void setMetadata( final Supplier<Map<String, String>> metadata ) {
153
      assert metadata != null;
154
      mMetadata = metadata.get() == null ? HashMap::new : metadata;
155
    }
156
157
    /**
158
     * Sets the source for deriving the {@link Caret}. Typically, this is
159
     * the text editor that has focus.
160
     *
161
     * @param caret The source for the currently active caret.
162
     */
163
    public void setCaret( final Supplier<Caret> caret ) {
164
      assert caret != null;
165
      mCaret = caret;
166
    }
167
168
    public void setImageDir( final Supplier<File> imageDir ) {
169
      assert imageDir != null;
170
171
      mImageDir = () -> {
172
        final var dir = imageDir.get();
173
174
        return (dir == null ? USER_DIRECTORY : dir).toPath();
175
      };
176
    }
177
178
    public void setImageOrder( final Supplier<String> imageOrder ) {
179
      assert imageOrder != null;
180
      mImageOrder = imageOrder;
181
    }
182
183
    public void setImageServer( final Supplier<String> imageServer ) {
184
      assert imageServer != null;
185
      mImageServer = imageServer;
186
    }
187
188
    public void setSigilBegan( final Supplier<String> sigilBegan ) {
189
      assert sigilBegan != null;
190
      mSigilBegan = sigilBegan;
191
    }
192
193
    public void setSigilEnded( final Supplier<String> sigilEnded ) {
194
      assert sigilEnded != null;
195
      mSigilEnded = sigilEnded;
196
    }
197
198
    public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
199
      assert rWorkingDir != null;
200
201
      mRWorkingDir = rWorkingDir;
202
    }
203
204
    public void setRScript( final Supplier<String> rScript ) {
205
      assert rScript != null;
206
      mRScript = rScript;
207
    }
208
209
    public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
210
      assert curlQuotes != null;
211
      mCurlQuotes = curlQuotes;
212
    }
213
214
    public void setAutoClean( final Supplier<Boolean> autoClean ) {
215
      assert autoClean != null;
216
      mAutoClean = autoClean;
217
    }
218
219
    private boolean isExportFormat( final ExportFormat format ) {
220
      return mExportFormat == format;
221
    }
222
  }
223
224
  public static GenericBuilder<Mutator, ProcessorContext> builder() {
225
    return GenericBuilder.of( Mutator::new, ProcessorContext::new );
226
  }
227
228
  /**
229
   * Creates a new context for use by the {@link ProcessorFactory} when
230
   * instantiating new {@link Processor} instances. Although all the
231
   * parameters are required, not all {@link Processor} instances will use
232
   * all parameters.
233
   */
234
  private ProcessorContext( final Mutator mutator ) {
235
    assert mutator != null;
236
237
    mMutator = mutator;
238
  }
239
240
  public Path getInputPath() {
241
    return mMutator.mInputPath;
242
  }
243
244
  /**
245
   * Fully qualified file name to use when exporting (e.g., document.pdf).
246
   *
247
   * @return Full path to a file name.
248
   */
249
  public Path getOutputPath() {
250
    return mMutator.mOutputPath;
251
  }
252
253
  public ExportFormat getExportFormat() {
254
    return mMutator.mExportFormat;
255
  }
256
257
  public Locale getLocale() {
258
    return mMutator.mLocale.get();
259
  }
260
261
  /**
262
   * Returns the variable map of definitions, without interpolation.
263
   *
264
   * @return A map to help dereference variables.
265
   */
266
  public Map<String, String> getDefinitions() {
267
    return mMutator.mDefinitions.get();
268
  }
269
270
  /**
271
   * Returns the variable map of definitions, with interpolation.
272
   *
273
   * @return A map to help dereference variables.
274
   */
275
  public InterpolatingMap getInterpolatedDefinitions() {
276
    return new InterpolatingMap(
277
      createDefinitionKeyOperator(), getDefinitions()
278
    ).interpolate();
279
  }
280
281
  public Map<String, String> getMetadata() {
282
    return mMutator.mMetadata.get();
283
  }
284
285
  /**
286
   * Returns the current caret position in the document being edited and is
287
   * always up-to-date.
288
   *
289
   * @return Caret position in the document.
290
   */
291
  public Supplier<Caret> getCaret() {
292
    return mMutator.mCaret;
293
  }
294
295
  /**
296
   * Returns the directory that contains the file being edited. When
297
   * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is
298
   * {@code null}. This will get absolute path to the file before trying to
299
   * get te parent path, which should always be a valid path. In the unlikely
300
   * event that the base path cannot be determined by the path alone, the
301
   * default user directory is returned. This is necessary for the creation
302
   * of new files.
303
   *
304
   * @return Path to the directory containing a file being edited, or the
305
   * default user directory if the base path cannot be determined.
306
   */
307
  public Path getBaseDir() {
308
    final var path = getInputPath().toAbsolutePath().getParent();
309
    return path == null ? DEFAULT_DIRECTORY : path;
310
  }
311
312
  FileType getInputFileType() {
313
    return lookup( getInputPath() );
314
  }
315
316
  public Path getImageDir() {
317
    return mMutator.mImageDir.get();
318
  }
319
320
  public Iterable<String> getImageOrder() {
321
    assert mMutator.mImageOrder != null;
322
323
    final var order = mMutator.mImageOrder.get();
324
    final var token = order.contains( "," ) ? ',' : ' ';
325
326
    return Splitter.on( token ).split( token + order );
327
  }
328
329
  public String getImageServer() {
330
    return mMutator.mImageServer.get();
331
  }
332
333
  public Path getThemePath() {
334
    return mMutator.mThemePath.get();
335
  }
336
337
  public Path getRWorkingDir() {
338
    return mMutator.mRWorkingDir.get();
339
  }
340
341
  public String getRScript() {
342
    return mMutator.mRScript.get();
343
  }
344
345
  public boolean getCurlQuotes() {
346
    return mMutator.mCurlQuotes.get();
347
  }
348
349
  public boolean getAutoClean() {
350
    return mMutator.mAutoClean.get();
351
  }
352
353
  /**
354
   * Answers whether to process a single text file or all text files in
355
   * the same directory as a single text file. See {@link #getInputPath()}
356
   * for the file to process (or all files in its directory).
357
   *
358
   * @return {@code true} means to process all text files, {@code false}
359
   * means to process a single file.
360
   */
361
  public boolean getConcatenate() {
362
    return mMutator.mConcatenate;
363
  }
364
365
  public SigilKeyOperator createKeyOperator() {
366
    return createKeyOperator( getInputPath() );
22
import static com.keenwrite.Bootstrap.USER_DATA_DIR;
23
import static com.keenwrite.constants.Constants.*;
24
import static com.keenwrite.io.FileType.UNKNOWN;
25
import static com.keenwrite.io.MediaType.TEXT_PROPERTIES;
26
import static com.keenwrite.io.MediaType.valueFrom;
27
import static com.keenwrite.predicates.PredicateFactory.createFileTypePredicate;
28
29
/**
30
 * Provides a context for configuring a chain of {@link Processor} instances.
31
 */
32
public final class ProcessorContext {
33
34
  private final Mutator mMutator;
35
36
  /**
37
   * Determines the file type from the path extension. This should only be
38
   * called when it is known that the file type won't be a definition file
39
   * (e.g., YAML or other definition source), but rather an editable file
40
   * (e.g., Markdown, R Markdown, etc.).
41
   *
42
   * @param path The path with a file name extension.
43
   * @return The FileType for the given path.
44
   */
45
  private static FileType lookup( final Path path ) {
46
    assert path != null;
47
48
    final var prefix = GLOB_PREFIX_FILE;
49
    final var keys = sSettings.getKeys( prefix );
50
51
    var found = false;
52
    var fileType = UNKNOWN;
53
54
    while( keys.hasNext() && !found ) {
55
      final var key = keys.next();
56
      final var patterns = sSettings.getStringSettingList( key );
57
      final var predicate = createFileTypePredicate( patterns );
58
59
      if( predicate.test( path.toFile() ) ) {
60
        // Remove the EXTENSIONS_PREFIX to get the file name extension mapped
61
        // to a standard name (as defined in the settings.properties file).
62
        final String suffix = key.replace( prefix + '.', "" );
63
        fileType = FileType.from( suffix );
64
        found = true;
65
      }
66
    }
67
68
    return fileType;
69
  }
70
71
  public boolean isExportFormat( final ExportFormat exportFormat ) {
72
    return mMutator.mExportFormat == exportFormat;
73
  }
74
75
  /**
76
   * Responsible for populating the instance variables required by the
77
   * context.
78
   */
79
  public static class Mutator {
80
    private Path mSourcePath;
81
    private Path mTargetPath;
82
    private ExportFormat mExportFormat;
83
    private boolean mConcatenate;
84
85
    private Supplier<Path> mThemesPath;
86
    private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
87
88
    private Supplier<Map<String, String>> mDefinitions = HashMap::new;
89
    private Supplier<Map<String, String>> mMetadata = HashMap::new;
90
    private Supplier<Caret> mCaret = () -> Caret.builder().build();
91
92
    private Supplier<Path> mFontsPath;
93
94
    private Supplier<Path> mImagesPath;
95
    private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
96
    private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
97
98
    private Supplier<Path> mCachesPath;
99
100
    private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
101
    private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
102
103
    private Supplier<Path> mRWorkingDir;
104
    private Supplier<String> mRScript = () -> "";
105
106
    private Supplier<Boolean> mCurlQuotes = () -> true;
107
    private Supplier<Boolean> mAutoRemove = () -> true;
108
109
    public void setSourcePath( final Path sourcePath ) {
110
      assert sourcePath != null;
111
      mSourcePath = sourcePath;
112
    }
113
114
    public void setTargetPath( final Path outputPath ) {
115
      assert outputPath != null;
116
      mTargetPath = outputPath;
117
    }
118
119
    public void setTargetPath( final File targetPath ) {
120
      assert targetPath != null;
121
      setTargetPath( targetPath.toPath() );
122
    }
123
124
    public void setThemesPath( final Supplier<Path> themesPath ) {
125
      assert themesPath != null;
126
      mThemesPath = themesPath;
127
    }
128
129
    public void setCachesPath( final Supplier<File> cachesDir ) {
130
      assert cachesDir != null;
131
132
      mCachesPath = () -> {
133
        final var dir = cachesDir.get();
134
135
        return (dir == null ? USER_DATA_DIR.toFile() : dir).toPath();
136
      };
137
    }
138
139
    public void setImagesPath( final Supplier<File> imagesDir ) {
140
      assert imagesDir != null;
141
142
      mImagesPath = () -> {
143
        final var dir = imagesDir.get();
144
145
        return (dir == null ? USER_DIRECTORY : dir).toPath();
146
      };
147
    }
148
149
    public void setImageOrder( final Supplier<String> imageOrder ) {
150
      assert imageOrder != null;
151
      mImageOrder = imageOrder;
152
    }
153
154
    public void setImageServer( final Supplier<String> imageServer ) {
155
      assert imageServer != null;
156
      mImageServer = imageServer;
157
    }
158
159
    public void setFontsPath( final Supplier<File> fontsPath ) {
160
      assert fontsPath != null;
161
      mFontsPath = () -> {
162
        final var dir = fontsPath.get();
163
164
        return (dir == null ? USER_DIRECTORY : dir).toPath();
165
      };
166
    }
167
168
    public void setExportFormat( final ExportFormat exportFormat ) {
169
      assert exportFormat != null;
170
      mExportFormat = exportFormat;
171
    }
172
173
    public void setConcatenate( final boolean concatenate ) {
174
      mConcatenate = concatenate;
175
    }
176
177
    public void setLocale( final Supplier<Locale> locale ) {
178
      assert locale != null;
179
      mLocale = locale;
180
    }
181
182
    /**
183
     * Sets the list of fully interpolated key-value pairs to use when
184
     * substituting variable names back into the document as variable values.
185
     * This uses a {@link Callable} reference so that GUI and command-line
186
     * usage can insert their respective behaviours. That is, this method
187
     * prevents coupling the GUI to the CLI.
188
     *
189
     * @param supplier Defines how to retrieve the definitions.
190
     */
191
    public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
192
      assert supplier != null;
193
      mDefinitions = supplier;
194
    }
195
196
    public void setMetadata( final Supplier<Map<String, String>> metadata ) {
197
      assert metadata != null;
198
      mMetadata = metadata.get() == null ? HashMap::new : metadata;
199
    }
200
201
    /**
202
     * Sets the source for deriving the {@link Caret}. Typically, this is
203
     * the text editor that has focus.
204
     *
205
     * @param caret The source for the currently active caret.
206
     */
207
    public void setCaret( final Supplier<Caret> caret ) {
208
      assert caret != null;
209
      mCaret = caret;
210
    }
211
212
    public void setSigilBegan( final Supplier<String> sigilBegan ) {
213
      assert sigilBegan != null;
214
      mSigilBegan = sigilBegan;
215
    }
216
217
    public void setSigilEnded( final Supplier<String> sigilEnded ) {
218
      assert sigilEnded != null;
219
      mSigilEnded = sigilEnded;
220
    }
221
222
    public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
223
      assert rWorkingDir != null;
224
225
      mRWorkingDir = rWorkingDir;
226
    }
227
228
    public void setRScript( final Supplier<String> rScript ) {
229
      assert rScript != null;
230
      mRScript = rScript;
231
    }
232
233
    public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
234
      assert curlQuotes != null;
235
      mCurlQuotes = curlQuotes;
236
    }
237
238
    public void setAutoRemove( final Supplier<Boolean> autoRemove ) {
239
      assert autoRemove != null;
240
      mAutoRemove = autoRemove;
241
    }
242
243
    private boolean isExportFormat( final ExportFormat format ) {
244
      return mExportFormat == format;
245
    }
246
  }
247
248
  public static GenericBuilder<Mutator, ProcessorContext> builder() {
249
    return GenericBuilder.of( Mutator::new, ProcessorContext::new );
250
  }
251
252
  /**
253
   * Creates a new context for use by the {@link ProcessorFactory} when
254
   * instantiating new {@link Processor} instances. Although all the
255
   * parameters are required, not all {@link Processor} instances will use
256
   * all parameters.
257
   */
258
  private ProcessorContext( final Mutator mutator ) {
259
    assert mutator != null;
260
261
    mMutator = mutator;
262
  }
263
264
  public Path getSourcePath() {
265
    return mMutator.mSourcePath;
266
  }
267
268
  /**
269
   * Fully qualified file name to use when exporting (e.g., document.pdf).
270
   *
271
   * @return Full path to a file name.
272
   */
273
  public Path getTargetPath() {
274
    return mMutator.mTargetPath;
275
  }
276
277
  public ExportFormat getExportFormat() {
278
    return mMutator.mExportFormat;
279
  }
280
281
  public Locale getLocale() {
282
    return mMutator.mLocale.get();
283
  }
284
285
  /**
286
   * Returns the variable map of definitions, without interpolation.
287
   *
288
   * @return A map to help dereference variables.
289
   */
290
  public Map<String, String> getDefinitions() {
291
    return mMutator.mDefinitions.get();
292
  }
293
294
  /**
295
   * Returns the variable map of definitions, with interpolation.
296
   *
297
   * @return A map to help dereference variables.
298
   */
299
  public InterpolatingMap getInterpolatedDefinitions() {
300
    return new InterpolatingMap(
301
      createDefinitionKeyOperator(), getDefinitions()
302
    ).interpolate();
303
  }
304
305
  public Map<String, String> getMetadata() {
306
    return mMutator.mMetadata.get();
307
  }
308
309
  /**
310
   * Returns the current caret position in the document being edited and is
311
   * always up-to-date.
312
   *
313
   * @return Caret position in the document.
314
   */
315
  public Supplier<Caret> getCaret() {
316
    return mMutator.mCaret;
317
  }
318
319
  /**
320
   * Returns the directory that contains the file being edited. When
321
   * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is
322
   * {@code null}. This will get absolute path to the file before trying to
323
   * get te parent path, which should always be a valid path. In the unlikely
324
   * event that the base path cannot be determined by the path alone, the
325
   * default user directory is returned. This is necessary for the creation
326
   * of new files.
327
   *
328
   * @return Path to the directory containing a file being edited, or the
329
   * default user directory if the base path cannot be determined.
330
   */
331
  public Path getBaseDir() {
332
    final var path = getSourcePath().toAbsolutePath().getParent();
333
    return path == null ? DEFAULT_DIRECTORY : path;
334
  }
335
336
  FileType getSourceFileType() {
337
    return lookup( getSourcePath() );
338
  }
339
340
  public Path getThemesPath() {
341
    return mMutator.mThemesPath.get();
342
  }
343
344
  public Path getImagesPath() {
345
    return mMutator.mImagesPath.get();
346
  }
347
348
  public Path getCachesPath() {
349
    return mMutator.mCachesPath.get();
350
  }
351
352
  public Iterable<String> getImageOrder() {
353
    assert mMutator.mImageOrder != null;
354
355
    final var order = mMutator.mImageOrder.get();
356
    final var token = order.contains( "," ) ? ',' : ' ';
357
358
    return Splitter.on( token ).split( token + order );
359
  }
360
361
  public String getImageServer() {
362
    return mMutator.mImageServer.get();
363
  }
364
365
  public Path getFontsPath() {
366
    return mMutator.mFontsPath.get();
367
  }
368
369
  public boolean getAutoRemove() {
370
    return mMutator.mAutoRemove.get();
371
  }
372
373
  public Path getRWorkingDir() {
374
    return mMutator.mRWorkingDir.get();
375
  }
376
377
  public String getRScript() {
378
    return mMutator.mRScript.get();
379
  }
380
381
  public boolean getCurlQuotes() {
382
    return mMutator.mCurlQuotes.get();
383
  }
384
385
  /**
386
   * Answers whether to process a single text file or all text files in
387
   * the same directory as a single text file. See {@link #getSourcePath()}
388
   * for the file to process (or all files in its directory).
389
   *
390
   * @return {@code true} means to process all text files, {@code false}
391
   * means to process a single file.
392
   */
393
  public boolean getConcatenate() {
394
    return mMutator.mConcatenate;
395
  }
396
397
  public SigilKeyOperator createKeyOperator() {
398
    return createKeyOperator( getSourcePath() );
367399
  }
368400
M src/main/java/com/keenwrite/processors/ProcessorFactory.java
6262
    };
6363
64
    final var inputType = context.getInputFileType();
64
    final var inputType = context.getSourceFileType();
6565
    final Processor<String> processor;
6666
M src/main/java/com/keenwrite/processors/VariableProcessor.java
8888
  @Override
8989
  public String apply( final String text ) {
90
    assert text != null;
91
9092
    return replace( text, getDefinitions() );
9193
  }
...
100102
   */
101103
  protected Map<String, String> entoken( final Map<String, String> map ) {
104
    assert map != null;
105
102106
    final var result = new HashMap<String, String>( map.size() );
103107
M src/main/java/com/keenwrite/processors/XhtmlProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
22
package com.keenwrite.processors;
33
44
import com.keenwrite.dom.DocumentParser;
5
import com.keenwrite.io.MediaTypeExtension;
56
import com.keenwrite.ui.heuristics.WordCounter;
7
import com.keenwrite.util.DataTypeConverter;
68
import com.whitemagicsoftware.keenquotes.parser.Contractions;
79
import com.whitemagicsoftware.keenquotes.parser.Curler;
...
1517
import java.util.Map;
1618
17
import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE;
19
import static com.keenwrite.Bootstrap.APP_TITLE_ABBR;
1820
import static com.keenwrite.dom.DocumentParser.createMeta;
1921
import static com.keenwrite.dom.DocumentParser.visit;
2022
import static com.keenwrite.events.StatusEvent.clue;
21
import static com.keenwrite.io.HttpFacade.httpGet;
23
import static com.keenwrite.io.downloads.DownloadManager.open;
2224
import static com.keenwrite.util.ProtocolScheme.getProtocol;
2325
import static com.whitemagicsoftware.keenquotes.lex.FilterType.FILTER_XML;
...
8183
8284
          if( attr != null ) {
83
            final var imageFile = exportImage( attr.getTextContent() );
85
            final var src = attr.getTextContent();
86
            final Path location;
87
            final Path imagesDir;
8488
85
            attr.setTextContent( imageFile.toString() );
89
            // Download into a cache directory, which can be written to without
90
            // any possibility of overwriting local image files. Further, the
91
            // filenames are hashed as a second layer of protection.
92
            if( getProtocol( src ).isRemote() ) {
93
              location = downloadImage( src );
94
              imagesDir = getCachesPath();
95
            }
96
            else {
97
              location = resolveImage( src );
98
              imagesDir = getImagesPath();
99
            }
100
101
            final var relative = imagesDir.relativize( location );
102
103
            attr.setTextContent( relative.toString() );
86104
          }
87105
        } catch( final Exception ex ) {
...
158176
159177
  /**
160
   * For a given src URI, this method will attempt to normalize it such that a
161
   * third-party application can find the file. Normalization could entail
162
   * downloading from the Internet or finding a suitable file name extension.
178
   * Hashes the URL so that the number of files doesn't eat up disk space
179
   * over time. For static resources, a feature could be added to prevent
180
   * downloading the URL if the hashed filename already exists.
163181
   *
164
   * @param src A path, local or remote, to a partial or complete file name.
165
   * @return A local file system path to the source path.
166
   * @throws Exception Could not read from, write to, or find a file.
182
   * @param src The source file's URL to download.
183
   * @return A {@link Path} to the local file containing the URL's contents.
184
   * @throws Exception Could not download or save the file.
167185
   */
168
  private Path exportImage( final String src ) throws Exception {
169
    return getProtocol( src ).isRemote()
170
      ? downloadImage( src )
171
      : resolveImage( src );
172
  }
173
174186
  private Path downloadImage( final String src ) throws Exception {
175187
    final Path imageFile;
188
    final var cachesPath = getCachesPath();
176189
177190
    clue( "Main.status.image.xhtml.image.download", src );
178191
179
    try( final var response = httpGet( src ) ) {
192
    try( final var response = open( src ) ) {
180193
      final var mediaType = response.getMediaType();
181194
182
      // Preserve image files if autoclean is turned off.
183
      imageFile = mediaType.createTempFile( APP_TITLE_LOWERCASE, autoclean() );
195
      final var ext = MediaTypeExtension.valueFrom( mediaType ).getExtension();
196
      final var hash = DataTypeConverter.toHex( DataTypeConverter.hash( src ) );
197
      final var id = hash.toLowerCase();
198
199
      imageFile = cachesPath.resolve( APP_TITLE_ABBR + id + '.' + ext );
200
201
      // Preserve image files if auto-remove is turned off.
202
      if( autoRemove() ) {
203
        imageFile.toFile().deleteOnExit();
204
      }
184205
185206
      try( final var image = response.getInputStream() ) {
...
196217
197218
  private Path resolveImage( final String src ) throws Exception {
198
    var imagePath = getImagePath();
219
    var imagePath = getImagesPath();
199220
    var found = false;
200221
201222
    Path imageFile = null;
202223
203224
    clue( "Main.status.image.xhtml.image.resolve", src );
204225
205226
    for( final var extension : getImageOrder() ) {
206227
      final var filename = format(
207228
        "%s%s%s", src, extension.isBlank() ? "" : ".", extension );
208
      imageFile = Path.of( imagePath, filename );
229
      imageFile = imagePath.resolve( filename );
209230
210231
      if( imageFile.toFile().exists() ) {
211232
        found = true;
212233
        break;
213234
      }
214235
    }
215236
216237
    if( !found ) {
217
      imagePath = getDocumentDir().toString();
218
      imageFile = Path.of( imagePath, src );
238
      imagePath = getDocumentDir();
239
      imageFile = imagePath.resolve( src );
219240
220241
      if( !imageFile.toFile().exists() ) {
...
231252
  }
232253
233
  private String getImagePath() {
234
    return mContext.getImageDir().toString();
254
  private Path getImagesPath() {
255
    return mContext.getImagesPath();
256
  }
257
258
  private Path getCachesPath() {
259
    return mContext.getCachesPath();
235260
  }
236261
...
261286
  }
262287
263
  private boolean autoclean() {
264
    return mContext.getAutoClean();
288
  private boolean autoRemove() {
289
    return mContext.getAutoRemove();
265290
  }
266291
M src/main/java/com/keenwrite/processors/markdown/MarkdownProcessor.java
5858
  @Override
5959
  List<Extension> createExtensions( final ProcessorContext context ) {
60
    final var inputPath = context.getInputPath();
60
    final var inputPath = context.getSourcePath();
6161
    final var mediaType = MediaType.valueFrom( inputPath );
6262
    final Processor<String> processor;
M src/main/java/com/keenwrite/processors/markdown/extensions/ImageLinkExtension.java
9898
      final var fqfn = Path.of( baseDir.toString(), uri ).toFile();
9999
100
      if( fqfn.isFile() && fqfn.canRead() ) {
101
        return valid( link, uri );
102
      }
103
104
      if( mContext.getExportFormat() != NONE ) {
100
      if( fqfn.isFile() && fqfn.canRead() ||
101
        mContext.getExportFormat() != NONE ) {
105102
        return valid( link, uri );
106103
      }
...
139136
140137
    private Path getImageDir() {
141
      return mContext.getImageDir();
138
      return mContext.getImagesPath();
142139
    }
143140
M src/main/java/com/keenwrite/processors/text/AbstractTextReplacer.java
1111
   * Optimization: Cache keys until the map changes.
1212
   */
13
  private String[] mKeys;
13
  private String[] mKeys = new String[ 0 ];
1414
1515
  /**
1616
   * Optimization: Cache values until the map changes.
1717
   */
18
  private String[] mValues;
18
  private String[] mValues = new String[ 0 ];
1919
2020
  /**
2121
   * Optimization: Detect when the map changes.
2222
   */
2323
  private int mMapHash;
24
25
  private final Object mMutex = new Object();
2426
2527
  /**
2628
   * Default (empty) constructor.
2729
   */
28
  protected AbstractTextReplacer() {
29
  }
30
  protected AbstractTextReplacer() { }
3031
3132
  protected String[] keys( final Map<String, String> map ) {
32
    updateCache( map );
33
34
    return mKeys;
33
    synchronized( mMutex ) {
34
      updateCache( map );
35
      return mKeys;
36
    }
3537
  }
3638
3739
  protected String[] values( final Map<String, String> map ) {
38
    updateCache( map );
39
40
    return mValues;
40
    synchronized( mMutex ) {
41
      updateCache( map );
42
      return mValues;
43
    }
4144
  }
4245
4346
  private void updateCache( final Map<String, String> map ) {
44
    if( map.hashCode() != mMapHash ) {
45
      mKeys = map.keySet().toArray( new String[ 0 ] );
46
      mValues = map.values().toArray( new String[ 0 ] );
47
      mMapHash = map.hashCode();
47
    synchronized( mMutex ) {
48
      if( map.hashCode() != mMapHash ) {
49
        mKeys = map.keySet().toArray( new String[ 0 ] );
50
        mValues = map.values().toArray( new String[ 0 ] );
51
        mMapHash = map.hashCode();
52
      }
4853
    }
4954
  }
M src/main/java/com/keenwrite/processors/text/AhoCorasickReplacer.java
1818
  @Override
1919
  public String replace( final String text, final Map<String, String> map ) {
20
    assert text != null;
21
    assert map != null;
22
2023
    // Create a buffer sufficiently large that re-allocations are minimized.
2124
    final var sb = new StringBuilder( (int) (text.length() * 1.25) );
2225
2326
    // Definition names cannot overlap.
2427
    final var builder = builder().ignoreOverlaps();
25
    builder.addKeywords( keys( map ) );
28
    final var keySet = keys( map );
29
30
    if( keySet != null ) {
31
      builder.addKeywords( keys( map ) );
32
    }
2633
2734
    int index = 0;
M src/main/java/com/keenwrite/processors/text/TextReplacementFactory.java
3737
  public static String replace(
3838
    final String haystack, final Map<String, String> needles ) {
39
    assert haystack != null;
40
    assert needles != null;
41
3942
    return getTextReplacer( haystack.length() ).replace( haystack, needles );
4043
  }
A src/main/java/com/keenwrite/typesetting/GuestTypesetter.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting;
3
4
import com.keenwrite.io.CommandNotFoundException;
5
import com.keenwrite.io.StreamGobbler;
6
import com.keenwrite.typesetting.containerization.Podman;
7
import org.apache.commons.io.FilenameUtils;
8
9
import java.nio.file.Path;
10
import java.util.LinkedList;
11
import java.util.concurrent.Callable;
12
13
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
14
import static com.keenwrite.io.StreamGobbler.gobble;
15
import static com.keenwrite.typesetting.containerization.Podman.MANAGER;
16
import static java.lang.String.format;
17
18
/**
19
 * Responsible for invoking a typesetter installed inside a container.
20
 */
21
public final class GuestTypesetter extends Typesetter
22
  implements Callable<Boolean> {
23
  private static final String SOURCE = "/root/source";
24
  private static final String TARGET = "/root/target";
25
  private static final String THEMES = "/root/themes";
26
  private static final String IMAGES = "/root/images";
27
  private static final String CACHES = "/root/caches";
28
  private static final String FONTS = "/root/fonts";
29
30
  private static final boolean READONLY = true;
31
  private static final boolean READWRITE = false;
32
33
  private static final String TYPESETTER_VERSION =
34
    TYPESETTER_EXE + " --version > /dev/null";
35
36
  public GuestTypesetter( final Mutator mutator ) {
37
    super( mutator );
38
  }
39
40
  @Override
41
  public Boolean call() throws Exception {
42
    final var sourcePath = getSourcePath();
43
    final var targetPath = getTargetPath();
44
    final var themesPath = getThemesPath();
45
46
    final var sourceDir = normalize( sourcePath.getParent() );
47
    final var targetDir = normalize( targetPath.getParent() );
48
    final var themesDir = normalize( themesPath.getParent() );
49
    final var imagesDir = normalize( getImagesPath() );
50
    final var cachesDir = normalize( getCachesPath() );
51
    final var fontsDir = normalize( getFontsPath() );
52
53
    final var sourceFile = sourcePath.getFileName();
54
    final var targetFile = targetPath.getFileName();
55
    final var themesFile = themesPath.getFileName();
56
57
    final var manager = new Podman();
58
    manager.mount( sourceDir, SOURCE, READONLY );
59
    manager.mount( targetDir, TARGET, READWRITE );
60
    manager.mount( themesDir, THEMES, READONLY );
61
    manager.mount( imagesDir, IMAGES, READONLY );
62
    manager.mount( cachesDir, CACHES, READWRITE );
63
    manager.mount( fontsDir, FONTS, READONLY );
64
65
    final var args = new LinkedList<String>();
66
    args.add( TYPESETTER_EXE );
67
    args.addAll( commonOptions() );
68
    args.add( format(
69
      "--arguments=themesdir=%s/%s,imagesdir=%s,cachesdir=%s",
70
      THEMES, themesFile, IMAGES, CACHES
71
    ) );
72
    args.add( format( "--path='%s/%s'", THEMES, themesFile ) );
73
    args.add( format( "--result='%s'", removeExtension( targetFile ) ) );
74
    args.add( format( "%s/%s", SOURCE, sourceFile ) );
75
76
    final var listener = new PaginationListener();
77
    final var command = String.join( " ", args );
78
79
    manager.run( in -> StreamGobbler.gobble( in, listener ), command );
80
81
    return true;
82
  }
83
84
  /**
85
   * If the path doesn't exist right before typesetting, switch the path
86
   * to the user's home directory to increase the odds of the typesetter
87
   * succeeding. This could help, for example, if the images directory was
88
   * deleted or moved.
89
   *
90
   * @param path The path to verify existence.
91
   * @return The given path, if it exists, otherwise the user's home directory.
92
   */
93
  private static Path normalize( final Path path ) {
94
    assert path != null;
95
96
    return path.toFile().exists()
97
      ? path
98
      : USER_DIRECTORY.toPath();
99
  }
100
101
  static String removeExtension( final Path path ) {
102
    return FilenameUtils.removeExtension( path.toString() );
103
  }
104
105
  /**
106
   * @return {@code true} indicates that the containerized typesetter is
107
   * installed, properly configured, and ready to typeset documents.
108
   */
109
  static boolean isReady() {
110
    if( MANAGER.canRun() ) {
111
      final var exitCode = new StringBuilder();
112
      final var manager = new Podman();
113
114
      try {
115
        // Running blocks until the command completes.
116
        manager.run(
117
          input -> gobble( input, s -> exitCode.append( s.trim() ) ),
118
          TYPESETTER_VERSION + "; echo $?"
119
        );
120
121
        // If the typesetter ran with an exit code of 0, it is available.
122
        return exitCode.indexOf( "0" ) == 0;
123
      } catch( final CommandNotFoundException ignored ) { }
124
    }
125
126
    return false;
127
  }
128
}
1129
A src/main/java/com/keenwrite/typesetting/HostTypesetter.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting;
3
4
import com.keenwrite.collections.CircularQueue;
5
import com.keenwrite.io.StreamGobbler;
6
import com.keenwrite.io.SysFile;
7
8
import java.io.FileNotFoundException;
9
import java.io.IOException;
10
import java.nio.file.NoSuchFileException;
11
import java.nio.file.Path;
12
import java.util.ArrayList;
13
import java.util.List;
14
import java.util.concurrent.Callable;
15
16
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
17
import static com.keenwrite.events.StatusEvent.clue;
18
import static java.lang.ProcessBuilder.Redirect.DISCARD;
19
import static java.lang.System.getProperty;
20
import static java.nio.file.Files.*;
21
import static java.util.Arrays.asList;
22
import static java.util.concurrent.TimeUnit.SECONDS;
23
import static org.apache.commons.io.FilenameUtils.removeExtension;
24
25
/**
26
 * Responsible for invoking an executable to typeset text. This will
27
 * construct suitable command-line arguments to invoke the typesetting engine.
28
 * This uses a version of the typesetter installed on the host system.
29
 */
30
public final class HostTypesetter extends Typesetter
31
  implements Callable<Boolean> {
32
  private static final SysFile TYPESETTER = new SysFile( TYPESETTER_EXE );
33
34
  HostTypesetter( final Mutator mutator ) {
35
    super( mutator );
36
  }
37
38
  /**
39
   * Answers whether the typesetting software is installed locally.
40
   *
41
   * @return {@code true} if the typesetting software is installed on the host.
42
   */
43
  public static boolean isReady() {
44
    return TYPESETTER.canRun();
45
  }
46
47
  /**
48
   * Launches a task to typeset a document.
49
   */
50
  private class TypesetTask implements Callable<Boolean> {
51
    private final List<String> mArgs = new ArrayList<>();
52
53
    /**
54
     * Working directory must be set because ConTeXt cannot write the
55
     * result to an arbitrary location.
56
     */
57
    private final Path mDirectory;
58
59
    private TypesetTask() {
60
      final var parentDir = getTargetPath().getParent();
61
      mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
62
    }
63
64
    /**
65
     * Initializes ConTeXt, which means creating the cache directory if it
66
     * doesn't already exist. The theme entry point must be named 'main.tex'.
67
     *
68
     * @return {@code true} if the cache directory exists.
69
     */
70
    private boolean reinitialize() {
71
      final var cacheExists = !isEmpty( getCacheDir().toPath() );
72
73
      // Ensure invoking multiple times will load the correct arguments.
74
      mArgs.clear();
75
      mArgs.add( TYPESETTER_EXE );
76
77
      if( cacheExists ) {
78
        mArgs.addAll( options() );
79
80
        final var sb = new StringBuilder( 128 );
81
        mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
82
        clue( sb.toString() );
83
      }
84
      else {
85
        mArgs.add( "--generate" );
86
      }
87
88
      return cacheExists;
89
    }
90
91
    /**
92
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first
93
     * try. If the cache directory doesn't exist, attempt to create it, then
94
     * call ConTeXt to generate the PDF. This is brittle because if the
95
     * directory is empty, or not populated with cached data, a false positive
96
     * will be returned, resulting in no PDF being created.
97
     *
98
     * @return {@code true} if the document was typeset successfully.
99
     * @throws IOException          If the process could not be started.
100
     * @throws InterruptedException If the process was killed.
101
     */
102
    private boolean typeset() throws IOException, InterruptedException {
103
      return reinitialize() ? call() : call() && reinitialize() && call();
104
    }
105
106
    @Override
107
    public Boolean call() throws IOException, InterruptedException {
108
      final var stdout = new CircularQueue<String>( 150 );
109
      final var builder = new ProcessBuilder( mArgs );
110
      builder.directory( mDirectory.toFile() );
111
      builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
112
113
      // Without redirecting (or draining) stderr, the command may not
114
      // terminate successfully.
115
      builder.redirectError( DISCARD );
116
117
      final var process = builder.start();
118
      final var listener = new PaginationListener();
119
120
      // Slurp page numbers in a separate thread while typesetting.
121
      StreamGobbler.gobble( process.getInputStream(), line -> {
122
        listener.accept( line );
123
        stdout.add( line );
124
      } );
125
126
      // Even though the process has completed, there may be incomplete I/O.
127
      process.waitFor();
128
129
      // Allow time for any incomplete I/O to take place.
130
      process.waitFor( 1, SECONDS );
131
132
      final var exit = process.exitValue();
133
      process.destroy();
134
135
      // If there was an error, the typesetter will leave behind log, pdf, and
136
      // error files.
137
      if( exit > 0 ) {
138
        final var xmlName = getSourcePath().getFileName().toString();
139
        final var srcName = getTargetPath().getFileName().toString();
140
        final var logName = newExtension( xmlName, ".log" );
141
        final var errName = newExtension( xmlName, "-error.log" );
142
        final var pdfName = newExtension( xmlName, ".pdf" );
143
        final var tuaName = newExtension( xmlName, ".tua" );
144
        final var badName = newExtension( srcName, ".log" );
145
146
        log( badName );
147
        log( logName );
148
        log( errName );
149
        log( stdout.stream().toList() );
150
151
        // Users may opt to keep these files around for debugging purposes.
152
        if( autoRemove() ) {
153
          deleteIfExists( logName );
154
          deleteIfExists( errName );
155
          deleteIfExists( pdfName );
156
          deleteIfExists( badName );
157
          deleteIfExists( tuaName );
158
        }
159
      }
160
161
      // Exit value for a successful invocation of the typesetter. This value
162
      // is returned when creating the cache on the first run as well as
163
      // creating PDFs on subsequent runs (after the cache has been created).
164
      // Users don't care about exit codes, only whether the PDF was generated.
165
      return exit == 0;
166
    }
167
168
    private Path newExtension( final String baseName, final String ext ) {
169
      final var path = getTargetPath();
170
      return path.resolveSibling( removeExtension( baseName ) + ext );
171
    }
172
173
    /**
174
     * Fires a status message for each line in the given file. The file format
175
     * is somewhat machine-readable, but no effort beyond line splitting is
176
     * made to parse the text.
177
     *
178
     * @param path Path to the file containing error messages.
179
     */
180
    private void log( final Path path ) throws IOException {
181
      if( exists( path ) ) {
182
        log( readAllLines( path ) );
183
      }
184
    }
185
186
    private void log( final List<String> lines ) {
187
      final var splits = new ArrayList<String>( lines.size() * 2 );
188
189
      for( final var line : lines ) {
190
        splits.addAll( asList( line.split( "\\\\n" ) ) );
191
      }
192
193
      clue( splits );
194
    }
195
196
    /**
197
     * Returns the location of the cache directory.
198
     *
199
     * @return A fully qualified path to the location to store temporary
200
     * files between typesetting runs.
201
     */
202
    @SuppressWarnings( "SpellCheckingInspection" )
203
    private java.io.File getCacheDir() {
204
      final var temp = getProperty( "java.io.tmpdir" );
205
      final var cache = Path.of( temp, "luatex-cache" );
206
      return cache.toFile();
207
    }
208
209
    /**
210
     * Answers whether the given directory is empty. The typesetting software
211
     * creates a non-empty directory by default. The return value from this
212
     * method is a proxy to answering whether the typesetter has been run for
213
     * the first time or not.
214
     *
215
     * @param path The directory to check for emptiness.
216
     * @return {@code true} if the directory is empty.
217
     */
218
    private boolean isEmpty( final Path path ) {
219
      try( final var stream = newDirectoryStream( path ) ) {
220
        return !stream.iterator().hasNext();
221
      } catch( final NoSuchFileException | FileNotFoundException ex ) {
222
        // A missing directory means it doesn't exist, ergo is empty.
223
        return true;
224
      } catch( final IOException ex ) {
225
        throw new RuntimeException( ex );
226
      }
227
    }
228
  }
229
230
  /**
231
   * This will typeset the document using a new process. The return value only
232
   * indicates whether the typesetter exists, not whether the typesetting was
233
   * successful. The typesetter must be known to exist prior to calling this
234
   * method.
235
   *
236
   * @throws IOException                 If the process could not be started.
237
   * @throws InterruptedException        If the process was killed.
238
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
239
   */
240
  @Override
241
  public Boolean call()
242
    throws IOException, InterruptedException, TypesetterNotFoundException {
243
    final var task = new HostTypesetter.TypesetTask();
244
    return task.typeset();
245
  }
246
}
1247
A src/main/java/com/keenwrite/typesetting/PaginationListener.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting;
3
4
import java.util.Scanner;
5
import java.util.function.Consumer;
6
import java.util.regex.Pattern;
7
8
import static com.keenwrite.events.StatusEvent.clue;
9
10
/**
11
 * Responsible for parsing the output from the typesetting engine and
12
 * updating the status bar to provide assurance that typesetting is
13
 * executing.
14
 *
15
 * <p>
16
 * Example lines written to standard output:
17
 * </p>
18
 * <pre>{@code
19
 * pages           > flushing realpage 15, userpage 15, subpage 15
20
 * pages           > flushing realpage 16, userpage 16, subpage 16
21
 * pages           > flushing realpage 1, userpage 1, subpage 1
22
 * pages           > flushing realpage 2, userpage 2, subpage 2
23
 * }</pre>
24
 * <p>
25
 * The lines are parsed; the first number is displayed as a status bar
26
 * message.
27
 * </p>
28
 */
29
class PaginationListener implements Consumer<String> {
30
  private static final Pattern DIGITS = Pattern.compile( "\\D+" );
31
32
  private int mPageCount = 1;
33
  private int mPassCount = 1;
34
  private int mPageTotal = 0;
35
36
  public PaginationListener() { }
37
38
  @Override
39
  public void accept( final String line ) {
40
    if( line.startsWith( "pages" ) ) {
41
      final var scanner = new Scanner( line ).useDelimiter( DIGITS );
42
      final var digits = scanner.next();
43
      final var page = Integer.parseInt( digits );
44
45
      // If the page number is less than the previous page count, it
46
      // means that the typesetting engine has started another pass.
47
      if( page < mPageCount ) {
48
        mPassCount++;
49
        mPageTotal = mPageCount;
50
      }
51
52
      mPageCount = page;
53
54
      // Inform the user of pages being typeset.
55
      clue( "Main.status.typeset.page",
56
            mPageCount, mPageTotal < 1 ? "?" : mPageTotal, mPassCount
57
      );
58
    }
59
  }
60
}
161
M src/main/java/com/keenwrite/typesetting/Typesetter.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting;
3
4
import com.keenwrite.collections.BoundedCache;
5
import com.keenwrite.io.SysFile;
6
import com.keenwrite.util.GenericBuilder;
7
8
import java.io.*;
9
import java.nio.file.NoSuchFileException;
10
import java.nio.file.Path;
11
import java.util.ArrayList;
12
import java.util.List;
13
import java.util.Map;
14
import java.util.Scanner;
15
import java.util.concurrent.Callable;
16
import java.util.regex.Pattern;
17
18
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
19
import static com.keenwrite.events.StatusEvent.clue;
20
import static java.lang.ProcessBuilder.Redirect.DISCARD;
21
import static java.lang.String.format;
22
import static java.lang.System.currentTimeMillis;
23
import static java.lang.System.getProperty;
24
import static java.nio.file.Files.*;
25
import static java.util.Arrays.asList;
26
import static java.util.concurrent.TimeUnit.*;
27
import static org.apache.commons.io.FilenameUtils.removeExtension;
28
29
/**
30
 * Responsible for invoking an executable to typeset text. This will
31
 * construct suitable command-line arguments to invoke the typesetting engine.
32
 */
33
public class Typesetter {
34
  private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
35
36
  private final Mutator mMutator;
37
38
  public static GenericBuilder<Mutator, Typesetter> builder() {
39
    return GenericBuilder.of( Mutator::new, Typesetter::new );
40
  }
41
42
  public static final class Mutator {
43
    private Path mInputPath;
44
    private Path mOutputPath;
45
    private Path mThemePath;
46
    private boolean mAutoClean;
47
48
    /**
49
     * @param inputPath The input document to typeset.
50
     */
51
    public void setInputPath( final Path inputPath ) {
52
      mInputPath = inputPath;
53
    }
54
55
    /**
56
     * @param outputPath Path to the finished typeset document to create.
57
     */
58
    public void setOutputPath( final Path outputPath ) {
59
      mOutputPath = outputPath;
60
    }
61
62
    /**
63
     * @param themePath Fully qualified path to the theme directory, which
64
     *                  ends with the selected theme name.
65
     */
66
    public void setThemePath( final Path themePath ) {
67
      mThemePath = themePath;
68
    }
69
70
    /**
71
     * @param autoClean {@code true} to remove all temporary files after
72
     *                  typesetter produces a PDF file.
73
     */
74
    public void setAutoClean( final boolean autoClean ) {
75
      mAutoClean = autoClean;
76
    }
77
  }
78
79
  public static boolean canRun() {
80
    return TYPESETTER.canRun();
81
  }
82
83
  /**
84
   * Calculates the time that has elapsed from the current time to the
85
   * given moment in time.
86
   *
87
   * @param start The starting time, which should be before the current time.
88
   * @return A human-readable formatted time.
89
   * @see #asElapsed(long)
90
   */
91
  private static String since( final long start ) {
92
    return asElapsed( currentTimeMillis() - start );
93
  }
94
95
  /**
96
   * Converts an elapsed time to a human-readable format (hours, minutes,
97
   * seconds, and milliseconds).
98
   *
99
   * @param elapsed An elapsed time, in milliseconds.
100
   * @return Human-readable elapsed time.
101
   */
102
  private static String asElapsed( final long elapsed ) {
103
    final var hours = MILLISECONDS.toHours( elapsed );
104
    final var eHours = elapsed - HOURS.toMillis( hours );
105
    final var minutes = MILLISECONDS.toMinutes( eHours );
106
    final var eMinutes = eHours - MINUTES.toMillis( minutes );
107
    final var seconds = MILLISECONDS.toSeconds( eMinutes );
108
    final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
109
    final var milliseconds = MILLISECONDS.toMillis( eSeconds );
110
111
    return format( "%02d:%02d:%02d.%03d",
112
                   hours, minutes, seconds, milliseconds );
113
  }
114
115
  /**
116
   * Launches a task to typeset a document.
117
   */
118
  private class TypesetTask implements Callable<Boolean> {
119
    private final List<String> mArgs = new ArrayList<>();
120
121
    /**
122
     * Working directory must be set because ConTeXt cannot write the
123
     * result to an arbitrary location.
124
     */
125
    private final Path mDirectory;
126
127
    private TypesetTask() {
128
      final var parentDir = getOutputPath().getParent();
129
      mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
130
    }
131
132
    /**
133
     * Initializes ConTeXt, which means creating the cache directory if it
134
     * doesn't already exist. The theme entry point must be named 'main.tex'.
135
     *
136
     * @return {@code true} if the cache directory exists.
137
     */
138
    private boolean reinitialize() {
139
      final var filename = getOutputPath().getFileName();
140
      final var theme = getThemePath();
141
      final var cacheExists = !isEmpty( getCacheDir().toPath() );
142
143
      // Ensure invoking multiple times will load the correct arguments.
144
      mArgs.clear();
145
      mArgs.add( TYPESETTER.getName() );
146
147
      if( cacheExists ) {
148
        mArgs.add( "--autogenerate" );
149
        mArgs.add( "--script" );
150
        mArgs.add( "mtx-context" );
151
        mArgs.add( "--batchmode" );
152
        mArgs.add( "--nonstopmode" );
153
        mArgs.add( "--purgeall" );
154
        mArgs.add( "--path='" + theme + "'" );
155
        mArgs.add( "--environment='main'" );
156
        mArgs.add( "--result='" + filename + "'" );
157
        mArgs.add( getInputPath().toString() );
158
159
        final var sb = new StringBuilder( 128 );
160
        mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
161
        clue( sb.toString() );
162
      }
163
      else {
164
        mArgs.add( "--generate" );
165
      }
166
167
      return cacheExists;
168
    }
169
170
    /**
171
     * Setting {@code TEXMFCACHE} when run on a fresh system fails on the first
172
     * try. If the cache directory doesn't exist, attempt to create it, then
173
     * call ConTeXt to generate the PDF. This is brittle because if the
174
     * directory is empty, or not populated with cached data, a false positive
175
     * will be returned, resulting in no PDF being created.
176
     *
177
     * @return {@code true} if the document was typeset successfully.
178
     * @throws IOException          If the process could not be started.
179
     * @throws InterruptedException If the process was killed.
180
     */
181
    private boolean typeset() throws IOException, InterruptedException {
182
      return reinitialize() ? call() : call() && reinitialize() && call();
183
    }
184
185
    @Override
186
    public Boolean call() throws IOException, InterruptedException {
187
      final var stdout = new BoundedCache<String, String>( 150 );
188
      final var builder = new ProcessBuilder( mArgs );
189
      builder.directory( mDirectory.toFile() );
190
      builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
191
192
      // Without redirecting (or draining) stderr, the command may not
193
      // terminate successfully.
194
      builder.redirectError( DISCARD );
195
196
      final var process = builder.start();
197
      final var stream = process.getInputStream();
198
199
      // Reading from stdout allows slurping page numbers while generating.
200
      final var listener = new PaginationListener( stream, stdout );
201
      listener.start();
202
203
      // Even though the process has completed, there may be incomplete I/O.
204
      process.waitFor();
205
206
      // Allow time for any incomplete I/O to take place.
207
      process.waitFor( 1, SECONDS );
208
209
      final var exit = process.exitValue();
210
      process.destroy();
211
212
      // If there was an error, the typesetter will leave behind log, pdf, and
213
      // error files.
214
      if( exit > 0 ) {
215
        final var xmlName = getInputPath().getFileName().toString();
216
        final var srcName = getOutputPath().getFileName().toString();
217
        final var logName = newExtension( xmlName, ".log" );
218
        final var errName = newExtension( xmlName, "-error.log" );
219
        final var pdfName = newExtension( xmlName, ".pdf" );
220
        final var tuaName = newExtension( xmlName, ".tua" );
221
        final var badName = newExtension( srcName, ".log" );
222
223
        log( badName );
224
        log( logName );
225
        log( errName );
226
        log( stdout.keySet().stream().toList() );
227
228
        // Users may opt to keep these files around for debugging purposes.
229
        if( autoclean() ) {
230
          deleteIfExists( logName );
231
          deleteIfExists( errName );
232
          deleteIfExists( pdfName );
233
          deleteIfExists( badName );
234
          deleteIfExists( tuaName );
235
        }
236
      }
237
238
      // Exit value for a successful invocation of the typesetter. This value
239
      // is returned when creating the cache on the first run as well as
240
      // creating PDFs on subsequent runs (after the cache has been created).
241
      // Users don't care about exit codes, only whether the PDF was generated.
242
      return exit == 0;
243
    }
244
245
    private Path newExtension( final String baseName, final String ext ) {
246
      final var path = getOutputPath();
247
      return path.resolveSibling( removeExtension( baseName ) + ext );
248
    }
249
250
    /**
251
     * Fires a status message for each line in the given file. The file format
252
     * is somewhat machine-readable, but no effort beyond line splitting is
253
     * made to parse the text.
254
     *
255
     * @param path Path to the file containing error messages.
256
     */
257
    private void log( final Path path ) throws IOException {
258
      if( exists( path ) ) {
259
        log( readAllLines( path ) );
260
      }
261
    }
262
263
    private void log( final List<String> lines ) {
264
      final var splits = new ArrayList<String>( lines.size() * 2 );
265
266
      for( final var line : lines ) {
267
        splits.addAll( asList( line.split( "\\\\n" ) ) );
268
      }
269
270
      clue( splits );
271
    }
272
273
    /**
274
     * Returns the location of the cache directory.
275
     *
276
     * @return A fully qualified path to the location to store temporary
277
     * files between typesetting runs.
278
     */
279
    private java.io.File getCacheDir() {
280
      final var temp = getProperty( "java.io.tmpdir" );
281
      final var cache = Path.of( temp, "luatex-cache" );
282
      return cache.toFile();
283
    }
284
285
    /**
286
     * Answers whether the given directory is empty. The typesetting software
287
     * creates a non-empty directory by default. The return value from this
288
     * method is a proxy to answering whether the typesetter has been run for
289
     * the first time or not.
290
     *
291
     * @param path The directory to check for emptiness.
292
     * @return {@code true} if the directory is empty.
293
     */
294
    private boolean isEmpty( final Path path ) {
295
      try( final var stream = newDirectoryStream( path ) ) {
296
        return !stream.iterator().hasNext();
297
      } catch( final NoSuchFileException | FileNotFoundException ex ) {
298
        // A missing directory means it doesn't exist, ergo is empty.
299
        return true;
300
      } catch( final IOException ex ) {
301
        throw new RuntimeException( ex );
302
      }
303
    }
304
  }
305
306
  /**
307
   * Responsible for parsing the output from the typesetting engine and
308
   * updating the status bar to provide assurance that typesetting is
309
   * executing.
310
   *
311
   * <p>
312
   * Example lines written to standard output:
313
   * </p>
314
   * <pre>{@code
315
   * pages           > flushing realpage 15, userpage 15, subpage 15
316
   * pages           > flushing realpage 16, userpage 16, subpage 16
317
   * pages           > flushing realpage 1, userpage 1, subpage 1
318
   * pages           > flushing realpage 2, userpage 2, subpage 2
319
   * }</pre>
320
   * <p>
321
   * The lines are parsed; the first number is displayed as a status bar
322
   * message.
323
   * </p>
324
   */
325
  private static class PaginationListener extends Thread {
326
    private static final Pattern DIGITS = Pattern.compile( "\\D+" );
327
328
    private final InputStream mInputStream;
329
330
    private final Map<String, String> mCache;
331
332
    public PaginationListener(
333
      final InputStream in, final Map<String, String> cache ) {
334
      mInputStream = in;
335
      mCache = cache;
336
    }
337
338
    @Override
339
    public void run() {
340
      try( final var reader = createReader( mInputStream ) ) {
341
        int pageCount = 1;
342
        int passCount = 1;
343
        int pageTotal = 0;
344
        String line;
345
346
        while( (line = reader.readLine()) != null ) {
347
          mCache.put( line, "" );
348
349
          if( line.startsWith( "pages" ) ) {
350
            // The bottleneck will be the typesetting engine writing to stdout,
351
            // not the parsing of stdout.
352
            final var scanner = new Scanner( line ).useDelimiter( DIGITS );
353
            final var digits = scanner.next();
354
            final var page = Integer.parseInt( digits );
355
356
            // If the page number is less than the previous page count, it
357
            // means that the typesetting engine has started another pass.
358
            if( page < pageCount ) {
359
              passCount++;
360
              pageTotal = pageCount;
361
            }
362
363
            pageCount = page;
364
365
            // Inform the user of pages being typeset.
366
            clue( "Main.status.typeset.page",
367
                  pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
368
            );
369
          }
370
        }
371
      } catch( final IOException ex ) {
372
        clue( ex );
373
        throw new RuntimeException( ex );
374
      }
375
    }
376
377
    private BufferedReader createReader( final InputStream inputStream ) {
378
      return new BufferedReader( new InputStreamReader( inputStream ) );
379
    }
380
  }
381
382
  /**
383
   * Creates a new {@link Typesetter} instance capable of configuring the
384
   * typesetter used to generate a typeset document.
385
   */
386
  private Typesetter( final Mutator mutator ) {
387
    assert mutator != null;
388
389
    mMutator = mutator;
390
  }
391
392
  /**
393
   * This will typeset the document using a new process. The return value only
394
   * indicates whether the typesetter exists, not whether the typesetting was
395
   * successful.
396
   *
397
   * @throws IOException                 If the process could not be started.
398
   * @throws InterruptedException        If the process was killed.
399
   * @throws TypesetterNotFoundException When no typesetter is along the PATH.
400
   */
401
  public void typeset()
402
    throws IOException, InterruptedException, TypesetterNotFoundException {
403
    if( TYPESETTER.canRun() ) {
404
      final var outputPath = getOutputPath();
405
406
      clue( "Main.status.typeset.began", outputPath );
407
      final var task = new TypesetTask();
408
      final var time = currentTimeMillis();
409
      final var success = task.typeset();
410
411
      clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
412
            outputPath, since( time )
413
      );
414
    }
415
    else {
416
      throw new TypesetterNotFoundException( TYPESETTER.toString() );
417
    }
418
  }
419
420
  private Path getInputPath() {
421
    return mMutator.mInputPath;
422
  }
423
424
  private Path getOutputPath() {
425
    return mMutator.mOutputPath;
426
  }
427
428
  private Path getThemePath() {
429
    return mMutator.mThemePath;
430
  }
431
432
  /**
433
   * Answers whether logs and other files should be deleted upon error. The
434
   * log files are useful for debugging.
435
   *
436
   * @return {@code true} to delete generated files.
437
   */
438
  public boolean autoclean() {
439
    return mMutator.mAutoClean;
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting;
3
4
import com.keenwrite.util.GenericBuilder;
5
import com.keenwrite.util.Time;
6
7
import java.nio.file.Path;
8
import java.time.Duration;
9
import java.util.LinkedList;
10
import java.util.List;
11
import java.util.concurrent.Callable;
12
13
import static com.keenwrite.events.StatusEvent.clue;
14
import static com.keenwrite.util.Time.toElapsedTime;
15
import static java.lang.String.format;
16
import static java.lang.System.currentTimeMillis;
17
import static java.time.Duration.ofMillis;
18
19
/**
20
 * Responsible for typesetting a document using either a typesetter installed
21
 * on the computer ({@link HostTypesetter} or installed within a container
22
 * ({@link GuestTypesetter}).
23
 */
24
public class Typesetter {
25
  /**
26
   * Name of the executable program that can typeset documents.
27
   */
28
  static final String TYPESETTER_EXE = "mtxrun";
29
30
  public static GenericBuilder<Mutator, Typesetter> builder() {
31
    return GenericBuilder.of( Mutator::new, Typesetter::new );
32
  }
33
34
  public static final class Mutator {
35
    private Path mSourcePath;
36
    private Path mTargetPath;
37
    private Path mThemesPath;
38
    private Path mImagesPath;
39
    private Path mCachesPath;
40
    private Path mFontsPath;
41
    private boolean mAutoRemove;
42
43
    /**
44
     * @param inputPath The input document to typeset.
45
     */
46
    public void setSourcePath( final Path inputPath ) {
47
      mSourcePath = inputPath;
48
    }
49
50
    /**
51
     * @param outputPath Path to the finished typeset document to create.
52
     */
53
    public void setTargetPath( final Path outputPath ) {
54
      mTargetPath = outputPath;
55
    }
56
57
    /**
58
     * @param themePath Fully qualified path to the theme directory, which
59
     *                  ends with the selected theme name.
60
     */
61
    public void setThemesPath( final Path themePath ) {
62
      mThemesPath = themePath;
63
    }
64
65
    /**
66
     * @param imagePath Fully qualified path to the "images" directory.
67
     */
68
    public void setImagesPath( final Path imagePath ) {
69
      mImagesPath = imagePath;
70
    }
71
72
    /**
73
     * @param cachePath Fully qualified path to the "caches" directory.
74
     */
75
    public void setCachesPath( final Path cachePath ) {
76
      mCachesPath = cachePath;
77
    }
78
79
    /**
80
     * @param fontsPath Fully qualified path to the "fonts" directory.
81
     */
82
    public void setFontsPath( final Path fontsPath ) {
83
      mFontsPath = fontsPath;
84
    }
85
86
    /**
87
     * @param remove {@code true} to remove all temporary files after the
88
     *               typesetter produces a PDF file.
89
     */
90
    public void setAutoRemove( final boolean remove ) {
91
      mAutoRemove = remove;
92
    }
93
94
    public Path getSourcePath() {
95
      return mSourcePath;
96
    }
97
98
    public Path getTargetPath() {
99
      return mTargetPath;
100
    }
101
102
    public Path getThemesPath() {
103
      return mThemesPath;
104
    }
105
106
    public Path getImagesPath() {
107
      return mImagesPath;
108
    }
109
110
    public Path getCachesPath() {
111
      return mCachesPath;
112
    }
113
114
    public Path getFontsPath() {
115
      return mFontsPath;
116
    }
117
118
    public boolean isAutoRemove() {
119
      return mAutoRemove;
120
    }
121
  }
122
123
  private final Mutator mMutator;
124
125
  /**
126
   * Creates a new {@link Typesetter} instance capable of configuring the
127
   * typesetter used to generate a typeset document.
128
   */
129
  Typesetter( final Mutator mutator ) {
130
    assert mutator != null;
131
132
    mMutator = mutator;
133
  }
134
135
  public void typeset() throws Exception {
136
    final Callable<Boolean> typesetter;
137
138
    if( HostTypesetter.isReady() ) {
139
      typesetter = new HostTypesetter( mMutator );
140
    }
141
    else if( GuestTypesetter.isReady() ) {
142
      typesetter = new GuestTypesetter( mMutator );
143
    }
144
    else {
145
      throw new TypesetterNotFoundException( TYPESETTER_EXE );
146
    }
147
148
    final var outputPath = getTargetPath();
149
    final var prefix = "Main.status.typeset";
150
151
    clue( prefix + ".began", outputPath );
152
153
    final var time = currentTimeMillis();
154
    final var success = typesetter.call();
155
    final var suffix = success ? ".success" : ".failure";
156
157
    clue( prefix + ".ended" + suffix, outputPath, since( time ) );
158
  }
159
160
  /**
161
   * Generates the command-line arguments used to invoke the typesetter.
162
   */
163
  @SuppressWarnings( "SpellCheckingInspection" )
164
  List<String> options() {
165
    final var args = commonOptions();
166
167
    final var sourcePath = getSourcePath().toString();
168
    final var targetPath = getTargetPath().getFileName();
169
    final var themesPath = getThemesPath();
170
    final var imagesPath = getImagesPath();
171
    final var cachesPath = getCachesPath();
172
173
    args.add(
174
      format( "--arguments=themesdir=%s,imagesdir=%s,cachesdir=%s",
175
              themesPath, imagesPath, cachesPath  )
176
    );
177
    args.add( format( "--path='%s'", themesPath ) );
178
    args.add( format( "--result='%s'", targetPath ) );
179
    args.add( sourcePath );
180
181
    return args;
182
  }
183
184
  @SuppressWarnings( "SpellCheckingInspection" )
185
  List<String> commonOptions() {
186
    final var args = new LinkedList<String>();
187
188
    args.add( "--autogenerate" );
189
    args.add( "--script" );
190
    args.add( "mtx-context" );
191
    args.add( "--batchmode" );
192
    args.add( "--nonstopmode" );
193
    args.add( "--purgeall" );
194
    args.add( "--environment='main'" );
195
196
    return args;
197
  }
198
199
  protected Path getSourcePath() {
200
    return mMutator.getSourcePath();
201
  }
202
203
  protected Path getTargetPath() {
204
    return mMutator.getTargetPath();
205
  }
206
207
  protected Path getThemesPath() {
208
    return mMutator.getThemesPath();
209
  }
210
211
  protected Path getImagesPath() {
212
    return mMutator.getImagesPath();
213
  }
214
215
  protected Path getCachesPath() {
216
    return mMutator.getCachesPath();
217
  }
218
219
  protected Path getFontsPath() {
220
    return mMutator.getFontsPath();
221
  }
222
223
  /**
224
   * Answers whether logs and other files should be deleted upon error. The
225
   * log files are useful for debugging.
226
   *
227
   * @return {@code true} to delete generated files.
228
   */
229
  public boolean autoRemove() {
230
    return mMutator.isAutoRemove();
231
  }
232
233
  public static boolean canRun() {
234
    return hostCanRun() || guestCanRun();
235
  }
236
237
  private static boolean hostCanRun() {
238
    return HostTypesetter.isReady();
239
  }
240
241
  private static boolean guestCanRun() {
242
    return GuestTypesetter.isReady();
243
  }
244
245
  /**
246
   * Calculates the time that has elapsed from the current time to the
247
   * given moment in time.
248
   *
249
   * @param start The starting time, which should be before the current time.
250
   * @return A human-readable formatted time.
251
   * @see Time#toElapsedTime(Duration)
252
   */
253
  private static String since( final long start ) {
254
    return toElapsedTime( ofMillis( currentTimeMillis() - start ) );
440255
  }
441256
}
A src/main/java/com/keenwrite/typesetting/containerization/ContainerManager.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.containerization;
3
4
import com.keenwrite.io.CommandNotFoundException;
5
6
import java.io.File;
7
import java.io.IOException;
8
import java.util.List;
9
10
public interface ContainerManager {
11
  /**
12
   * Installs the container software, in quiet and headless mode if possible.
13
   *
14
   * @param exe The installer binary to run.
15
   * @return The exit code from the installer program, or -1 on failure.
16
   * @throws IOException The container installer could not be run.
17
   */
18
  int install( final File exe )
19
    throws IOException;
20
21
  /**
22
   * Runs preliminary commands against the container before starting.
23
   *
24
   * @param processor Processes the command output (in a separate thread).
25
   * @throws CommandNotFoundException The container executable was not found.
26
   */
27
  void start( StreamProcessor processor ) throws CommandNotFoundException;
28
29
  /**
30
   * Requests that the container manager load an image into the container.
31
   *
32
   * @param name The full container name of the image to pull.
33
   * @param processor Processes the command output (in a separate thread).
34
   * @throws CommandNotFoundException The container executable was not found.
35
   */
36
  void pull( StreamProcessor processor, String name )
37
    throws CommandNotFoundException;
38
39
  /**
40
   * Runs a command using the container manager.
41
   *
42
   * @param processor Processes the command output (in a separate thread).
43
   * @param args      The command and arguments to run.
44
   * @return The exit code returned by the installer program.
45
   * @throws CommandNotFoundException The container executable was not found.
46
   */
47
  int run( StreamProcessor processor, String... args )
48
    throws CommandNotFoundException;
49
50
  /**
51
   * Convenience method to run a command using the container manager.
52
   *
53
   * @see #run(StreamProcessor, String...)
54
   */
55
  default int run( final StreamProcessor listener, final List<String> args )
56
    throws CommandNotFoundException {
57
    return run( listener, toArray( args ) );
58
  }
59
60
  /**
61
   * Convenience method to convert a {@link List} into an array.
62
   *
63
   * @param list The elements to convert to an array.
64
   * @return The converted {@link List}.
65
   */
66
  default String[] toArray( final List<String> list ) {
67
    return list.toArray( new String[ 0 ] );
68
  }
69
}
170
A src/main/java/com/keenwrite/typesetting/containerization/Podman.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.containerization;
3
4
import com.keenwrite.io.CommandNotFoundException;
5
import com.keenwrite.io.SysFile;
6
7
import java.io.File;
8
import java.nio.file.Path;
9
import java.util.LinkedList;
10
import java.util.List;
11
12
import static com.keenwrite.Bootstrap.CONTAINER_VERSION;
13
import static java.lang.String.format;
14
import static java.lang.System.arraycopy;
15
import static java.util.Arrays.copyOf;
16
17
/**
18
 * Provides facilities for interacting with a container environment.
19
 */
20
public final class Podman implements ContainerManager {
21
  public static final SysFile MANAGER = new SysFile( "podman" );
22
  public static final String CONTAINER_SHORTNAME = "typesetter";
23
  public static final String CONTAINER_NAME =
24
    format( "%s:%s", CONTAINER_SHORTNAME, CONTAINER_VERSION );
25
26
  private final List<String> mMountPoints = new LinkedList<>();
27
28
  public Podman() { }
29
30
  @Override
31
  public int install( final File exe ) {
32
    // This monstrosity runs the installer in the background without displaying
33
    // a secondary command window, while blocking until the installer completes
34
    // and an exit code can be determined. I hate Windows.
35
    final var builder = processBuilder(
36
      "cmd", "/c",
37
      format(
38
        "start /b /high /wait cmd /c %s /quiet /install & exit ^!errorlevel^!",
39
        exe.getAbsolutePath()
40
      )
41
    );
42
43
    try {
44
      // Wait for installation to finish (successfully or not).
45
      return wait( builder.start() );
46
    } catch( final Exception ignored ) {
47
      return -1;
48
    }
49
  }
50
51
  @Override
52
  public void start( final StreamProcessor processor )
53
    throws CommandNotFoundException {
54
    machine( processor, "stop" );
55
    podman( processor, "system", "prune", "--force" );
56
    machine( processor, "rm", "--force" );
57
    machine( processor, "init" );
58
    machine( processor, "start" );
59
  }
60
61
  @Override
62
  public void pull( final StreamProcessor processor, final String name )
63
    throws CommandNotFoundException {
64
    podman( processor, "pull", "ghcr.io/davejarvis/" + name );
65
  }
66
67
  /**
68
   * Runs:
69
   * <p>
70
   * <code>podman run --network=host --rm -t IMAGE /bin/sh -lc</code>
71
   * </p>
72
   * followed by the given arguments.
73
   *
74
   * @param args The command and arguments to run against the container.
75
   * @return The exit code from running the container manager (not the
76
   * exit code from running the command).
77
   * @throws CommandNotFoundException Container manager couldn't be found.
78
   */
79
  @Override
80
  public int run(
81
    final StreamProcessor processor,
82
    final String... args ) throws CommandNotFoundException {
83
    final var options = new LinkedList<String>();
84
    options.add( "run" );
85
    options.add( "--rm" );
86
    options.add( "--network=host" );
87
    options.addAll( mMountPoints );
88
    options.add( "-t" );
89
    options.add( CONTAINER_NAME );
90
    options.add( "/bin/sh" );
91
    options.add( "-lc" );
92
93
    final var command = toArray( toArray( options ), args );
94
    return podman( processor, command );
95
  }
96
97
  /**
98
   * Generates a command-line argument representing a mount point between
99
   * the host and guest systems.
100
   *
101
   * @param hostDir  The host directory to mount in the container.
102
   * @param guestDir The guest directory to map from the container to host.
103
   * @param readonly Set {@code true} to make the mount point read-only.
104
   */
105
  public void mount(
106
    final Path hostDir, final String guestDir, final boolean readonly ) {
107
    assert hostDir != null;
108
    assert guestDir != null;
109
    assert !guestDir.isBlank();
110
    assert hostDir.toFile().isDirectory();
111
112
    mMountPoints.add(
113
      format( "-v%s:%s:%s", hostDir, guestDir, readonly ? "ro" : "Z" )
114
    );
115
  }
116
117
  private static void machine(
118
    final StreamProcessor processor,
119
    final String... args )
120
    throws CommandNotFoundException {
121
    podman( processor, toArray( "machine", args ) );
122
  }
123
124
  private static int podman(
125
    final StreamProcessor processor, final String... args )
126
    throws CommandNotFoundException {
127
    try {
128
      final var exe = MANAGER.locate();
129
      final var path = exe.orElseThrow();
130
      final var builder = processBuilder( path, args );
131
      final var process = builder.start();
132
133
      processor.start( process.getInputStream() );
134
135
      return wait( process );
136
    } catch( final Exception ex ) {
137
      throw new CommandNotFoundException( MANAGER.toString() );
138
    }
139
  }
140
141
  /**
142
   * Performs a blocking wait until the {@link Process} completes.
143
   *
144
   * @param process The {@link Process} to await completion.
145
   * @return The exit code from running a command.
146
   * @throws InterruptedException The {@link Process} was interrupted.
147
   */
148
  private static int wait( final Process process ) throws InterruptedException {
149
    final var exitCode = process.waitFor();
150
    process.destroy();
151
152
    return exitCode;
153
  }
154
155
  private static ProcessBuilder processBuilder( final String... args ) {
156
    final var builder = new ProcessBuilder( args );
157
    builder.redirectErrorStream( true );
158
159
    return builder;
160
  }
161
162
  private static ProcessBuilder processBuilder(
163
    final File file, final String... s ) {
164
    return processBuilder( toArray( file.getAbsolutePath(), s ) );
165
  }
166
167
  private static ProcessBuilder processBuilder(
168
    final Path path, final String... s ) {
169
    return processBuilder( path.toFile(), s );
170
  }
171
172
  /**
173
   * Merges two arrays into a single array.
174
   *
175
   * @param first  The first array to merge before the second array.
176
   * @param second The second array to merge after the first array.
177
   * @param <T>    The type of arrays to merge.
178
   * @return The merged arrays, with the first array elements preceding the
179
   * second array's elements.
180
   */
181
  private static <T> T[] toArray( final T[] first, final T[] second ) {
182
    assert first != null;
183
    assert second != null;
184
    assert first.length > 0;
185
    assert second.length > 0;
186
187
    final var merged = copyOf( first, first.length + second.length );
188
    arraycopy( second, 0, merged, first.length, second.length );
189
    return merged;
190
  }
191
192
  /**
193
   * Convenience method to merge a single string with an array of strings.
194
   *
195
   * @param first  The first item to prepend to the secondary items.
196
   * @param second The second item to combine with the first item.
197
   * @return A new array with the first element at index 0 and the second
198
   * elements starting at index 1.
199
   */
200
  private static String[] toArray( final String first, String... second ) {
201
    assert first != null;
202
    assert second != null;
203
    assert second.length > 0;
204
205
    return toArray( new String[]{first}, second );
206
  }
207
}
1208
A src/main/java/com/keenwrite/typesetting/containerization/StreamProcessor.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.containerization;
3
4
import java.io.InputStream;
5
import java.io.PipedInputStream;
6
import java.io.PipedOutputStream;
7
8
/**
9
 * Implementations receive an {@link InputStream} for reading, which happens
10
 * on a separate thread. Implementations are responsible for starting the
11
 * thread. This class helps avoid relying on {@link PipedInputStream} and
12
 * {@link PipedOutputStream} to connect the {@link InputStream} from an
13
 * instance of {@link ProcessBuilder} to process standard output and standard
14
 * error for a running command.
15
 */
16
@FunctionalInterface
17
public interface StreamProcessor {
18
  /**
19
   * Processes the given {@link InputStream} on a separate thread.
20
   */
21
  void start( InputStream in );
22
}
123
A src/main/java/com/keenwrite/typesetting/installer/TypesetterInstaller.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer;
3
4
import com.keenwrite.events.ExportFailedEvent;
5
import com.keenwrite.preferences.AppKeys;
6
import com.keenwrite.preferences.Workspace;
7
import com.keenwrite.typesetting.installer.panes.*;
8
import org.controlsfx.dialog.Wizard;
9
import org.greenrobot.eventbus.Subscribe;
10
11
import java.util.LinkedList;
12
13
import static com.keenwrite.Messages.get;
14
import static com.keenwrite.events.Bus.register;
15
import static org.apache.commons.lang3.SystemUtils.*;
16
17
/**
18
 * Responsible for installing the typesetting system and all its requirements.
19
 */
20
public final class TypesetterInstaller {
21
  private final Workspace mWorkspace;
22
23
  /**
24
   * Registers for the {@link ExportFailedEvent}, which, when received,
25
   * indicates that the typesetting software must be installed.
26
   *
27
   * @param workspace To set {@link AppKeys#KEY_TYPESET_CONTEXT_THEMES_PATH} via
28
   *                  {@link TypesetterThemesDownloadPane}.
29
   */
30
  public TypesetterInstaller( final Workspace workspace ) {
31
    assert workspace != null;
32
33
    mWorkspace = workspace;
34
35
    register( this );
36
  }
37
38
  @Subscribe
39
  @SuppressWarnings( "unused" )
40
  public void handle( final ExportFailedEvent failedEvent ) {
41
    final var wizard = wizard();
42
43
    wizard.showAndWait();
44
  }
45
46
  private Wizard wizard() {
47
    final var title = get( "Wizard.typesetter.all.1.install.title" );
48
    final var wizard = new Wizard( this, title );
49
    final var wizardFlow = wizardFlow();
50
51
    wizard.setFlow( wizardFlow );
52
53
    return wizard;
54
  }
55
56
  private Wizard.Flow wizardFlow() {
57
    final var panels = wizardPanes();
58
    return new Wizard.LinearFlow( panels );
59
  }
60
61
  private InstallerPane[] wizardPanes() {
62
    final var panes = new LinkedList<InstallerPane>();
63
64
    // STEP 1: Introduction panel (all)
65
    panes.add( new IntroductionPane() );
66
67
    if( IS_OS_WINDOWS ) {
68
      // STEP 2 a: Download container (Windows)
69
      panes.add( new WindowsManagerDownloadPane() );
70
      // STEP 2 b: Install container (Windows)
71
      panes.add( new WindowsManagerInstallPane() );
72
    }
73
    else if( IS_OS_UNIX ) {
74
      // STEP 2: Install container (Unix)
75
      panes.add( new UnixManagerInstallPane() );
76
    }
77
    else {
78
      // STEP 2: Install container (other)
79
      panes.add( new UniversalManagerInstallPane() );
80
    }
81
82
    if( !IS_OS_LINUX ) {
83
      // STEP 3: Initialize container (all except Linux)
84
      panes.add( new ManagerInitializationPane() );
85
    }
86
87
    // STEP 4: Install typesetter container image (all)
88
    panes.add( new TypesetterImageDownloadPane() );
89
90
    // STEP 5: Download and install typesetter themes (all)
91
    panes.add( new TypesetterThemesDownloadPane( mWorkspace ) );
92
93
    return panes.toArray( InstallerPane[]::new );
94
  }
95
}
196
A src/main/java/com/keenwrite/typesetting/installer/WizardConstants.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer;
3
4
/**
5
 * Provides common constants across all panes.
6
 */
7
public class WizardConstants {
8
9
10
  private WizardConstants() { }
11
}
112
A src/main/java/com/keenwrite/typesetting/installer/panes/AbstractDownloadPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
import com.keenwrite.io.SysFile;
5
import javafx.collections.ObservableMap;
6
import javafx.concurrent.Task;
7
import javafx.scene.control.Label;
8
import javafx.scene.layout.BorderPane;
9
import org.controlsfx.dialog.Wizard;
10
11
import java.io.File;
12
import java.net.URI;
13
14
import static com.keenwrite.Bootstrap.USER_DATA_DIR;
15
import static com.keenwrite.Messages.get;
16
import static com.keenwrite.Messages.getUri;
17
18
/**
19
 * Responsible for asynchronous downloads.
20
 */
21
public abstract class AbstractDownloadPane extends InstallerPane {
22
  private static final String STATUS = ".status";
23
24
  private final Label mStatus;
25
  private final File mTarget;
26
  private final String mFilename;
27
  private final URI mUri;
28
29
  public AbstractDownloadPane() {
30
    mUri = getUri( getPrefix() + ".download.link.url" );
31
    mFilename = toFilename( mUri );
32
    final var directory = USER_DATA_DIR;
33
    mTarget = directory.resolve( mFilename ).toFile();
34
    final var source = labelf( getPrefix() + ".paths", mFilename, directory );
35
    mStatus = labelf( getPrefix() + STATUS + ".progress", 0, 0 );
36
37
    final var border = new BorderPane();
38
    border.setTop( source );
39
    border.setCenter( spacer() );
40
    border.setBottom( mStatus );
41
42
    setContent( border );
43
  }
44
45
  @Override
46
  public void onEnteringPage( final Wizard wizard ) {
47
    disableNext( true );
48
49
    final var threadName = getClass().getCanonicalName();
50
    final var properties = wizard.getProperties();
51
    final var thread = properties.get( threadName );
52
53
    if( thread instanceof Task<?> downloader && downloader.isRunning() ) {
54
      return;
55
    }
56
57
    updateProperties( properties );
58
59
    final var target = getTarget();
60
    final var sysFile = new SysFile( target );
61
    final var checksum = getChecksum();
62
63
    if( sysFile.exists() ) {
64
      final var checksumOk = sysFile.isChecksum( checksum );
65
      final var suffix = checksumOk ? ".ok" : ".no";
66
67
      updateStatus( STATUS + ".checksum" + suffix, mFilename );
68
      disableNext( !checksumOk );
69
    }
70
    else {
71
      final var task = downloadAsync( mUri, target, ( progress, bytes ) -> {
72
        final var suffix = progress < 0 ? ".bytes" : ".progress";
73
74
        updateStatus( STATUS + suffix, progress, bytes );
75
      } );
76
77
      properties.put( threadName, task );
78
79
      task.setOnSucceeded( e -> onDownloadSucceeded( threadName, properties ) );
80
      task.setOnFailed( e -> onDownloadFailed( threadName, properties ) );
81
      task.setOnCancelled( e -> onDownloadFailed( threadName, properties ) );
82
    }
83
  }
84
85
  protected void updateProperties(
86
    final ObservableMap<Object, Object> properties ) {
87
  }
88
89
  @Override
90
  protected String getHeaderKey() {
91
    return getPrefix() + ".header";
92
  }
93
94
  protected File getTarget() {
95
    return mTarget;
96
  }
97
98
  protected abstract String getChecksum();
99
100
  protected abstract String getPrefix();
101
102
  protected void onDownloadSucceeded(
103
    final String threadName, final ObservableMap<Object, Object> properties ) {
104
    updateStatus( STATUS + ".success" );
105
    properties.remove( threadName );
106
    disableNext( false );
107
  }
108
109
  protected void onDownloadFailed(
110
    final String threadName, final ObservableMap<Object, Object> properties ) {
111
    updateStatus( STATUS + ".failure" );
112
    properties.remove( threadName );
113
  }
114
115
  protected void updateStatus( final String suffix, final Object... args ) {
116
    update( mStatus, get( getPrefix() + suffix, args ) );
117
  }
118
119
  protected void deleteTarget() {
120
    final var ignored = getTarget().delete();
121
  }
122
}
1123
A src/main/java/com/keenwrite/typesetting/installer/panes/InstallerPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
import com.keenwrite.events.HyperlinkOpenEvent;
5
import com.keenwrite.io.downloads.DownloadManager;
6
import com.keenwrite.io.downloads.DownloadManager.ProgressListener;
7
import com.keenwrite.typesetting.containerization.ContainerManager;
8
import com.keenwrite.typesetting.containerization.Podman;
9
import javafx.animation.Animation;
10
import javafx.animation.RotateTransition;
11
import javafx.concurrent.Task;
12
import javafx.geometry.Insets;
13
import javafx.scene.Node;
14
import javafx.scene.control.*;
15
import javafx.scene.image.ImageView;
16
import javafx.scene.layout.BorderPane;
17
import javafx.scene.layout.FlowPane;
18
import javafx.scene.layout.Pane;
19
import org.controlsfx.dialog.Wizard;
20
import org.controlsfx.dialog.WizardPane;
21
22
import java.io.File;
23
import java.io.FileOutputStream;
24
import java.net.URI;
25
import java.nio.file.Paths;
26
import java.util.concurrent.Callable;
27
28
import static com.keenwrite.Messages.get;
29
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG;
30
import static java.lang.System.lineSeparator;
31
import static javafx.animation.Interpolator.LINEAR;
32
import static javafx.application.Platform.runLater;
33
import static javafx.scene.control.ButtonBar.ButtonData.NEXT_FORWARD;
34
import static javafx.scene.control.ContentDisplay.RIGHT;
35
import static javafx.util.Duration.seconds;
36
37
/**
38
 * Responsible for creating a {@link WizardPane} with a common header for all
39
 * subclasses.
40
 */
41
public abstract class InstallerPane extends WizardPane {
42
  /**
43
   * Unique key name to store the animation object so that it can be stopped.
44
   */
45
  private static final String PROP_ROTATION = "Wizard.typesetter.next.animate";
46
47
  /**
48
   * Defines amount of spacing between the installer's UI widgets, in pixels.
49
   */
50
  static final int PAD = 10;
51
52
  private static final double HEADER_FONT_SCALE = 1.25;
53
54
  public InstallerPane() {
55
    setHeader( createHeader() );
56
  }
57
58
  /**
59
   * When leaving the page, stop the animation. This is idempotent.
60
   *
61
   * @param wizard The wizard controlling the installer steps.
62
   */
63
  @Override
64
  public void onExitingPage( final Wizard wizard ) {
65
    super.onExitingPage( wizard );
66
    runLater( () -> stopAnimation( getNextButton() ) );
67
  }
68
69
  /**
70
   * Returns the property bundle key representing the dialog box title.
71
   */
72
  protected abstract String getHeaderKey();
73
74
  private BorderPane createHeader() {
75
    final var headerLabel = label( getHeaderKey() );
76
    headerLabel.setScaleX( HEADER_FONT_SCALE );
77
    headerLabel.setScaleY( HEADER_FONT_SCALE );
78
79
    final var separator = new Separator();
80
    separator.setPadding( new Insets( PAD, 0, 0, 0 ) );
81
82
    final var header = new BorderPane();
83
    header.setCenter( headerLabel );
84
    header.setRight( new ImageView( ICON_DIALOG ) );
85
    header.setBottom( separator );
86
    header.setPadding( new Insets( PAD, PAD, 0, PAD ) );
87
88
    return header;
89
  }
90
91
  /**
92
   * Disables the "Next" button during the installer. Normally disabling UI
93
   * elements is an anti-pattern (along with modal dialogs); however, in this
94
   * case, installation cannot proceed until each step is successfully
95
   * completed. Further, there may be "misleading" success messages shown
96
   * in the output panel, which the user may take as the step being complete.
97
   *
98
   * @param disable Set to {@code true} to disable the button.
99
   */
100
  void disableNext( final boolean disable ) {
101
    runLater( () -> {
102
      final var button = getNextButton();
103
104
      button.setDisable( disable );
105
106
      if( disable ) {
107
        startAnimation( button );
108
      }
109
      else {
110
        stopAnimation( button );
111
      }
112
    } );
113
  }
114
115
  /**
116
   * Returns the {@link Button} for advancing the wizard to the next pane.
117
   *
118
   * @return The Next button, if present, otherwise a new {@link Button}
119
   * instance so that API calls will succeed, despite not affecting the UI.
120
   */
121
  private Button getNextButton() {
122
    for( final var buttonType : getButtonTypes() ) {
123
      final var buttonData = buttonType.getButtonData();
124
125
      if( buttonData.equals( NEXT_FORWARD ) &&
126
        lookupButton( buttonType ) instanceof Button button ) {
127
        return button;
128
      }
129
    }
130
131
    // If there's no Next button, return a fake button.
132
    return new Button();
133
  }
134
135
  private void startAnimation( final Button button ) {
136
    // Create an image that is slightly taller than the button's font.
137
    final var graphic = new ImageView( ICON_DIALOG );
138
    graphic.setFitHeight( button.getFont().getSize() + 2 );
139
    graphic.setPreserveRatio( true );
140
    graphic.setSmooth( true );
141
142
    button.setGraphic( graphic );
143
    button.setGraphicTextGap( PAD );
144
    button.setContentDisplay( RIGHT );
145
146
    final var rotation = new RotateTransition( seconds( 1 ), graphic );
147
    getProperties().put( PROP_ROTATION, rotation );
148
149
    rotation.setCycleCount( Animation.INDEFINITE );
150
    rotation.setByAngle( 360 );
151
    rotation.setInterpolator( LINEAR );
152
    rotation.play();
153
  }
154
155
  private void stopAnimation( final Button button ) {
156
    final var animation = getProperties().get( PROP_ROTATION );
157
158
    if( animation instanceof RotateTransition rotation ) {
159
      rotation.stop();
160
      button.setGraphic( null );
161
      getProperties().remove( PROP_ROTATION );
162
    }
163
  }
164
165
  static TitledPane titledPane( final String title, final Node child ) {
166
    final var pane = new TitledPane( title, child );
167
    pane.setAnimated( false );
168
    pane.setCollapsible( false );
169
    pane.setExpanded( true );
170
171
    return pane;
172
  }
173
174
  static TextArea textArea( final int rows, final int cols ) {
175
    final var textarea = new TextArea();
176
    textarea.setEditable( false );
177
    textarea.setWrapText( true );
178
    textarea.setPrefRowCount( rows );
179
    textarea.setPrefColumnCount( cols );
180
181
    return textarea;
182
  }
183
184
  static Label label( final String key ) {
185
    return new Label( get( key ) );
186
  }
187
188
  /**
189
   * Like printf for labels.
190
   *
191
   * @param key    The property key to look up.
192
   * @param values The values to insert at the placeholders.
193
   * @return The formatted text with values replaced.
194
   */
195
  @SuppressWarnings( "SpellCheckingInspection" )
196
  static Label labelf( final String key, final Object... values ) {
197
    return new Label( get( key, values ) );
198
  }
199
200
  @SuppressWarnings( "SameParameterValue" )
201
  static Button button( final String key ) {
202
    return new Button( get( key ) );
203
  }
204
205
  static Node flowPane( final Node... nodes ) {
206
    return new FlowPane( nodes );
207
  }
208
209
  /**
210
   * Provides vertical spacing between {@link Node}s.
211
   *
212
   * @return A new empty vertical gap widget.
213
   */
214
  static Node spacer() {
215
    final var spacer = new Pane();
216
    spacer.setPadding( new Insets( PAD, 0, 0, 0 ) );
217
218
    return spacer;
219
  }
220
221
  static Hyperlink hyperlink( final String prefix ) {
222
    final var label = get( prefix + ".lbl" );
223
    final var url = get( prefix + ".url" );
224
    final var link = new Hyperlink( label );
225
226
    link.setOnAction( e -> browse( url ) );
227
    link.setTooltip( new Tooltip( url ) );
228
229
    return link;
230
  }
231
232
  /**
233
   * Opens a browser window off of the JavaFX main execution thread. This
234
   * is necessary so that the links open immediately, instead of being blocked
235
   * by any modal dialog (i.e., the {@link Wizard} instance).
236
   *
237
   * @param property The property key name associated with a hyperlink URL.
238
   */
239
  static void browse( final String property ) {
240
    final var url = get( property );
241
    final var task = createTask( () -> {
242
      HyperlinkOpenEvent.fire( url );
243
      return null;
244
    } );
245
    final var thread = createThread( task );
246
247
    thread.start();
248
  }
249
250
  static <T> Task<T> createTask( final Callable<T> callable ) {
251
    return new Task<>() {
252
      @Override
253
      protected T call() throws Exception {
254
        return callable.call();
255
      }
256
    };
257
  }
258
259
  static <T> Thread createThread( final Task<T> task ) {
260
    final var thread = new Thread( task );
261
    thread.setDaemon( true );
262
    return thread;
263
  }
264
265
  /**
266
   * Creates a container that can have its standard output read as an input
267
   * stream that's piped directly to a {@link TextArea}.
268
   *
269
   * @return An object that can perform tasks against a container.
270
   */
271
  static ContainerManager createContainer() {
272
    return new Podman();
273
  }
274
275
  static void update( final Label node, final String text ) {
276
    runLater( () -> node.setText( text ) );
277
  }
278
279
  static void append( final TextArea node, final String text ) {
280
    runLater( () -> {
281
      node.appendText( text );
282
      node.appendText( lineSeparator() );
283
    } );
284
  }
285
286
  /**
287
   * Downloads a resource to a local file in a separate {@link Thread}.
288
   *
289
   * @param uri      The resource to download.
290
   * @param file     The destination mTarget for the resource.
291
   * @param listener Receives updates as the download proceeds.
292
   */
293
  static Task<Void> downloadAsync(
294
    final URI uri,
295
    final File file,
296
    final ProgressListener listener ) {
297
    final Task<Void> task = createTask( () -> {
298
      try( final var token = DownloadManager.open( uri ) ) {
299
        final var output = new FileOutputStream( file );
300
        final var downloader = token.download( output, listener );
301
302
        downloader.run();
303
      }
304
305
      return null;
306
    } );
307
308
    createThread( task ).start();
309
    return task;
310
  }
311
312
  static String toFilename( final URI uri ) {
313
    return Paths.get( uri.getPath() ).toFile().getName();
314
  }
315
}
1316
A src/main/java/com/keenwrite/typesetting/installer/panes/IntroductionPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
/**
5
 * Responsible for informing the user as to what will happen next.
6
 */
7
public final class IntroductionPane extends InstallerPane {
8
  private static final String PREFIX = "Wizard.typesetter.all.1.install";
9
10
  public IntroductionPane() {
11
    setContent( flowPane(
12
      hyperlink( PREFIX + ".about.container.link" ),
13
      label( PREFIX + ".about.text.1" ),
14
      hyperlink( PREFIX + ".about.typesetter.link" ),
15
      label( PREFIX + ".about.text.2" )
16
    ) );
17
  }
18
19
  @Override
20
  protected String getHeaderKey() {
21
    return PREFIX + ".header";
22
  }
23
}
124
A src/main/java/com/keenwrite/typesetting/installer/panes/ManagerInitializationPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
import com.keenwrite.typesetting.containerization.ContainerManager;
5
6
/**
7
 * Responsible for initializing the container manager on all platforms except
8
 * for Linux.
9
 */
10
public final class ManagerInitializationPane extends ManagerOutputPane {
11
12
  private static final String PREFIX =
13
    "Wizard.typesetter.all.3.install.container";
14
15
  public ManagerInitializationPane() {
16
    super(
17
      PREFIX + ".correct",
18
      PREFIX + ".missing",
19
      ContainerManager::start,
20
      35
21
    );
22
  }
23
24
  @Override
25
  public String getHeaderKey() {
26
    return PREFIX + ".header";
27
  }
28
}
129
A src/main/java/com/keenwrite/typesetting/installer/panes/ManagerOutputPane.java
1
package com.keenwrite.typesetting.installer.panes;
2
3
import com.keenwrite.io.CommandNotFoundException;
4
import com.keenwrite.typesetting.containerization.ContainerManager;
5
import com.keenwrite.typesetting.containerization.StreamProcessor;
6
import javafx.concurrent.Task;
7
import javafx.scene.control.TextArea;
8
import javafx.scene.layout.BorderPane;
9
import org.apache.commons.lang3.function.FailableBiConsumer;
10
import org.controlsfx.dialog.Wizard;
11
12
import static com.keenwrite.Messages.get;
13
import static com.keenwrite.io.StreamGobbler.gobble;
14
15
/**
16
 * Responsible for showing the output from running commands against a container
17
 * manager. There are a few installation steps that run different commands
18
 * against the installer, which are platform-specific and cannot be merged.
19
 * Common functionality between them is codified in this class.
20
 */
21
public abstract class ManagerOutputPane extends InstallerPane {
22
  private final String PROP_EXECUTOR = getClass().getCanonicalName();
23
24
  private final String mCorrectKey;
25
  private final String mMissingKey;
26
  private final FailableBiConsumer
27
    <ContainerManager, StreamProcessor, CommandNotFoundException> mFc;
28
  private final ContainerManager mContainer;
29
  private final TextArea mTextArea;
30
31
  public ManagerOutputPane(
32
    final String correctKey,
33
    final String missingKey,
34
    final FailableBiConsumer
35
      <ContainerManager, StreamProcessor, CommandNotFoundException> fc,
36
    final int cols
37
  ) {
38
    mFc = fc;
39
    mCorrectKey = correctKey;
40
    mMissingKey = missingKey;
41
    mTextArea = textArea( 5, cols );
42
    mContainer = createContainer();
43
44
    final var borderPane = new BorderPane();
45
    final var titledPane = titledPane( "Output", mTextArea );
46
47
    borderPane.setBottom( titledPane );
48
    setContent( borderPane );
49
  }
50
51
  @Override
52
  public void onEnteringPage( final Wizard wizard ) {
53
    disableNext( true );
54
55
    try {
56
      final var properties = wizard.getProperties();
57
      final var thread = properties.get( PROP_EXECUTOR );
58
59
      if( thread instanceof Thread executor && executor.isAlive() ) {
60
        return;
61
      }
62
63
      final Task<Void> task = createTask( () -> {
64
        mFc.accept(
65
          mContainer,
66
          input -> gobble( input, line -> append( mTextArea, line ) )
67
        );
68
        properties.remove( thread );
69
        return null;
70
      } );
71
72
      task.setOnSucceeded( event -> {
73
        append( mTextArea, get( mCorrectKey ) );
74
        properties.remove( thread );
75
        disableNext( false );
76
      } );
77
      task.setOnFailed( event -> append( mTextArea, get( mMissingKey ) ) );
78
      task.setOnCancelled( event -> append( mTextArea, get( mMissingKey ) ) );
79
80
      final var executor = createThread( task );
81
      properties.put( PROP_EXECUTOR, executor );
82
      executor.start();
83
    } catch( final Exception e ) {
84
      throw new RuntimeException( e );
85
    }
86
  }
87
}
188
A src/main/java/com/keenwrite/typesetting/installer/panes/TypesetterImageDownloadPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
import static com.keenwrite.typesetting.containerization.Podman.CONTAINER_NAME;
5
6
/**
7
 * Responsible for installing the typesetter's image via the container manager.
8
 */
9
public final class TypesetterImageDownloadPane extends ManagerOutputPane {
10
  private static final String PREFIX =
11
    "Wizard.typesetter.all.4.download.image";
12
13
  public TypesetterImageDownloadPane() {
14
    super(
15
      PREFIX + ".correct",
16
      PREFIX + ".missing",
17
      (container, processor) -> container.pull( processor, CONTAINER_NAME ),
18
      45
19
    );
20
  }
21
22
  @Override
23
  public String getHeaderKey() {
24
    return PREFIX + ".header";
25
  }
26
}
127
A src/main/java/com/keenwrite/typesetting/installer/panes/TypesetterThemesDownloadPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
import com.keenwrite.io.UserDataDir;
5
import com.keenwrite.io.Zip;
6
import com.keenwrite.preferences.Workspace;
7
import javafx.collections.ObservableMap;
8
import org.controlsfx.dialog.Wizard;
9
10
import java.io.File;
11
import java.io.IOException;
12
13
import static com.keenwrite.Messages.get;
14
import static com.keenwrite.events.StatusEvent.clue;
15
import static com.keenwrite.preferences.AppKeys.KEY_TYPESET_CONTEXT_THEMES_PATH;
16
17
/**
18
 * Responsible for downloading themes into the application's data directory.
19
 * The data directory differs between platforms, which is handled
20
 * transparently by the {@link UserDataDir} class.
21
 */
22
public class TypesetterThemesDownloadPane extends AbstractDownloadPane {
23
  private static final String PREFIX =
24
    "Wizard.typesetter.all.5.download.themes";
25
26
  private final Workspace mWorkspace;
27
28
  public TypesetterThemesDownloadPane( final Workspace workspace ) {
29
    assert workspace != null;
30
    mWorkspace = workspace;
31
  }
32
33
  @Override
34
  public void onEnteringPage( final Wizard wizard ) {
35
    // Delete the target themes file to force re-download so that unzipping
36
    // the file takes place. This side-steps checksum validation, which would
37
    // be best implemented after downloading.
38
    deleteTarget();
39
    super.onEnteringPage( wizard );
40
  }
41
42
  @Override
43
  protected void onDownloadSucceeded(
44
    final String threadName, final ObservableMap<Object, Object> properties ) {
45
    super.onDownloadSucceeded( threadName, properties );
46
47
    try {
48
      process( getTarget() );
49
    } catch( final Exception ex ) {
50
      clue( ex );
51
    }
52
  }
53
54
  private void process( final File target ) throws IOException {
55
    Zip.extract( target.toPath() );
56
57
    // Replace the default themes directory with the downloaded version.
58
    final var root = Zip.root( target.toPath() ).toFile();
59
60
    // Make sure the typesetter will know where to find the themes.
61
    mWorkspace.fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ).set( root );
62
    mWorkspace.save();
63
64
    // The themes pack is no longer needed.
65
    deleteTarget();
66
  }
67
68
  @Override
69
  protected String getPrefix() {
70
    return PREFIX;
71
  }
72
73
  @Override
74
  protected String getChecksum() {
75
    return get( "Wizard.typesetter.themes.checksum" );
76
  }
77
}
178
A src/main/java/com/keenwrite/typesetting/installer/panes/UniversalManagerInstallPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
/**
5
 * Responsible for installing the container manager for any operating system
6
 * that was not explicitly detected.
7
 */
8
public final class UniversalManagerInstallPane extends InstallerPane {
9
  private static final String PREFIX =
10
    "Wizard.typesetter.all.2.install.container";
11
12
  public UniversalManagerInstallPane() { }
13
14
  @Override
15
  protected String getHeaderKey() {
16
    return PREFIX + ".header";
17
  }
18
}
119
A src/main/java/com/keenwrite/typesetting/installer/panes/UnixManagerInstallPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
import com.keenwrite.ui.clipboard.Clipboard;
5
import javafx.geometry.Insets;
6
import javafx.scene.Node;
7
import javafx.scene.control.ButtonBar;
8
import javafx.scene.control.ComboBox;
9
import javafx.scene.control.TextArea;
10
import javafx.scene.layout.BorderPane;
11
import javafx.scene.layout.HBox;
12
import javafx.scene.layout.VBox;
13
import org.jetbrains.annotations.NotNull;
14
15
import static com.keenwrite.Messages.get;
16
import static com.keenwrite.Messages.getInt;
17
import static java.lang.String.format;
18
import static org.apache.commons.lang3.SystemUtils.IS_OS_MAC;
19
20
public final class UnixManagerInstallPane extends InstallerPane {
21
  private static final String PREFIX =
22
    "Wizard.typesetter.unix.2.install.container";
23
24
  private final TextArea mCommands = textArea( 2, 40 );
25
26
  public UnixManagerInstallPane() {
27
    final var titledPane = titledPane( "Run", mCommands );
28
    final var comboBox = createUnixOsCommandMap();
29
    final var selection = comboBox.getSelectionModel();
30
    selection
31
      .selectedItemProperty()
32
      .addListener( ( c, o, n ) -> mCommands.setText( n.command() ) );
33
34
    // Auto-select if running on macOS.
35
    if( IS_OS_MAC ) {
36
      final var items = comboBox.getItems();
37
38
      for( final var item : items ) {
39
        if( "macOS".equalsIgnoreCase( item.name ) ) {
40
          selection.select( item );
41
          break;
42
        }
43
      }
44
    }
45
    else {
46
      selection.select( 0 );
47
    }
48
49
    final var distro = label( PREFIX + ".os" );
50
    distro.setText( distro.getText() + ":" );
51
    distro.setPadding( new Insets( PAD / 2.0, PAD, 0, 0 ) );
52
53
    final var hbox = new HBox();
54
    hbox.getChildren().add( distro );
55
    hbox.getChildren().add( comboBox );
56
    hbox.setPadding( new Insets( 0, 0, PAD, 0 ) );
57
58
    final var stepsPane = new VBox();
59
    final var steps = stepsPane.getChildren();
60
    steps.add( label( PREFIX + ".step.0" ) );
61
    steps.add( spacer() );
62
    steps.add( label( PREFIX + ".step.1" ) );
63
    steps.add( label( PREFIX + ".step.2" ) );
64
    steps.add( label( PREFIX + ".step.3" ) );
65
    steps.add( label( PREFIX + ".step.4" ) );
66
    steps.add( spacer() );
67
68
    steps.add( flowPane(
69
      label( PREFIX + ".details.prefix" ),
70
      hyperlink( PREFIX + ".details.link" ),
71
      label( PREFIX + ".details.suffix" )
72
    ) );
73
    steps.add( spacer() );
74
75
    final var border = new BorderPane();
76
    border.setTop( stepsPane );
77
    border.setCenter( hbox );
78
    border.setBottom( titledPane );
79
80
    setContent( border );
81
  }
82
83
  @Override
84
  public Node createButtonBar() {
85
    final var node = super.createButtonBar();
86
    final var layout = new BorderPane();
87
    final var copyButton = button( PREFIX + ".copy.began" );
88
89
    // Change the label to indicate clipboard is updated.
90
    copyButton.setOnAction( event -> {
91
      Clipboard.write( mCommands.getText() );
92
      copyButton.setText( get( PREFIX + ".copy.ended" ) );
93
    } );
94
95
    if( node instanceof ButtonBar buttonBar ) {
96
      copyButton.setMinWidth( buttonBar.getButtonMinWidth() );
97
    }
98
99
    layout.setPadding( new Insets( PAD, PAD, PAD, PAD ) );
100
    layout.setLeft( copyButton );
101
    layout.setRight( node );
102
103
    return layout;
104
  }
105
106
  @Override
107
  protected String getHeaderKey() {
108
    return PREFIX + ".header";
109
  }
110
111
  private record UnixOsCommand( String name, String command )
112
    implements Comparable<UnixOsCommand> {
113
    @Override
114
    public int compareTo(
115
      final @NotNull UnixOsCommand other ) {
116
      return toString().compareToIgnoreCase( other.toString() );
117
    }
118
119
    @Override
120
    public String toString() {
121
      return name;
122
    }
123
  }
124
125
  /**
126
   * Creates a collection of *nix distributions mapped to instructions for users
127
   * to run in a terminal.
128
   *
129
   * @return A map of *nix to instructions.
130
   */
131
  private static ComboBox<UnixOsCommand> createUnixOsCommandMap() {
132
    new ComboBox<UnixOsCommand>();
133
    final var comboBox = new ComboBox<UnixOsCommand>();
134
    final var items = comboBox.getItems();
135
    final var prefix = PREFIX + ".command";
136
    final var distros = getInt( prefix + ".distros", 14 );
137
138
    for( int i = 1; i <= distros; i++ ) {
139
      final var suffix = format( ".%02d", i );
140
      final var name = get( prefix + ".os.name" + suffix );
141
      final var command = get( prefix + ".os.text" + suffix );
142
143
      items.add( new UnixOsCommand( name, command ) );
144
    }
145
146
    items.sort( UnixOsCommand::compareTo );
147
148
    return comboBox;
149
  }
150
}
1151
A src/main/java/com/keenwrite/typesetting/installer/panes/WindowsManagerDownloadPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
import javafx.collections.ObservableMap;
5
6
import static com.keenwrite.Messages.get;
7
import static com.keenwrite.typesetting.installer.panes.WindowsManagerInstallPane.WIN_BIN;
8
9
/**
10
 * Responsible for downloading the container manager software on Windows.
11
 */
12
public final class WindowsManagerDownloadPane extends AbstractDownloadPane {
13
  private static final String PREFIX =
14
    "Wizard.typesetter.win.2.download.container";
15
16
  @Override
17
  protected void updateProperties(
18
    final ObservableMap<Object, Object> properties ) {
19
    properties.put( WIN_BIN, getTarget() );
20
  }
21
22
  @Override
23
  protected String getPrefix() {
24
    return PREFIX;
25
  }
26
27
  @Override
28
  protected String getChecksum() {
29
    return get( "Wizard.typesetter.container.checksum" );
30
  }
31
}
132
A src/main/java/com/keenwrite/typesetting/installer/panes/WindowsManagerInstallPane.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.typesetting.installer.panes;
3
4
import com.keenwrite.typesetting.containerization.ContainerManager;
5
import javafx.scene.control.TextArea;
6
import javafx.scene.layout.BorderPane;
7
import javafx.scene.layout.VBox;
8
import org.controlsfx.dialog.Wizard;
9
10
import java.io.File;
11
12
import static com.keenwrite.Messages.get;
13
14
/**
15
 * Responsible for installing the container manager on Windows.
16
 */
17
public final class WindowsManagerInstallPane extends InstallerPane {
18
  /**
19
   * Property for the installation thread to help with reentrancy.
20
   */
21
  private static final String WIN_INSTALLER = "windows.container.installer";
22
23
  /**
24
   * Shared property to track name of container manager binary file.
25
   */
26
  static final String WIN_BIN = "windows.container.binary";
27
28
  private static final String PREFIX =
29
    "Wizard.typesetter.win.2.install.container";
30
31
  private final ContainerManager mContainer;
32
  private final TextArea mCommands;
33
34
  public WindowsManagerInstallPane() {
35
    mCommands = textArea( 2, 55 );
36
37
    final var titledPane = titledPane( "Output", mCommands );
38
    append( mCommands, get( PREFIX + ".status.running" ) );
39
40
    final var stepsPane = new VBox();
41
    final var steps = stepsPane.getChildren();
42
    steps.add( label( PREFIX + ".step.0" ) );
43
    steps.add( spacer() );
44
    steps.add( label( PREFIX + ".step.1" ) );
45
    steps.add( label( PREFIX + ".step.2" ) );
46
    steps.add( label( PREFIX + ".step.3" ) );
47
    steps.add( spacer() );
48
    steps.add( titledPane );
49
50
    final var border = new BorderPane();
51
    border.setTop( stepsPane );
52
53
    mContainer = createContainer();
54
  }
55
56
  @Override
57
  public void onEnteringPage( final Wizard wizard ) {
58
    disableNext( true );
59
60
    // Pull the fully qualified installer path from the properties.
61
    final var properties = wizard.getProperties();
62
    final var thread = properties.get( WIN_INSTALLER );
63
64
    if( thread instanceof Thread installer && installer.isAlive() ) {
65
      return;
66
    }
67
68
    final var binary = properties.get( WIN_BIN );
69
    final var key = PREFIX + ".status";
70
71
    if( binary instanceof File exe ) {
72
      final var task = createTask( () -> {
73
        final var exit = mContainer.install( exe );
74
75
        // Remove the installer after installation is finished.
76
        properties.remove( thread );
77
78
        final var msg = exit == 0
79
          ? get( key + ".success" )
80
          : get( key + ".failure", exit );
81
82
        append( mCommands, msg );
83
        disableNext( exit != 0 );
84
85
        return null;
86
      } );
87
88
      final var installer = createThread( task );
89
      properties.put( WIN_INSTALLER, installer );
90
      installer.start();
91
    }
92
    else {
93
      append( mCommands, get( PREFIX + ".unknown", binary ) );
94
    }
95
  }
96
97
  @Override
98
  public String getHeaderKey() {
99
    return PREFIX + ".header";
100
  }
101
}
1102
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
4747
import static com.keenwrite.Messages.get;
4848
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
49
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
50
import static com.keenwrite.events.StatusEvent.clue;
51
import static com.keenwrite.preferences.AppKeys.*;
52
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
53
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
54
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
55
import static com.keenwrite.util.FileWalker.walk;
56
import static java.lang.System.lineSeparator;
57
import static java.nio.file.Files.readString;
58
import static java.nio.file.Files.writeString;
59
import static java.util.concurrent.Executors.newFixedThreadPool;
60
import static javafx.application.Platform.runLater;
61
import static javafx.event.Event.fireEvent;
62
import static javafx.scene.control.Alert.AlertType.INFORMATION;
63
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
64
import static org.apache.commons.io.FilenameUtils.getExtension;
65
66
/**
67
 * Responsible for abstracting how functionality is mapped to the application.
68
 * This allows users to customize accelerator keys and will provide pluggable
69
 * functionality so that different text markup languages can change documents
70
 * using their respective syntax.
71
 */
72
public final class GuiCommands {
73
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
74
75
  private static final String STYLE_SEARCH = "search";
76
77
  /**
78
   * Sci-fi genres, which are can be longer than other genres, typically fall
79
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
80
   * memory when concatenating files together when exporting novels.
81
   */
82
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
83
84
  /**
85
   * When an action is executed, this is one of the recipients.
86
   */
87
  private final MainPane mMainPane;
88
89
  private final MainScene mMainScene;
90
91
  private final LogView mLogView;
92
93
  /**
94
   * Tracks finding text in the active document.
95
   */
96
  private final SearchModel mSearchModel;
97
98
  public GuiCommands( final MainScene scene, final MainPane pane ) {
99
    mMainScene = scene;
100
    mMainPane = pane;
101
    mLogView = new LogView();
102
    mSearchModel = new SearchModel();
103
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
104
      final var editor = getActiveTextEditor();
105
106
      // Clear highlighted areas before highlighting a new region.
107
      if( o != null ) {
108
        editor.unstylize( STYLE_SEARCH );
109
      }
110
111
      if( n != null ) {
112
        editor.moveTo( n.getStart() );
113
        editor.stylize( n, STYLE_SEARCH );
114
      }
115
    } );
116
117
    // When the active text editor changes ...
118
    mMainPane.textEditorProperty().addListener(
119
      ( c, o, n ) -> {
120
        // ... update the haystack.
121
        mSearchModel.search( getActiveTextEditor().getText() );
122
123
        // ... update the status bar with the current caret position.
124
        if( n != null ) {
125
          final var w = getWorkspace();
126
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
127
128
          // ... preserve the most recent document.
129
          recentDoc.setValue( n.getFile() );
130
          CaretMovedEvent.fire( n.getCaret() );
131
        }
132
      }
133
    );
134
  }
135
136
  public void file_new() {
137
    getMainPane().newTextEditor();
138
  }
139
140
  public void file_open() {
141
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
142
  }
143
144
  public void file_close() {
145
    getMainPane().close();
146
  }
147
148
  public void file_close_all() {
149
    getMainPane().closeAll();
150
  }
151
152
  public void file_save() {
153
    getMainPane().save();
154
  }
155
156
  public void file_save_as() {
157
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
158
  }
159
160
  public void file_save_all() {
161
    getMainPane().saveAll();
162
  }
163
164
  /**
165
   * Converts the actively edited file in the given file format.
166
   *
167
   * @param format The destination file format.
168
   */
169
  private void file_export( final ExportFormat format ) {
170
    file_export( format, false );
171
  }
172
173
  /**
174
   * Converts one or more files into the given file format. If {@code dir}
175
   * is set to true, this will first append all files in the same directory
176
   * as the actively edited file.
177
   *
178
   * @param format The destination file format.
179
   * @param dir    Export all files in the actively edited file's directory.
180
   */
181
  private void file_export( final ExportFormat format, final boolean dir ) {
182
    final var main = getMainPane();
183
    final var editor = main.getTextEditor();
184
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
185
    final var filename = format.toExportFilename( editor.getPath() );
186
    final var selected = PDF_DEFAULT.getName()
187
                                    .equals( exported.get().getName() );
188
    final var selection = pickFile(
189
      selected ? filename : exported.get(),
190
      exported.get().toPath().getParent(),
191
      FILE_EXPORT
192
    );
193
194
    selection.ifPresent( files -> {
195
      editor.save();
196
197
      final var file = files.get( 0 );
198
      final var path = file.toPath();
199
      final var document = dir ? append( editor ) : editor.getText();
200
      final var context = main.createProcessorContext( path, format );
201
202
      final var task = new Task<Path>() {
203
        @Override
204
        protected Path call() throws Exception {
205
          final var chain = createProcessors( context );
206
          final var export = chain.apply( document );
207
208
          // Processors can export binary files. In such cases, processors
209
          // return null to prevent further processing.
210
          return export == null ? null : writeString( path, export );
211
        }
212
      };
213
214
      task.setOnSucceeded(
215
        e -> {
216
          // Remember the exported file name for next time.
217
          exported.setValue( file );
218
219
          final var result = task.getValue();
220
221
          // Binary formats must notify users of success independently.
222
          if( result != null ) {
223
            clue( "Main.status.export.success", result );
224
          }
225
        }
226
      );
227
228
      task.setOnFailed( e -> {
229
        final var ex = task.getException();
230
        clue( ex );
231
232
        if( ex instanceof TypeNotPresentException ) {
233
          fireExportFailedEvent();
234
        }
235
      } );
236
237
      sExecutor.execute( task );
238
    } );
239
  }
240
241
  /**
242
   * @param dir {@code true} means to export all files in the active file
243
   *            editor's directory; {@code false} means to export only the
244
   *            actively edited file.
245
   */
246
  private void file_export_pdf( final boolean dir ) {
247
    final var workspace = getWorkspace();
248
    final var themes = workspace.getFile(
249
      KEY_TYPESET_CONTEXT_THEMES_PATH
250
    );
251
    final var theme = workspace.stringProperty(
252
      KEY_TYPESET_CONTEXT_THEME_SELECTION
253
    );
254
    final var chapters = workspace.stringProperty(
255
      KEY_TYPESET_CONTEXT_CHAPTERS
256
    );
257
    final var settings = ExportSettings
258
      .builder()
259
      .with( ExportSettings.Mutator::setTheme, theme )
260
      .with( ExportSettings.Mutator::setChapters, chapters )
261
      .build();
262
263
    if( Typesetter.canRun() ) {
264
      // If the typesetter is installed, allow the user to select a theme. If
265
      // the themes aren't installed, a status message will appear.
266
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
267
        file_export( APPLICATION_PDF, dir );
268
      }
269
    }
270
    else {
271
      fireExportFailedEvent();
272
    }
273
  }
274
275
  public void file_export_pdf() {
276
    file_export_pdf( false );
277
  }
278
279
  public void file_export_pdf_dir() {
280
    file_export_pdf( true );
281
  }
282
283
  public void file_export_html_svg() {
284
    file_export( HTML_TEX_SVG );
285
  }
286
287
  public void file_export_html_tex() {
288
    file_export( HTML_TEX_DELIMITED );
289
  }
290
291
  public void file_export_xhtml_tex() {
292
    file_export( XHTML_TEX );
293
  }
294
295
  private void fireExportFailedEvent() {
296
    runLater( ExportFailedEvent::fire );
297
  }
298
299
  public void file_exit() {
300
    final var window = getWindow();
301
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
302
  }
303
304
  public void edit_undo() {
305
    getActiveTextEditor().undo();
306
  }
307
308
  public void edit_redo() {
309
    getActiveTextEditor().redo();
310
  }
311
312
  public void edit_cut() {
313
    getActiveTextEditor().cut();
314
  }
315
316
  public void edit_copy() {
317
    getActiveTextEditor().copy();
318
  }
319
320
  public void edit_paste() {
321
    getActiveTextEditor().paste();
322
  }
323
324
  public void edit_select_all() {
325
    getActiveTextEditor().selectAll();
326
  }
327
328
  public void edit_find() {
329
    final var nodes = getMainScene().getStatusBar().getLeftItems();
330
331
    if( nodes.isEmpty() ) {
332
      final var searchBar = new SearchBar();
333
334
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
335
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
336
337
      searchBar.setOnCancelAction( event -> {
338
        final var editor = getActiveTextEditor();
339
        nodes.remove( searchBar );
340
        editor.unstylize( STYLE_SEARCH );
341
        editor.getNode().requestFocus();
342
      } );
343
344
      searchBar.addInputListener( ( c, o, n ) -> {
345
        if( n != null && !n.isEmpty() ) {
346
          mSearchModel.search( n, getActiveTextEditor().getText() );
347
        }
348
      } );
349
350
      searchBar.setOnNextAction( event -> edit_find_next() );
351
      searchBar.setOnPrevAction( event -> edit_find_prev() );
352
353
      nodes.add( searchBar );
354
      searchBar.requestFocus();
355
    }
356
    else {
357
      nodes.clear();
358
    }
359
  }
360
361
  public void edit_find_next() {
362
    mSearchModel.advance();
363
  }
364
365
  public void edit_find_prev() {
366
    mSearchModel.retreat();
367
  }
368
369
  public void edit_preferences() {
370
    try {
371
      new PreferencesController( getWorkspace() ).show();
372
    } catch( final Exception ex ) {
373
      clue( ex );
374
    }
375
  }
376
377
  public void format_bold() {
378
    getActiveTextEditor().bold();
379
  }
380
381
  public void format_italic() {
382
    getActiveTextEditor().italic();
383
  }
384
385
  public void format_monospace() {
386
    getActiveTextEditor().monospace();
387
  }
388
389
  public void format_superscript() {
390
    getActiveTextEditor().superscript();
391
  }
392
393
  public void format_subscript() {
394
    getActiveTextEditor().subscript();
395
  }
396
397
  public void format_strikethrough() {
398
    getActiveTextEditor().strikethrough();
399
  }
400
401
  public void insert_blockquote() {
402
    getActiveTextEditor().blockquote();
403
  }
404
405
  public void insert_code() {
406
    getActiveTextEditor().code();
407
  }
408
409
  public void insert_fenced_code_block() {
410
    getActiveTextEditor().fencedCodeBlock();
411
  }
412
413
  public void insert_link() {
414
    insertObject( createLinkDialog() );
415
  }
416
417
  public void insert_image() {
418
    insertObject( createImageDialog() );
419
  }
420
421
  private void insertObject( final Dialog<String> dialog ) {
422
    final var textArea = getActiveTextEditor().getTextArea();
423
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
424
  }
425
426
  private Dialog<String> createLinkDialog() {
427
    return new LinkDialog( getWindow(), createHyperlinkModel() );
428
  }
429
430
  private Dialog<String> createImageDialog() {
431
    final var path = getActiveTextEditor().getPath();
432
    final var parentDir = path.getParent();
433
    return new ImageDialog( getWindow(), parentDir );
434
  }
435
436
  /**
437
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
438
   * the Markdown AST.
439
   *
440
   * @return An instance containing the link URL and display text.
441
   */
442
  private HyperlinkModel createHyperlinkModel() {
443
    final var context = getMainPane().createProcessorContext();
444
    final var editor = getActiveTextEditor();
445
    final var textArea = editor.getTextArea();
446
    final var selectedText = textArea.getSelectedText();
447
448
    // Convert current paragraph to Markdown nodes.
449
    final var mp = MarkdownProcessor.create( context );
450
    final var p = textArea.getCurrentParagraph();
451
    final var paragraph = textArea.getText( p );
452
    final var node = mp.toNode( paragraph );
453
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
454
    final var link = visitor.process( node );
455
456
    if( link != null ) {
457
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
458
    }
459
460
    return createHyperlinkModel( link, selectedText );
461
  }
462
463
  private HyperlinkModel createHyperlinkModel(
464
    final Link link, final String selection ) {
465
466
    return link == null
467
      ? new HyperlinkModel( selection, "https://localhost" )
468
      : new HyperlinkModel( link );
469
  }
470
471
  public void insert_heading_1() {
472
    insert_heading( 1 );
473
  }
474
475
  public void insert_heading_2() {
476
    insert_heading( 2 );
477
  }
478
479
  public void insert_heading_3() {
480
    insert_heading( 3 );
481
  }
482
483
  private void insert_heading( final int level ) {
484
    getActiveTextEditor().heading( level );
485
  }
486
487
  public void insert_unordered_list() {
488
    getActiveTextEditor().unorderedList();
489
  }
490
491
  public void insert_ordered_list() {
492
    getActiveTextEditor().orderedList();
493
  }
494
495
  public void insert_horizontal_rule() {
496
    getActiveTextEditor().horizontalRule();
497
  }
498
499
  public void definition_create() {
500
    getActiveTextDefinition().createDefinition();
501
  }
502
503
  public void definition_rename() {
504
    getActiveTextDefinition().renameDefinition();
505
  }
506
507
  public void definition_delete() {
508
    getActiveTextDefinition().deleteDefinitions();
509
  }
510
511
  public void definition_autoinsert() {
512
    getMainPane().autoinsert();
513
  }
514
515
  public void view_refresh() {
516
    getMainPane().viewRefresh();
517
  }
518
519
  public void view_preview() {
520
    getMainPane().viewPreview();
521
  }
522
523
  public void view_outline() {
524
    getMainPane().viewOutline();
525
  }
526
527
  public void view_files() {getMainPane().viewFiles();}
49
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
50
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
51
import static com.keenwrite.events.StatusEvent.clue;
52
import static com.keenwrite.preferences.AppKeys.*;
53
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
54
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
55
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
56
import static com.keenwrite.util.FileWalker.walk;
57
import static java.lang.System.lineSeparator;
58
import static java.nio.file.Files.readString;
59
import static java.nio.file.Files.writeString;
60
import static java.util.concurrent.Executors.newFixedThreadPool;
61
import static javafx.application.Platform.runLater;
62
import static javafx.event.Event.fireEvent;
63
import static javafx.scene.control.Alert.AlertType.INFORMATION;
64
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
65
import static org.apache.commons.io.FilenameUtils.getExtension;
66
67
/**
68
 * Responsible for abstracting how functionality is mapped to the application.
69
 * This allows users to customize accelerator keys and will provide pluggable
70
 * functionality so that different text markup languages can change documents
71
 * using their respective syntax.
72
 */
73
public final class GuiCommands {
74
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
75
76
  private static final String STYLE_SEARCH = "search";
77
78
  /**
79
   * Sci-fi genres, which are can be longer than other genres, typically fall
80
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
81
   * memory when concatenating files together when exporting novels.
82
   */
83
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
84
85
  /**
86
   * When an action is executed, this is one of the recipients.
87
   */
88
  private final MainPane mMainPane;
89
90
  private final MainScene mMainScene;
91
92
  private final LogView mLogView;
93
94
  /**
95
   * Tracks finding text in the active document.
96
   */
97
  private final SearchModel mSearchModel;
98
99
  private boolean mCanTypeset;
100
101
  public GuiCommands( final MainScene scene, final MainPane pane ) {
102
    mMainScene = scene;
103
    mMainPane = pane;
104
    mLogView = new LogView();
105
    mSearchModel = new SearchModel();
106
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
107
      final var editor = getActiveTextEditor();
108
109
      // Clear highlighted areas before highlighting a new region.
110
      if( o != null ) {
111
        editor.unstylize( STYLE_SEARCH );
112
      }
113
114
      if( n != null ) {
115
        editor.moveTo( n.getStart() );
116
        editor.stylize( n, STYLE_SEARCH );
117
      }
118
    } );
119
120
    // When the active text editor changes ...
121
    mMainPane.textEditorProperty().addListener(
122
      ( c, o, n ) -> {
123
        // ... update the haystack.
124
        mSearchModel.search( getActiveTextEditor().getText() );
125
126
        // ... update the status bar with the current caret position.
127
        if( n != null ) {
128
          final var w = getWorkspace();
129
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
130
131
          // ... preserve the most recent document.
132
          recentDoc.setValue( n.getFile() );
133
          CaretMovedEvent.fire( n.getCaret() );
134
        }
135
      }
136
    );
137
  }
138
139
  public void file_new() {
140
    getMainPane().newTextEditor();
141
  }
142
143
  public void file_open() {
144
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
145
  }
146
147
  public void file_close() {
148
    getMainPane().close();
149
  }
150
151
  public void file_close_all() {
152
    getMainPane().closeAll();
153
  }
154
155
  public void file_save() {
156
    getMainPane().save();
157
  }
158
159
  public void file_save_as() {
160
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
161
  }
162
163
  public void file_save_all() {
164
    getMainPane().saveAll();
165
  }
166
167
  /**
168
   * Converts the actively edited file in the given file format.
169
   *
170
   * @param format The destination file format.
171
   */
172
  private void file_export( final ExportFormat format ) {
173
    file_export( format, false );
174
  }
175
176
  /**
177
   * Converts one or more files into the given file format. If {@code dir}
178
   * is set to true, this will first append all files in the same directory
179
   * as the actively edited file.
180
   *
181
   * @param format The destination file format.
182
   * @param dir    Export all files in the actively edited file's directory.
183
   */
184
  private void file_export( final ExportFormat format, final boolean dir ) {
185
    final var main = getMainPane();
186
    final var editor = main.getTextEditor();
187
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
188
    final var filename = format.toExportFilename( editor.getPath() );
189
    final var exportParent = exported.get().toPath().getParent();
190
    final var editorParent = editor.getPath().getParent();
191
    final var userHomeParent = USER_DIRECTORY.toPath();
192
    final var exportPath = exportParent != null
193
      ? exportParent
194
      : editorParent != null
195
      ? editorParent
196
      : userHomeParent;
197
198
    final var selected = PDF_DEFAULT
199
      .getName()
200
      .equals( exported.get().getName() );
201
    final var selection = pickFile(
202
      selected
203
        ? filename
204
        : exported.get(),
205
      exportPath,
206
      FILE_EXPORT
207
    );
208
209
    selection.ifPresent( files -> {
210
      editor.save();
211
212
      final var sourceFile = files.get( 0 );
213
      final var sourcePath = sourceFile.toPath();
214
      final var document = dir ? append( editor ) : editor.getText();
215
      final var context = main.createProcessorContext( sourcePath, format );
216
217
      final var task = new Task<Path>() {
218
        @Override
219
        protected Path call() throws Exception {
220
          final var chain = createProcessors( context );
221
          final var export = chain.apply( document );
222
223
          // Processors can export binary files. In such cases, processors
224
          // return null to prevent further processing.
225
          return export == null ? null : writeString( sourcePath, export );
226
        }
227
      };
228
229
      task.setOnSucceeded(
230
        e -> {
231
          // Remember the exported file name for next time.
232
          exported.setValue( sourceFile );
233
234
          final var result = task.getValue();
235
236
          // Binary formats must notify users of success independently.
237
          if( result != null ) {
238
            clue( "Main.status.export.success", result );
239
          }
240
        }
241
      );
242
243
      task.setOnFailed( e -> {
244
        final var ex = task.getException();
245
        clue( ex );
246
247
        if( ex instanceof TypeNotPresentException ) {
248
          fireExportFailedEvent();
249
        }
250
      } );
251
252
      sExecutor.execute( task );
253
    } );
254
  }
255
256
  /**
257
   * @param dir {@code true} means to export all files in the active file
258
   *            editor's directory; {@code false} means to export only the
259
   *            actively edited file.
260
   */
261
  private void file_export_pdf( final boolean dir ) {
262
    final var workspace = getWorkspace();
263
    final var themes = workspace.getFile(
264
      KEY_TYPESET_CONTEXT_THEMES_PATH
265
    );
266
    final var theme = workspace.stringProperty(
267
      KEY_TYPESET_CONTEXT_THEME_SELECTION
268
    );
269
    final var chapters = workspace.stringProperty(
270
      KEY_TYPESET_CONTEXT_CHAPTERS
271
    );
272
    final var settings = ExportSettings
273
      .builder()
274
      .with( ExportSettings.Mutator::setTheme, theme )
275
      .with( ExportSettings.Mutator::setChapters, chapters )
276
      .build();
277
278
    // Don't re-validate the typesetter installation each time. If the
279
    // user mucks up the typesetter installation, it'll get caught the
280
    // next time the application is started. Don't use |= because it
281
    // won't short-circuit.
282
    mCanTypeset = mCanTypeset || Typesetter.canRun();
283
284
    if( mCanTypeset ) {
285
      // If the typesetter is installed, allow the user to select a theme. If
286
      // the themes aren't installed, a status message will appear.
287
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
288
        file_export( APPLICATION_PDF, dir );
289
      }
290
    }
291
    else {
292
      fireExportFailedEvent();
293
    }
294
  }
295
296
  public void file_export_pdf() {
297
    file_export_pdf( false );
298
  }
299
300
  public void file_export_pdf_dir() {
301
    file_export_pdf( true );
302
  }
303
304
  public void file_export_html_svg() {
305
    file_export( HTML_TEX_SVG );
306
  }
307
308
  public void file_export_html_tex() {
309
    file_export( HTML_TEX_DELIMITED );
310
  }
311
312
  public void file_export_xhtml_tex() {
313
    file_export( XHTML_TEX );
314
  }
315
316
  private void fireExportFailedEvent() {
317
    runLater( ExportFailedEvent::fire );
318
  }
319
320
  public void file_exit() {
321
    final var window = getWindow();
322
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
323
  }
324
325
  public void edit_undo() {
326
    getActiveTextEditor().undo();
327
  }
328
329
  public void edit_redo() {
330
    getActiveTextEditor().redo();
331
  }
332
333
  public void edit_cut() {
334
    getActiveTextEditor().cut();
335
  }
336
337
  public void edit_copy() {
338
    getActiveTextEditor().copy();
339
  }
340
341
  public void edit_paste() {
342
    getActiveTextEditor().paste();
343
  }
344
345
  public void edit_select_all() {
346
    getActiveTextEditor().selectAll();
347
  }
348
349
  public void edit_find() {
350
    final var nodes = getMainScene().getStatusBar().getLeftItems();
351
352
    if( nodes.isEmpty() ) {
353
      final var searchBar = new SearchBar();
354
355
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
356
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
357
358
      searchBar.setOnCancelAction( event -> {
359
        final var editor = getActiveTextEditor();
360
        nodes.remove( searchBar );
361
        editor.unstylize( STYLE_SEARCH );
362
        editor.getNode().requestFocus();
363
      } );
364
365
      searchBar.addInputListener( ( c, o, n ) -> {
366
        if( n != null && !n.isEmpty() ) {
367
          mSearchModel.search( n, getActiveTextEditor().getText() );
368
        }
369
      } );
370
371
      searchBar.setOnNextAction( event -> edit_find_next() );
372
      searchBar.setOnPrevAction( event -> edit_find_prev() );
373
374
      nodes.add( searchBar );
375
      searchBar.requestFocus();
376
    }
377
    else {
378
      nodes.clear();
379
    }
380
  }
381
382
  public void edit_find_next() {
383
    mSearchModel.advance();
384
  }
385
386
  public void edit_find_prev() {
387
    mSearchModel.retreat();
388
  }
389
390
  public void edit_preferences() {
391
    try {
392
      new PreferencesController( getWorkspace() ).show();
393
    } catch( final Exception ex ) {
394
      clue( ex );
395
    }
396
  }
397
398
  public void format_bold() {
399
    getActiveTextEditor().bold();
400
  }
401
402
  public void format_italic() {
403
    getActiveTextEditor().italic();
404
  }
405
406
  public void format_monospace() {
407
    getActiveTextEditor().monospace();
408
  }
409
410
  public void format_superscript() {
411
    getActiveTextEditor().superscript();
412
  }
413
414
  public void format_subscript() {
415
    getActiveTextEditor().subscript();
416
  }
417
418
  public void format_strikethrough() {
419
    getActiveTextEditor().strikethrough();
420
  }
421
422
  public void insert_blockquote() {
423
    getActiveTextEditor().blockquote();
424
  }
425
426
  public void insert_code() {
427
    getActiveTextEditor().code();
428
  }
429
430
  public void insert_fenced_code_block() {
431
    getActiveTextEditor().fencedCodeBlock();
432
  }
433
434
  public void insert_link() {
435
    insertObject( createLinkDialog() );
436
  }
437
438
  public void insert_image() {
439
    insertObject( createImageDialog() );
440
  }
441
442
  private void insertObject( final Dialog<String> dialog ) {
443
    final var textArea = getActiveTextEditor().getTextArea();
444
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
445
  }
446
447
  private Dialog<String> createLinkDialog() {
448
    return new LinkDialog( getWindow(), createHyperlinkModel() );
449
  }
450
451
  private Dialog<String> createImageDialog() {
452
    final var path = getActiveTextEditor().getPath();
453
    final var parentDir = path.getParent();
454
    return new ImageDialog( getWindow(), parentDir );
455
  }
456
457
  /**
458
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
459
   * the Markdown AST.
460
   *
461
   * @return An instance containing the link URL and display text.
462
   */
463
  private HyperlinkModel createHyperlinkModel() {
464
    final var context = getMainPane().createProcessorContext();
465
    final var editor = getActiveTextEditor();
466
    final var textArea = editor.getTextArea();
467
    final var selectedText = textArea.getSelectedText();
468
469
    // Convert current paragraph to Markdown nodes.
470
    final var mp = MarkdownProcessor.create( context );
471
    final var p = textArea.getCurrentParagraph();
472
    final var paragraph = textArea.getText( p );
473
    final var node = mp.toNode( paragraph );
474
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
475
    final var link = visitor.process( node );
476
477
    if( link != null ) {
478
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
479
    }
480
481
    return createHyperlinkModel( link, selectedText );
482
  }
483
484
  private HyperlinkModel createHyperlinkModel(
485
    final Link link, final String selection ) {
486
487
    return link == null
488
      ? new HyperlinkModel( selection, "https://localhost" )
489
      : new HyperlinkModel( link );
490
  }
491
492
  public void insert_heading_1() {
493
    insert_heading( 1 );
494
  }
495
496
  public void insert_heading_2() {
497
    insert_heading( 2 );
498
  }
499
500
  public void insert_heading_3() {
501
    insert_heading( 3 );
502
  }
503
504
  private void insert_heading( final int level ) {
505
    getActiveTextEditor().heading( level );
506
  }
507
508
  public void insert_unordered_list() {
509
    getActiveTextEditor().unorderedList();
510
  }
511
512
  public void insert_ordered_list() {
513
    getActiveTextEditor().orderedList();
514
  }
515
516
  public void insert_horizontal_rule() {
517
    getActiveTextEditor().horizontalRule();
518
  }
519
520
  public void definition_create() {
521
    getActiveTextDefinition().createDefinition();
522
  }
523
524
  public void definition_rename() {
525
    getActiveTextDefinition().renameDefinition();
526
  }
527
528
  public void definition_delete() {
529
    getActiveTextDefinition().deleteDefinitions();
530
  }
531
532
  public void definition_autoinsert() {
533
    getMainPane().autoinsert();
534
  }
535
536
  public void view_refresh() {
537
    getMainPane().viewRefresh();
538
  }
539
540
  public void view_preview() {
541
    getMainPane().viewPreview();
542
  }
543
544
  public void view_outline() {
545
    getMainPane().viewOutline();
546
  }
547
548
  public void view_files() { getMainPane().viewFiles(); }
528549
529550
  public void view_statistics() {
A src/main/java/com/keenwrite/ui/clipboard/Clipboard.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.ui.clipboard;
3
4
import javafx.scene.input.ClipboardContent;
5
6
import static javafx.scene.input.Clipboard.getSystemClipboard;
7
8
/**
9
 * Responsible for pasting into the computer's clipboard.
10
 */
11
public class Clipboard {
12
  /**
13
   * Copies the given text into the clipboard, overwriting all data.
14
   *
15
   * @param text The text to insert into the clipboard.
16
   */
17
  public static void write( final String text ) {
18
    final var contents = new ClipboardContent();
19
    contents.putString( text );
20
    getSystemClipboard().setContent( contents );
21
  }
22
23
  /**
24
   * Delegates to {@link #write(String)}.
25
   *
26
   * @see #write(String)
27
   */
28
  public static void write( final StringBuilder text ) {
29
    write( text.toString() );
30
  }
31
}
132
M src/main/java/com/keenwrite/ui/dialogs/ExportDialog.java
3939
import static javafx.geometry.Pos.CENTER;
4040
import static javafx.scene.control.ButtonType.OK;
41
import static org.codehaus.plexus.util.StringUtils.abbreviate;
41
import static org.apache.commons.lang3.StringUtils.abbreviate;
4242
4343
/**
...
7171
7272
    setResultConverter( button -> button == OK ? settings : null );
73
    initComboBox( mComboBox, mSettings, readThemes( themesDir ) );
7473
75
    mPane.add( createLabel( "Dialog.typesetting.settings.theme" ), 0, 1 );
76
    mPane.add( mComboBox, 1, 1 );
74
    final var themes = readThemes( themesDir );
75
76
    if( !themes.isEmpty() ) {
77
      initComboBox( mComboBox, mSettings, readThemes( themesDir ) );
78
79
      mPane.add( createLabel( "Dialog.typesetting.settings.theme" ), 0, 1 );
80
      mPane.add( mComboBox, 1, 1 );
81
    }
82
    else {
83
      clue( "Dialog.typesetting.settings.themes.missing",
84
            themesDir.getAbsolutePath() );
85
    }
7786
7887
    var title = "Dialog.typesetting.settings.header.";
M src/main/java/com/keenwrite/ui/explorer/FilePickerFactory.java
8686
    public void setInitialFilename( final File file ) {
8787
      assert file != null;
88
8889
      mChooser.setInitialFileName( file.getName() );
8990
    }
9091
9192
    @Override
9293
    public void setInitialDirectory( final Path path ) {
9394
      assert path != null;
94
      mChooser.setInitialDirectory( path.toFile() );
95
96
      final var file = path.toFile();
97
98
      mChooser.setInitialDirectory(
99
        file.exists() ? file : new File( System.getProperty( "user.home" ) )
100
      );
95101
    }
96102
M src/main/java/com/keenwrite/ui/logging/LogView.java
33
44
import com.keenwrite.events.StatusEvent;
5
import com.keenwrite.ui.clipboard.Clipboard;
56
import javafx.beans.property.SimpleStringProperty;
67
import javafx.beans.property.StringProperty;
78
import javafx.collections.ObservableList;
89
import javafx.scene.control.*;
9
import javafx.scene.input.ClipboardContent;
1010
import javafx.scene.input.KeyCodeCombination;
1111
import javafx.stage.Stage;
...
2929
import static javafx.scene.control.ButtonType.OK;
3030
import static javafx.scene.control.SelectionMode.MULTIPLE;
31
import static javafx.scene.input.Clipboard.getSystemClipboard;
3231
import static javafx.scene.input.KeyCode.C;
3332
import static javafx.scene.input.KeyCode.INSERT;
...
249248
    }
250249
251
    final var contents = new ClipboardContent();
252
    contents.putString( sb.toString() );
253
    getSystemClipboard().setContent( contents );
250
    Clipboard.write( sb );
254251
  }
255252
}
M src/main/java/com/keenwrite/ui/spelling/TextEditorSpellChecker.java
7070
    final var offset = change.getPosition();
7171
    final var position = editor.offsetToPosition( offset, Forward );
72
    final var paraId = position.getMajor();
73
    final var paragraph = editor.getParagraph( paraId );
74
    final var text = paragraph.getText();
72
    var paraId = position.getMajor();
73
    var paragraph = editor.getParagraph( paraId );
74
    var text = paragraph.getText();
75
76
    // If the current paragraph is blank, it may mean the caret is at the
77
    // start of a new paragraph (i.e., a blank line). Spellcheck the "next"
78
    // paragraph, instead.
79
    if( text.isBlank() ) {
80
      paraId++;
81
      paragraph = editor.getParagraph( paraId );
82
      text = paragraph.getText();
83
    }
7584
7685
    // Prevent doubling-up styles.
M src/main/java/com/keenwrite/util/AlphanumComparator.java
114114
        if( (result = thisChunkLength - thatChunkLength) == 0 ) {
115115
          for( var i = 0; i < thisChunkLength; i++ ) {
116
            final var diff = thisChunk.charAt( i ) - thatChunk.charAt( i );
117
            result = diff;
116
            result = thisChunk.charAt( i ) - thatChunk.charAt( i );
118117
119118
            if( result != 0 ) {
A src/main/java/com/keenwrite/util/DataTypeConverter.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.util;
3
4
import java.security.MessageDigest;
5
import java.security.NoSuchAlgorithmException;
6
7
import static java.nio.charset.StandardCharsets.US_ASCII;
8
import static java.nio.charset.StandardCharsets.UTF_8;
9
10
/**
11
 * Responsible for converting various data types into other representations.
12
 */
13
public final class DataTypeConverter {
14
  private static final byte[] HEX = "0123456789ABCDEF".getBytes( US_ASCII );
15
16
  /**
17
   * Returns a hexadecimal number that represents the bit sequences provided
18
   * in the given array of bytes.
19
   *
20
   * @param bytes The bytes to convert to a hexadecimal string.
21
   * @return An uppercase-encoded hexadecimal number.
22
   */
23
  public static String toHex( final byte[] bytes ) {
24
    final var hexChars = new byte[ bytes.length * 2 ];
25
    final var len = bytes.length;
26
27
    for( var i = 0; i < len; i++ ) {
28
      final var digit = bytes[ i ] & 0xFF;
29
30
      hexChars[ (i << 1) ] = HEX[ digit >>> 4 ];
31
      hexChars[ (i << 1) + 1 ] = HEX[ digit & 0x0F ];
32
    }
33
34
    return new String( hexChars, UTF_8 );
35
  }
36
37
  /**
38
   * Hashes a string using the SHA-1 algorithm.
39
   *
40
   * @param s The string to has.
41
   * @return The hashed string.
42
   * @throws NoSuchAlgorithmException Could not find the SHA-1 algorithm.
43
   */
44
  public static byte[] hash( final String s ) throws NoSuchAlgorithmException {
45
    final var digest = MessageDigest.getInstance( "SHA-1" );
46
    return digest.digest( s.getBytes() );
47
  }
48
}
149
M src/main/java/com/keenwrite/util/ProtocolScheme.java
4848
      return getProtocol( new URI( uri ) );
4949
    } catch( final Exception ex ) {
50
      // Using double-slashes is a short-hand to instruct the browser to
50
      // Using double-slashes is a shorthand to instruct the browser to
5151
      // reference a resource using the parent URL's security model. This
5252
      // is known as a protocol-relative URL.
A src/main/java/com/keenwrite/util/Time.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.util;
3
4
import java.time.Duration;
5
6
import static java.lang.String.format;
7
import static java.util.concurrent.TimeUnit.*;
8
9
/**
10
 * Responsible for time-related functionality.
11
 */
12
public final class Time {
13
  /**
14
   * Converts an elapsed time to a human-readable format (hours, minutes,
15
   * seconds, and milliseconds).
16
   *
17
   * @param duration An elapsed time.
18
   * @return Human-readable elapsed time.
19
   */
20
  public static String toElapsedTime( final Duration duration ) {
21
    final var elapsed = duration.toMillis();
22
    final var hours = MILLISECONDS.toHours( elapsed );
23
    final var eHours = elapsed - HOURS.toMillis( hours );
24
    final var minutes = MILLISECONDS.toMinutes( eHours );
25
    final var eMinutes = eHours - MINUTES.toMillis( minutes );
26
    final var seconds = MILLISECONDS.toSeconds( eMinutes );
27
    final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
28
    final var milliseconds = MILLISECONDS.toMillis( eSeconds );
29
30
    return format( "%02d:%02d:%02d.%03d",
31
                   hours, minutes, seconds, milliseconds );
32
  }
33
}
134
M src/main/module-info.txt
1010
  requires annotations;
1111
12
  requires batik.anim;
13
  requires batik.bridge;
14
  requires batik.css;
15
  requires batik.gvt;
16
  requires batik.transcoder;
17
  requires batik.util;
12
  requires echosvg.anim;
13
  requires echosvg.bridge;
14
  requires echosvg.css;
15
  requires echosvg.gvt;
16
  requires echosvg.transcoder;
17
  requires echosvg.util;
1818
1919
  requires com.dlsc.formsfx;
...
4242
  requires info.picocli;
4343
  requires jsymspell;
44
  requires plexus.utils;
4544
  requires tiwulfx.dock;
4645
  requires wellbehavedfx;
4746
  requires xml.apis.ext;
4847
  requires java.logging;
4948
}
50
5149
M src/main/resources/bootstrap.properties
1
# Used by the Gradle build script and the application.
21
application.title=KeenWrite
3
2
container.version=2.11.5
43
D src/main/resources/com/keenwrite/build.sh
1
#!/bin/bash
2
3
INKSCAPE="/usr/bin/inkscape"
4
PNG_COMPRESS="optipng"
5
PNG_COMPRESS_OPTS="-o9 *png"
6
ICO_TOOL="icotool"
7
ICO_TOOL_OPTS="-c -o ../../../../../icons/logo.ico logo64.png"
8
9
declare -a SIZES=("16" "32" "64" "128" "256" "512")
10
11
for i in "${SIZES[@]}"; do
12
  # -y: export background opacity 0
13
  $INKSCAPE -y 0 -w "${i}" --export-overwrite --export-type=png -o "logo${i}.png" "logo.svg" 
14
done
15
16
# Compess the PNG images.
17
which $PNG_COMPRESS && $PNG_COMPRESS $PNG_COMPRESS_OPTS
18
19
# Generate an ICO file.
20
which $ICO_TOOL && $ICO_TOOL $ICO_TOOL_OPTS
21
221
M src/main/resources/com/keenwrite/messages.properties
2828
workspace.typeset.context.clean.desc=Delete ancillary files after an unsuccessful export.
2929
workspace.typeset.context.clean.title=Purge
30
workspace.typeset.typography=Typography
31
workspace.typeset.typography.quotes=Quotation Marks
32
workspace.typeset.typography.quotes.desc=Convert straight quotes into curly quotes and primes.
33
workspace.typeset.typography.quotes.title=Curl
34
35
workspace.r=R
36
workspace.r.script=Startup Script
37
workspace.r.script.desc=Script runs prior to executing R statements within the document.
38
workspace.r.dir=Working Directory
39
workspace.r.dir.desc=Value assigned to v$application$r$working$directory and usable in the startup script.
40
workspace.r.dir.title=Directory
41
workspace.r.delimiter.began=Delimiter Prefix
42
workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables.
43
workspace.r.delimiter.began.title=Opening
44
workspace.r.delimiter.ended=Delimiter Suffix
45
workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables.
46
workspace.r.delimiter.ended.title=Closing
47
48
workspace.images=Images
49
workspace.images.dir=Absolute Directory
50
workspace.images.dir.desc=Path to search for local file system images.
51
workspace.images.dir.title=Directory
52
workspace.images.order=Extensions
53
workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
54
workspace.images.order.title=Extensions
55
workspace.images.resize=Resize
56
workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically.
57
workspace.images.resize.title=Resize
58
workspace.images.server=Diagram Server
59
workspace.images.server.desc=Server used to generate diagrams (e.g., kroki.io).
60
workspace.images.server.title=Name
61
62
workspace.definition=Variable
63
workspace.definition.path=File name
64
workspace.definition.path.desc=Absolute path to interpolated string variables.
65
workspace.definition.path.title=Path
66
workspace.definition.delimiter.began=Delimiter Prefix
67
workspace.definition.delimiter.began.desc=Indicates when a variable name is starting.
68
workspace.definition.delimiter.began.title=Opening
69
workspace.definition.delimiter.ended=Delimiter Suffix
70
workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending.
71
workspace.definition.delimiter.ended.title=Closing
72
73
workspace.ui.skin=Skins
74
workspace.ui.skin.selection=Bundled
75
workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light).
76
workspace.ui.skin.selection.title=Name
77
workspace.ui.skin.custom=Custom
78
workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
79
workspace.ui.skin.custom.title=Path
80
81
workspace.ui.preview=Preview
82
workspace.ui.preview.stylesheet=Stylesheet
83
workspace.ui.preview.stylesheet.desc=User-defined HTML cascading stylesheet file.
84
workspace.ui.preview.stylesheet.title=Path
85
86
workspace.ui.font=Fonts
87
workspace.ui.font.editor=Editor Font
88
workspace.ui.font.editor.name=Name
89
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
90
workspace.ui.font.editor.name.title=Family
91
workspace.ui.font.editor.size=Size
92
workspace.ui.font.editor.size.desc=Font size.
93
workspace.ui.font.editor.size.title=Points
94
workspace.ui.font.preview=Preview Font
95
workspace.ui.font.preview.name=Name
96
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
97
workspace.ui.font.preview.name.title=Family
98
workspace.ui.font.preview.size=Size
99
workspace.ui.font.preview.size.desc=Font size.
100
workspace.ui.font.preview.size.title=Points
101
workspace.ui.font.preview.mono.name=Name
102
workspace.ui.font.preview.mono.name.desc=Monospace font name.
103
workspace.ui.font.preview.mono.name.title=Family
104
workspace.ui.font.preview.mono.size=Size
105
workspace.ui.font.preview.mono.size.desc=Monospace font size.
106
workspace.ui.font.preview.mono.size.title=Points
107
108
workspace.language=Language
109
workspace.language.locale=Internationalization
110
workspace.language.locale.desc=Language for application and HTML export.
111
workspace.language.locale.title=Locale
112
113
# ########################################################################
114
# Editor actions
115
# ########################################################################
116
117
Editor.spelling.check.matches.none=No suggestions for ''{0}'' found.
118
Editor.spelling.check.matches.okay=The spelling for ''{0}'' appears to be correct.
119
120
# ########################################################################
121
# Menu Bar
122
# ########################################################################
123
124
Main.menu.file=_File
125
Main.menu.edit=_Edit
126
Main.menu.insert=_Insert
127
Main.menu.format=Forma_t
128
Main.menu.definition=_Variable
129
Main.menu.view=Vie_w
130
Main.menu.help=_Help
131
132
# ########################################################################
133
# Detachable Tabs
134
# ########################################################################
135
136
# {0} is the application title; {1} is a unique window ID.
137
Detach.tab.title={0} - {1}
138
139
# ########################################################################
140
# Status Bar
141
# ########################################################################
142
143
Main.status.text.offset=offset
144
Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
145
Main.status.state.default=OK
146
Main.status.export.success=Saved as ''{0}''
147
148
Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
149
150
Main.status.error.parse=Evaluation error: {0}
151
Main.status.error.def.blank=Move the caret to a word before inserting a variable
152
Main.status.error.def.empty=Create a variable before inserting one
153
Main.status.error.def.missing=No variable value found for ''{0}''
154
Main.status.error.r=Error with [{0}...]: {1}
155
Main.status.error.file.missing=Not found: ''{0}''
156
Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
157
158
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
159
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
160
161
Main.status.error.undo=Cannot undo; beginning of undo history reached
162
Main.status.error.redo=Cannot redo; end of redo history reached
163
164
Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'')
165
Main.status.error.theme.name=Cannot find theme name for ''{0}''
166
167
Main.status.image.request.init=Initializing HTTP request
168
Main.status.image.request.fetch=Requesting content type from ''{0}''
169
Main.status.image.request.success=Determined content type ''{0}''
170
Main.status.image.request.error.media=No media type for ''{0}''
171
Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
172
173
Main.status.image.xhtml.image.download=Downloading ''{0}''
174
Main.status.image.xhtml.image.resolve=Qualify path for ''{0}''
175
Main.status.image.xhtml.image.found=Found image ''{0}''
176
Main.status.image.xhtml.image.missing=Missing image ''{0}''
177
178
Main.status.font.search.missing=No font name starting with ''{0}'' was found
179
180
Main.status.export.concat=Concatenating ''{0}''
181
Main.status.export.concat.parent=No parent directory found for ''{0}''
182
Main.status.export.concat.extension=File name must have an extension ''{0}''
183
Main.status.export.concat.io=Could not read from ''{0}''
184
185
Main.status.typeset.create=Creating typesetter
186
Main.status.typeset.xhtml=Export document as XHTML
187
Main.status.typeset.began=Started typesetting ''{0}''
188
Main.status.typeset.failed=Could not generate PDF file
189
Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
190
Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
191
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
192
193
Main.status.lexicon.loading=Loading lexicon: {0} words
194
Main.status.lexicon.loaded=Loaded lexicon: {0} words
195
196
# ########################################################################
197
# Search Bar
198
# ########################################################################
199
200
Main.search.stop.tooltip=Close search bar
201
Main.search.stop.icon=CLOSE
202
Main.search.next.tooltip=Find next match
203
Main.search.next.icon=CHEVRON_DOWN
204
Main.search.prev.tooltip=Find previous match
205
Main.search.prev.icon=CHEVRON_UP
206
Main.search.find.tooltip=Search document for text
207
Main.search.find.icon=SEARCH
208
Main.search.match.none=No matches
209
Main.search.match.some={0} of {1} matches
210
211
# ########################################################################
212
# Definition Pane and its Tree View
213
# ########################################################################
214
215
Definition.menu.add.default=Undefined
216
217
# ########################################################################
218
# Variable Definitions Pane
219
# ########################################################################
220
221
Pane.definition.node.root.title=Variables
222
223
# ########################################################################
224
# HTML Preview Pane
225
# ########################################################################
226
227
Pane.preview.title=Preview
228
229
# ########################################################################
230
# Document Outline Pane
231
# ########################################################################
232
233
Pane.outline.title=Outline
234
235
# ########################################################################
236
# File Manager Pane
237
# ########################################################################
238
239
Pane.files.title=Files
240
241
# ########################################################################
242
# Document Outline Pane
243
# ########################################################################
244
245
Pane.statistics.title=Statistics
246
247
# ########################################################################
248
# Failure messages with respect to YAML files.
249
# ########################################################################
250
251
yaml.error.open=Could not open YAML file (ensure non-empty file).
252
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
253
yaml.error.missing=Empty variable value for key ''{0}''.
254
yaml.error.tree.form=Unassigned variable near ''{0}''.
255
256
# ########################################################################
257
# Text Resource
258
# ########################################################################
259
260
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
261
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
262
263
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
264
TextResource.saveFailed.title=Save
265
266
# ########################################################################
267
# File Open
268
# ########################################################################
269
270
Dialog.file.choose.open.title=Open File
271
Dialog.file.choose.save.title=Save File
272
Dialog.file.choose.export.title=Export File
273
Dialog.file.choose.import.title=Import File
274
275
Dialog.file.choose.filter.title.source=Source Files
276
Dialog.file.choose.filter.title.definition=Variable Files
277
Dialog.file.choose.filter.title.xml=XML Files
278
Dialog.file.choose.filter.title.all=All Files
279
280
# ########################################################################
281
# Browse File
282
# ########################################################################
283
284
BrowseFileButton.chooser.title=Open local file
285
BrowseFileButton.chooser.allFilesFilter=All Files
286
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
287
288
# ########################################################################
289
# Browse Directory
290
# ########################################################################
291
292
BrowseDirectoryButton.chooser.title=Open local directory
293
BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
294
295
# ########################################################################
296
# Alert Dialog
297
# ########################################################################
298
299
Alert.file.close.title=Close
300
Alert.file.close.text=Save changes to {0}?
301
302
# ########################################################################
303
# Typesetting Alert Dialog
304
# ########################################################################
305
306
Alert.typesetter.missing.title=Missing Typesetter
307
Alert.typesetter.missing.header=Install typesetter
308
Alert.typesetter.missing.version=for {0} {1} {2}-bit
309
Alert.typesetter.missing.installer.text=Download and install ConTeXt
310
Alert.typesetter.missing.installer.url=https://wiki.contextgarden.net/Installation
311
312
# ########################################################################
313
# Image Dialog
314
# ########################################################################
315
316
Dialog.image.title=Image
317
Dialog.image.chooser.imagesFilter=Images
318
Dialog.image.previewLabel.text=Markdown Preview\:
319
Dialog.image.textLabel.text=Alternate Text\:
320
Dialog.image.titleLabel.text=Title (tooltip)\:
321
Dialog.image.urlLabel.text=Image URL\:
322
323
# ########################################################################
324
# Hyperlink Dialog
325
# ########################################################################
326
327
Dialog.link.title=Link
328
Dialog.link.previewLabel.text=Markdown Preview\:
329
Dialog.link.textLabel.text=Link Text\:
330
Dialog.link.titleLabel.text=Title (tooltip)\:
331
Dialog.link.urlLabel.text=Link URL\:
332
333
# ########################################################################
334
# Typesetting Settings Dialog
335
# ########################################################################
336
337
Dialog.typesetting.settings.title=Typesetting export settings
338
Dialog.typesetting.settings.header.single=Export current document
339
Dialog.typesetting.settings.theme=Theme
340
341
Dialog.typesetting.settings.header.multiple=Export multiple documents
342
Dialog.typesetting.settings.chapters=Chapters (e.g., 1-3, 5, 7-)
343
344
# ########################################################################
345
# About Dialog
346
# ########################################################################
347
348
Dialog.about.title=About {0}
349
Dialog.about.header={0}
350
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
351
352
# ########################################################################
353
# Application Actions
354
# ########################################################################
355
356
Action.file.new.description=Create a new file
357
Action.file.new.accelerator=Shortcut+N
358
Action.file.new.icon=FILE_ALT
359
Action.file.new.text=_New
360
361
Action.file.open.description=Open a new file
362
Action.file.open.accelerator=Shortcut+O
363
Action.file.open.text=_Open...
364
Action.file.open.icon=FOLDER_OPEN_ALT
365
366
Action.file.close.description=Close the current document
367
Action.file.close.accelerator=Shortcut+W
368
Action.file.close.text=_Close
369
370
Action.file.close_all.description=Close all open documents
371
Action.file.close_all.accelerator=Ctrl+F4
372
Action.file.close_all.text=Close All
373
374
Action.file.save.description=Save the document
375
Action.file.save.accelerator=Shortcut+S
376
Action.file.save.text=_Save
377
Action.file.save.icon=FLOPPY_ALT
378
379
Action.file.save_as.description=Rename the current document
380
Action.file.save_as.text=Save _As
381
382
Action.file.save_all.description=Save all open documents
383
Action.file.save_all.accelerator=Shortcut+Shift+S
384
Action.file.save_all.text=Save A_ll
385
386
Action.file.export.pdf.description=Typeset the document
387
Action.file.export.pdf.accelerator=Shortcut+P
388
Action.file.export.pdf.text=_PDF
389
Action.file.export.pdf.icon=FILE_PDF_ALT
390
391
Action.file.export.pdf.dir.description=Typeset files in document directory
392
Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P
393
Action.file.export.pdf.dir.text=_Joined PDF
394
Action.file.export.pdf.dir.icon=FILE_PDF_ALT
395
396
Action.file.export.html_svg.description=Export the current document as HTML + SVG
397
Action.file.export.text=_Export As
398
Action.file.export.html_svg.text=HTML and S_VG
399
400
Action.file.export.html_tex.description=Export the current document as HTML + TeX
401
Action.file.export.html_tex.text=HTML and _TeX
402
403
Action.file.export.xhtml_tex.description=Export as XHTML + TeX
404
Action.file.export.xhtml_tex.text=_XHTML and TeX
405
406
Action.file.export.markdown.description=Export the current document as Markdown
407
Action.file.export.markdown.text=Markdown
408
409
Action.file.exit.description=Quit the application
410
Action.file.exit.text=E_xit
411
412
413
Action.edit.undo.description=Undo the previous edit
414
Action.edit.undo.accelerator=Shortcut+Z
415
Action.edit.undo.text=_Undo
416
Action.edit.undo.icon=UNDO
417
418
Action.edit.redo.description=Redo the previous edit
419
Action.edit.redo.accelerator=Shortcut+Y
420
Action.edit.redo.text=_Redo
421
Action.edit.redo.icon=REPEAT
422
423
Action.edit.cut.description=Delete the selected text or line
424
Action.edit.cut.accelerator=Shortcut+X
425
Action.edit.cut.text=Cu_t
426
Action.edit.cut.icon=CUT
427
428
Action.edit.copy.description=Copy the selected text
429
Action.edit.copy.accelerator=Shortcut+C
430
Action.edit.copy.text=_Copy
431
Action.edit.copy.icon=COPY
432
433
Action.edit.paste.description=Paste from the clipboard
434
Action.edit.paste.accelerator=Shortcut+V
435
Action.edit.paste.text=_Paste
436
Action.edit.paste.icon=PASTE
437
438
Action.edit.select_all.description=Highlight the current document text
439
Action.edit.select_all.accelerator=Shortcut+A
440
Action.edit.select_all.text=Select _All
441
442
Action.edit.find.description=Search for text in the document
443
Action.edit.find.accelerator=Shortcut+F
444
Action.edit.find.text=_Find
445
Action.edit.find.icon=SEARCH
446
447
Action.edit.find_next.description=Find next occurrence
448
Action.edit.find_next.accelerator=F3
449
Action.edit.find_next.text=Find _Next
450
451
Action.edit.find_prev.description=Find previous occurrence
452
Action.edit.find_prev.accelerator=Shift+F3
453
Action.edit.find_prev.text=Find _Prev
454
455
Action.edit.preferences.description=Edit user preferences
456
Action.edit.preferences.accelerator=Ctrl+Alt+S
457
Action.edit.preferences.text=_Preferences
458
459
460
Action.format.bold.description=Insert strong text
461
Action.format.bold.accelerator=Shortcut+B
462
Action.format.bold.text=_Bold
463
Action.format.bold.icon=BOLD
464
465
Action.format.italic.description=Insert text emphasis
466
Action.format.italic.accelerator=Shortcut+I
467
Action.format.italic.text=_Italic
468
Action.format.italic.icon=ITALIC
469
470
Action.format.monospace.description=Insert monospace text
471
Action.format.monospace.accelerator=Shortcut+`
472
Action.format.monospace.text=_Monospace
473
474
Action.format.superscript.description=Insert superscript text
475
Action.format.superscript.accelerator=Shortcut+[
476
Action.format.superscript.text=Su_perscript
477
Action.format.superscript.icon=SUPERSCRIPT
478
479
Action.format.subscript.description=Insert subscript text
480
Action.format.subscript.accelerator=Shortcut+]
481
Action.format.subscript.text=Su_bscript
482
Action.format.subscript.icon=SUBSCRIPT
483
484
Action.format.strikethrough.description=Insert struck text
485
Action.format.strikethrough.accelerator=Shortcut+T
486
Action.format.strikethrough.text=Stri_kethrough
487
Action.format.strikethrough.icon=STRIKETHROUGH
488
489
490
Action.insert.blockquote.description=Insert blockquote
491
Action.insert.blockquote.accelerator=Ctrl+Q
492
Action.insert.blockquote.text=_Blockquote
493
Action.insert.blockquote.icon=QUOTE_LEFT
494
495
Action.insert.code.description=Insert inline code
496
Action.insert.code.accelerator=Shortcut+K
497
Action.insert.code.text=Inline _Code
498
Action.insert.code.icon=CODE
499
500
Action.insert.fenced_code_block.description=Insert code block
501
Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
502
Action.insert.fenced_code_block.text=_Fenced Code Block
503
Action.insert.fenced_code_block.prompt.text=Enter code here
504
Action.insert.fenced_code_block.icon=FILE_CODE_ALT
505
506
Action.insert.link.description=Insert hyperlink
507
Action.insert.link.accelerator=Shortcut+L
508
Action.insert.link.text=_Link...
509
Action.insert.link.icon=LINK
510
511
Action.insert.image.description=Insert image
512
Action.insert.image.accelerator=Shortcut+G
513
Action.insert.image.text=_Image...
514
Action.insert.image.icon=PICTURE_ALT
515
516
Action.insert.heading.description=Insert heading level
517
Action.insert.heading.accelerator=Shortcut+
518
Action.insert.heading.icon=HEADER
519
520
Action.insert.heading_1.description=${Action.insert.heading.description} 1
521
Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1
522
Action.insert.heading_1.text=Heading _1
523
Action.insert.heading_1.icon=${Action.insert.heading.icon}
524
525
Action.insert.heading_2.description=${Action.insert.heading.description} 2
526
Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2
527
Action.insert.heading_2.text=Heading _2
528
Action.insert.heading_2.icon=${Action.insert.heading.icon}
529
530
Action.insert.heading_3.description=${Action.insert.heading.description} 3
531
Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3
532
Action.insert.heading_3.text=Heading _3
533
Action.insert.heading_3.icon=${Action.insert.heading.icon}
534
535
Action.insert.unordered_list.description=Insert bulleted list
536
Action.insert.unordered_list.accelerator=Shortcut+U
537
Action.insert.unordered_list.text=_Unordered List
538
Action.insert.unordered_list.icon=LIST_UL
539
540
Action.insert.ordered_list.description=Insert enumerated list
541
Action.insert.ordered_list.accelerator=Shortcut+Shift+O
542
Action.insert.ordered_list.text=_Ordered List
543
Action.insert.ordered_list.icon=LIST_OL
544
545
Action.insert.horizontal_rule.description=Insert horizontal rule
546
Action.insert.horizontal_rule.accelerator=Shortcut+H
547
Action.insert.horizontal_rule.text=_Horizontal Rule
548
Action.insert.horizontal_rule.icon=LIST_OL
549
550
551
Action.definition.create.description=Create a new variable
552
Action.definition.create.text=_Create
553
Action.definition.create.icon=TREE
554
Action.definition.create.tooltip=Add new item (Insert)
555
556
Action.definition.rename.description=Rename the selected variable
557
Action.definition.rename.text=_Rename
558
Action.definition.rename.icon=EDIT
559
Action.definition.rename.tooltip=Rename selected item (F2)
560
561
Action.definition.delete.description=Delete the selected variables
562
Action.definition.delete.text=De_lete
563
Action.definition.delete.icon=TRASH
564
Action.definition.delete.tooltip=Delete selected items (Delete)
565
566
Action.definition.insert.description=Insert a variable
567
Action.definition.insert.accelerator=Ctrl+Space
568
Action.definition.insert.text=_Insert
569
Action.definition.insert.icon=STAR
570
571
572
Action.view.refresh.description=Clear all caches
573
Action.view.refresh.accelerator=F5
574
Action.view.refresh.text=Refresh
575
576
Action.view.preview.description=Open document preview
577
Action.view.preview.accelerator=F6
578
Action.view.preview.text=Preview
579
580
Action.view.outline.description=Open document outline
581
Action.view.outline.accelerator=F7
582
Action.view.outline.text=Outline
583
584
Action.view.statistics.description=Open document word counts
585
Action.view.statistics.accelerator=F8
586
Action.view.statistics.text=Statistics
587
588
Action.view.files.description=Open file manager
589
Action.view.files.accelerator=Ctrl+F8
590
Action.view.files.text=Files
591
592
Action.view.menubar.description=Toggle menu bar
593
Action.view.menubar.accelerator=Ctrl+F9
594
Action.view.menubar.text=Menu bar
595
596
Action.view.toolbar.description=Toggle tool bar
597
Action.view.toolbar.accelerator=Ctrl+Shift+F9
598
Action.view.toolbar.text=Tool bar
30
workspace.typeset.context.fonts=Fonts
31
workspace.typeset.context.fonts.dir=Directory
32
workspace.typeset.context.fonts.dir.desc=Directory containing additional font files (OTF and TTF).
33
workspace.typeset.context.fonts.dir.title=Path
34
workspace.typeset.typography=Typography
35
workspace.typeset.typography.quotes=Quotation Marks
36
workspace.typeset.typography.quotes.desc=Export straight quotes and apostrophes as curled equivalents.
37
workspace.typeset.typography.quotes.title=Curl
38
39
workspace.r=R
40
workspace.r.script=Startup Script
41
workspace.r.script.desc=Script runs prior to executing R statements within the document.
42
workspace.r.dir=Working Directory
43
workspace.r.dir.desc=Value assigned to v$application$r$working$directory and usable in the startup script.
44
workspace.r.dir.title=Directory
45
workspace.r.delimiter.began=Delimiter Prefix
46
workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables.
47
workspace.r.delimiter.began.title=Opening
48
workspace.r.delimiter.ended=Delimiter Suffix
49
workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables.
50
workspace.r.delimiter.ended.title=Closing
51
52
workspace.images=Images
53
workspace.images.dir=Absolute Directory
54
workspace.images.dir.desc=Path to search for local file system images.
55
workspace.images.dir.title=Directory
56
workspace.images.cache.desc=Path to store remotely retrieved images.
57
workspace.images.cache.title=Directory
58
workspace.images.order=Extensions
59
workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
60
workspace.images.order.title=Extensions
61
workspace.images.resize=Resize
62
workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically.
63
workspace.images.resize.title=Resize
64
workspace.images.server=Diagram Server
65
workspace.images.server.desc=Server used to generate diagrams (e.g., kroki.io).
66
workspace.images.server.title=Name
67
68
workspace.definition=Variable
69
workspace.definition.path=File name
70
workspace.definition.path.desc=Absolute path to interpolated string variables.
71
workspace.definition.path.title=Path
72
workspace.definition.delimiter.began=Delimiter Prefix
73
workspace.definition.delimiter.began.desc=Indicates when a variable name is starting.
74
workspace.definition.delimiter.began.title=Opening
75
workspace.definition.delimiter.ended=Delimiter Suffix
76
workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending.
77
workspace.definition.delimiter.ended.title=Closing
78
79
workspace.ui.skin=Skins
80
workspace.ui.skin.selection=Bundled
81
workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light).
82
workspace.ui.skin.selection.title=Name
83
workspace.ui.skin.custom=Custom
84
workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
85
workspace.ui.skin.custom.title=Path
86
87
workspace.ui.preview=Preview
88
workspace.ui.preview.stylesheet=Stylesheet
89
workspace.ui.preview.stylesheet.desc=User-defined HTML cascading stylesheet file.
90
workspace.ui.preview.stylesheet.title=Path
91
92
workspace.ui.font=Fonts
93
workspace.ui.font.editor=Editor Font
94
workspace.ui.font.editor.name=Name
95
workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
96
workspace.ui.font.editor.name.title=Family
97
workspace.ui.font.editor.size=Size
98
workspace.ui.font.editor.size.desc=Font size.
99
workspace.ui.font.editor.size.title=Points
100
workspace.ui.font.preview=Preview Font
101
workspace.ui.font.preview.name=Name
102
workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
103
workspace.ui.font.preview.name.title=Family
104
workspace.ui.font.preview.size=Size
105
workspace.ui.font.preview.size.desc=Font size.
106
workspace.ui.font.preview.size.title=Points
107
workspace.ui.font.preview.mono.name=Name
108
workspace.ui.font.preview.mono.name.desc=Monospace font name.
109
workspace.ui.font.preview.mono.name.title=Family
110
workspace.ui.font.preview.mono.size=Size
111
workspace.ui.font.preview.mono.size.desc=Monospace font size.
112
workspace.ui.font.preview.mono.size.title=Points
113
114
workspace.language=Language
115
workspace.language.locale=Internationalization
116
workspace.language.locale.desc=Language for application and HTML export.
117
workspace.language.locale.title=Locale
118
119
# ########################################################################
120
# Editor actions
121
# ########################################################################
122
123
Editor.spelling.check.matches.none=No suggestions for ''{0}'' found.
124
Editor.spelling.check.matches.okay=The spelling for ''{0}'' appears to be correct.
125
126
# ########################################################################
127
# Menu Bar
128
# ########################################################################
129
130
Main.menu.file=_File
131
Main.menu.edit=_Edit
132
Main.menu.insert=_Insert
133
Main.menu.format=Forma_t
134
Main.menu.definition=_Variable
135
Main.menu.view=Vie_w
136
Main.menu.help=_Help
137
138
# ########################################################################
139
# Detachable Tabs
140
# ########################################################################
141
142
# {0} is the application title; {1} is a unique window ID.
143
Detach.tab.title={0} - {1}
144
145
# ########################################################################
146
# Status Bar
147
# ########################################################################
148
149
Main.status.text.offset=offset
150
Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
151
Main.status.state.default=OK
152
Main.status.export.success=Saved as ''{0}''
153
154
Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
155
156
Main.status.error.parse=Evaluation error: {0}
157
Main.status.error.def.blank=Move the caret to a word before inserting a variable
158
Main.status.error.def.empty=Create a variable before inserting one
159
Main.status.error.def.missing=No variable value found for ''{0}''
160
Main.status.error.r=Error with [{0}...]: {1}
161
Main.status.error.file.missing=Not found: ''{0}''
162
Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
163
164
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
165
Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
166
167
Main.status.error.undo=Cannot undo; beginning of undo history reached
168
Main.status.error.redo=Cannot redo; end of redo history reached
169
170
Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'')
171
Main.status.error.theme.name=Cannot find theme name for ''{0}''
172
173
Main.status.image.request.init=Initializing HTTP request
174
Main.status.image.request.fetch=Downloaded image ''{0}''
175
Main.status.image.request.success=Determined content type ''{0}''
176
Main.status.image.request.error.media=No media type for ''{0}''
177
Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
178
179
Main.status.image.xhtml.image.download=Downloading ''{0}''
180
Main.status.image.xhtml.image.resolve=Qualify path for ''{0}''
181
Main.status.image.xhtml.image.found=Found image ''{0}''
182
Main.status.image.xhtml.image.missing=Missing image ''{0}''
183
184
Main.status.font.search.missing=No font name starting with ''{0}'' was found
185
186
Main.status.export.concat=Concatenating ''{0}''
187
Main.status.export.concat.parent=No parent directory found for ''{0}''
188
Main.status.export.concat.extension=File name must have an extension ''{0}''
189
Main.status.export.concat.io=Could not read from ''{0}''
190
191
Main.status.typeset.create=Creating typesetter
192
Main.status.typeset.xhtml=Export document as XHTML
193
Main.status.typeset.began=Started typesetting ''{0}''
194
Main.status.typeset.failed=Could not generate PDF file
195
Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
196
Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
197
Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
198
199
Main.status.lexicon.loading=Loading lexicon: {0} words
200
Main.status.lexicon.loaded=Loaded lexicon: {0} words
201
202
# ########################################################################
203
# Search Bar
204
# ########################################################################
205
206
Main.search.stop.tooltip=Close search bar
207
Main.search.stop.icon=CLOSE
208
Main.search.next.tooltip=Find next match
209
Main.search.next.icon=CHEVRON_DOWN
210
Main.search.prev.tooltip=Find previous match
211
Main.search.prev.icon=CHEVRON_UP
212
Main.search.find.tooltip=Search document for text
213
Main.search.find.icon=SEARCH
214
Main.search.match.none=No matches
215
Main.search.match.some={0} of {1} matches
216
217
# ########################################################################
218
# Definition Pane and its Tree View
219
# ########################################################################
220
221
Definition.menu.add.default=Undefined
222
223
# ########################################################################
224
# Variable Definitions Pane
225
# ########################################################################
226
227
Pane.definition.node.root.title=Variables
228
229
# ########################################################################
230
# HTML Preview Pane
231
# ########################################################################
232
233
Pane.preview.title=Preview
234
235
# ########################################################################
236
# Document Outline Pane
237
# ########################################################################
238
239
Pane.outline.title=Outline
240
241
# ########################################################################
242
# File Manager Pane
243
# ########################################################################
244
245
Pane.files.title=Files
246
247
# ########################################################################
248
# Document Outline Pane
249
# ########################################################################
250
251
Pane.statistics.title=Statistics
252
253
# ########################################################################
254
# Failure messages with respect to YAML files.
255
# ########################################################################
256
257
yaml.error.open=Could not open YAML file (ensure non-empty file).
258
yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
259
yaml.error.missing=Empty variable value for key ''{0}''.
260
yaml.error.tree.form=Unassigned variable near ''{0}''.
261
262
# ########################################################################
263
# Text Resource
264
# ########################################################################
265
266
TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
267
TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
268
269
TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
270
TextResource.saveFailed.title=Save
271
272
# ########################################################################
273
# File Open
274
# ########################################################################
275
276
Dialog.file.choose.open.title=Open File
277
Dialog.file.choose.save.title=Save File
278
Dialog.file.choose.export.title=Export File
279
Dialog.file.choose.import.title=Import File
280
281
Dialog.file.choose.filter.title.source=Source Files
282
Dialog.file.choose.filter.title.definition=Variable Files
283
Dialog.file.choose.filter.title.xml=XML Files
284
Dialog.file.choose.filter.title.all=All Files
285
286
# ########################################################################
287
# Browse File
288
# ########################################################################
289
290
BrowseFileButton.chooser.title=Open local file
291
BrowseFileButton.chooser.allFilesFilter=All Files
292
BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
293
294
# ########################################################################
295
# Browse Directory
296
# ########################################################################
297
298
BrowseDirectoryButton.chooser.title=Open local directory
299
BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
300
301
# ########################################################################
302
# Alert Dialog
303
# ########################################################################
304
305
Alert.file.close.title=Close
306
Alert.file.close.text=Save changes to {0}?
307
308
# ########################################################################
309
# Typesetter Installation Wizard
310
# ########################################################################
311
312
Wizard.typesetter.name=ConTeXt
313
Wizard.typesetter.container.name=Podman
314
Wizard.typesetter.container.version=4.3.1
315
Wizard.typesetter.container.checksum=b741702663234ca36e1555149721580dc31ae76985d50c022a8641c6db2f5b93
316
Wizard.typesetter.themes.version=1.8.0
317
Wizard.typesetter.themes.checksum=2e6177d23210ea183f7759e5b6232f70bb1d4638a2f911beca129f877d25f92d
318
319
# STEP 1: Introduction panel (all)
320
Wizard.typesetter.all.1.install.title=Install typesetting system
321
Wizard.typesetter.all.1.install.header=Install typesetting system
322
Wizard.typesetter.all.1.install.about.container.link.lbl=${Wizard.typesetter.container.name}
323
Wizard.typesetter.all.1.install.about.container.link.url=https://podman.io
324
Wizard.typesetter.all.1.install.about.text.1=manages the container for the extensive
325
Wizard.typesetter.all.1.install.about.typesetter.link.lbl=${Wizard.typesetter.name}
326
Wizard.typesetter.all.1.install.about.typesetter.link.url=https://contextgarden.net
327
Wizard.typesetter.all.1.install.about.text.2=\
328
  typesetting software, which generates PDF files. This wizard\n\
329
  will guide you through the installation process. After each\n\
330
  step, you'll be prompted to click a button. Click Next to begin.
331
332
# STEP 2: Install container manager (Unix)
333
# Append steps to keep numbers stable; sorted programmatically.
334
Wizard.typesetter.unix.2.install.container.header=Install ${Wizard.typesetter.container.name} for Linux / macOS / Unix
335
# Copy button states
336
Wizard.typesetter.unix.2.install.container.copy.began=Copy
337
Wizard.typesetter.unix.2.install.container.copy.ended=Copied
338
Wizard.typesetter.unix.2.install.container.os=Operating System
339
Wizard.typesetter.unix.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}:
340
Wizard.typesetter.unix.2.install.container.step.1=\t1. Select this computer's ${Wizard.typesetter.unix.2.install.container.os}.
341
Wizard.typesetter.unix.2.install.container.step.2=\t2. Open a new terminal.
342
Wizard.typesetter.unix.2.install.container.step.3=\t3. Run the commands provided below in the terminal.
343
Wizard.typesetter.unix.2.install.container.step.4=\t4. Click Next to continue.
344
Wizard.typesetter.unix.2.install.container.details.prefix=See
345
Wizard.typesetter.unix.2.install.container.details.link.lbl=${Wizard.typesetter.container.name}'s instructions
346
Wizard.typesetter.unix.2.install.container.details.link.url=https://podman.io/getting-started/installation
347
Wizard.typesetter.unix.2.install.container.details.suffix=for more details.
348
Wizard.typesetter.unix.2.install.container.command.distros=14
349
Wizard.typesetter.unix.2.install.container.command.os.name.01=Arch Linux & Manjaro Linux
350
Wizard.typesetter.unix.2.install.container.command.os.text.01=sudo pacman -S podman
351
Wizard.typesetter.unix.2.install.container.command.os.name.02=Alpine Linux
352
Wizard.typesetter.unix.2.install.container.command.os.text.02=sudo apk add podman
353
Wizard.typesetter.unix.2.install.container.command.os.name.03=CentOS
354
Wizard.typesetter.unix.2.install.container.command.os.text.03=sudo yum -y install podman
355
Wizard.typesetter.unix.2.install.container.command.os.name.04=Debian
356
Wizard.typesetter.unix.2.install.container.command.os.text.04=sudo apt-get -y install podman
357
Wizard.typesetter.unix.2.install.container.command.os.name.05=Fedora
358
Wizard.typesetter.unix.2.install.container.command.os.text.05=sudo dnf -y install podman
359
Wizard.typesetter.unix.2.install.container.command.os.name.06=Gentoo
360
Wizard.typesetter.unix.2.install.container.command.os.text.06=sudo emerge app-containers/podman
361
Wizard.typesetter.unix.2.install.container.command.os.name.07=OpenEmbedded
362
Wizard.typesetter.unix.2.install.container.command.os.text.07=bitbake podman
363
Wizard.typesetter.unix.2.install.container.command.os.name.08=openSUSE
364
Wizard.typesetter.unix.2.install.container.command.os.text.08=sudo zypper install podman
365
Wizard.typesetter.unix.2.install.container.command.os.name.09=RHEL7
366
Wizard.typesetter.unix.2.install.container.command.os.text.09=\
367
  sudo subscription-manager repos \
368
    --enable=rhel-7-server-extras-rpms\n\
369
  sudo yum -y install podman
370
Wizard.typesetter.unix.2.install.container.command.os.name.10=RHEL8
371
Wizard.typesetter.unix.2.install.container.command.os.text.10=\
372
  sudo yum module enable -y container-tools:rhel8\n\
373
  sudo yum module install -y container-tools:rhel8
374
Wizard.typesetter.unix.2.install.container.command.os.name.11=Ubuntu 20.10+
375
Wizard.typesetter.unix.2.install.container.command.os.text.11=\
376
  sudo apt-get -y update\n\
377
  sudo apt-get -y install podman
378
Wizard.typesetter.unix.2.install.container.command.os.name.12=Linuxmint
379
Wizard.typesetter.unix.2.install.container.command.os.text.12=${Wizard.typesetter.unix.2.install.container.command.os.text.11}
380
Wizard.typesetter.unix.2.install.container.command.os.name.13=Linuxmint LMDE
381
Wizard.typesetter.unix.2.install.container.command.os.text.13=${Wizard.typesetter.unix.2.install.container.command.os.text.04}
382
Wizard.typesetter.unix.2.install.container.command.os.name.14=macOS
383
Wizard.typesetter.unix.2.install.container.command.os.text.14=\
384
  /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \n\
385
  brew install podman
386
387
# STEP 2 a: Download container manager (Windows)
388
Wizard.typesetter.win.2.download.container.header=Download ${Wizard.typesetter.container.name} for Windows
389
Wizard.typesetter.win.2.download.container.homepage.link.lbl=${Wizard.typesetter.container.name}
390
Wizard.typesetter.win.2.download.container.homepage.link.url=https://podman.io
391
Wizard.typesetter.win.2.download.container.download.link.lbl=repository
392
Wizard.typesetter.win.2.download.container.download.link.url=https://github.com/containers/podman/releases/download/v${Wizard.typesetter.container.version}/podman-${Wizard.typesetter.container.version}-setup.exe
393
Wizard.typesetter.win.2.download.container.paths=Downloading {0} into {1}.
394
# suppress inspection "UnusedMessageFormatParameter"
395
Wizard.typesetter.win.2.download.container.status.bytes=Downloaded {1} bytes (size unknown).
396
Wizard.typesetter.win.2.download.container.status.progress=Downloaded {0} % of {1} bytes.
397
Wizard.typesetter.win.2.download.container.status.checksum.ok=File {0} exists. Click Next to continue.
398
Wizard.typesetter.win.2.download.container.status.checksum.no=Integrity check failed, {0} may be corrupt.
399
Wizard.typesetter.win.2.download.container.status.success=Download successful. Click Next to continue.
400
Wizard.typesetter.win.2.download.container.status.failure=Download failed. Check network then click Previous to try again.
401
402
# STEP 2 b: Install container manager (Windows)
403
Wizard.typesetter.win.2.install.container.header=Install ${Wizard.typesetter.container.name} for Windows
404
Wizard.typesetter.win.2.install.container.step.0=Complete the following steps to install ${Wizard.typesetter.container.name}:
405
Wizard.typesetter.win.2.install.container.step.1=\t1. Open the task bar.
406
Wizard.typesetter.win.2.install.container.step.2=\t2. Click the shield icon to grant permissions.
407
Wizard.typesetter.win.2.install.container.step.3=\t3. Click Yes in the User Account Control dialog to install.
408
Wizard.typesetter.win.2.install.container.status.running=Installing ...
409
Wizard.typesetter.win.2.install.container.status.success=Installation successful.\nClick Next to continue.
410
Wizard.typesetter.win.2.install.container.status.failure=Installation failed with exit code {0}.
411
Wizard.typesetter.win.2.install.container.status.unknown=Could not determine installer file type: {0}
412
413
# STEP 2: Install container manager (Universal, undetected operating system)
414
Wizard.typesetter.all.2.install.container.header=Install ${Wizard.typesetter.container.name}
415
Wizard.typesetter.all.2.install.container.homepage.lbl=${Wizard.typesetter.container.name}
416
Wizard.typesetter.all.2.install.container.homepage.url=https://podman.io
417
418
# STEP 3: Initialize container manager (all except Linux)
419
Wizard.typesetter.all.3.install.container.header=Initialize ${Wizard.typesetter.container.name}
420
Wizard.typesetter.all.3.install.container.correct=${Wizard.typesetter.container.name} initialized.\nClick Next to continue.
421
Wizard.typesetter.all.3.install.container.missing=Install ${Wizard.typesetter.container.name} before continuing.
422
423
# STEP 4: Install typesetter container image (all)
424
Wizard.typesetter.all.4.download.image.header=Download ${Wizard.typesetter.name} image
425
Wizard.typesetter.all.4.download.image.correct=Download successful.\nClick Next to continue.
426
Wizard.typesetter.all.4.download.image.missing=Install ${Wizard.typesetter.container.name} before continuing.
427
428
# STEP 5: Download typesetter themes (all)
429
Wizard.typesetter.all.5.download.themes.header=Download ${Wizard.typesetter.name} themes
430
Wizard.typesetter.all.5.download.themes.download.link.lbl=repository
431
Wizard.typesetter.all.5.download.themes.download.link.url=https://github.com/DaveJarvis/keenwrite-themes/releases/download/${Wizard.typesetter.themes.version}/theme-pack.zip
432
Wizard.typesetter.all.5.download.themes.paths=Downloading {0} into {1}.
433
Wizard.typesetter.all.5.download.themes.status.bytes=Downloaded {0} bytes (size unknown).
434
Wizard.typesetter.all.5.download.themes.status.progress=Downloaded {0} % of {1} bytes.
435
Wizard.typesetter.all.5.download.themes.status.checksum.ok=File {0} exists. Click Finish to continue.
436
Wizard.typesetter.all.5.download.themes.status.checksum.no=Integrity check failed, {0} may be corrupt.
437
Wizard.typesetter.all.5.download.themes.status.success=Download successful. Click Finish to continue.
438
Wizard.typesetter.all.5.download.themes.status.failure=Download failed. Check network then click Previous to try again.
439
440
# ########################################################################
441
# Image Dialog
442
# ########################################################################
443
444
Dialog.image.title=Image
445
Dialog.image.chooser.imagesFilter=Images
446
Dialog.image.previewLabel.text=Markdown Preview\:
447
Dialog.image.textLabel.text=Alternate Text\:
448
Dialog.image.titleLabel.text=Title (tooltip)\:
449
Dialog.image.urlLabel.text=Image URL\:
450
451
# ########################################################################
452
# Hyperlink Dialog
453
# ########################################################################
454
455
Dialog.link.title=Link
456
Dialog.link.previewLabel.text=Markdown Preview\:
457
Dialog.link.textLabel.text=Link Text\:
458
Dialog.link.titleLabel.text=Title (tooltip)\:
459
Dialog.link.urlLabel.text=Link URL\:
460
461
# ########################################################################
462
# Typesetting Settings Dialog
463
# ########################################################################
464
465
Dialog.typesetting.settings.title=Typesetting export settings
466
Dialog.typesetting.settings.header.single=Export current document
467
Dialog.typesetting.settings.theme=Theme
468
Dialog.typesetting.settings.themes.missing=Install themes into {0}.
469
470
Dialog.typesetting.settings.header.multiple=Export multiple documents
471
Dialog.typesetting.settings.chapters=Chapters (e.g., 1-3, 5, 7-)
472
473
# ########################################################################
474
# About Dialog
475
# ########################################################################
476
477
Dialog.about.title=About {0}
478
Dialog.about.header={0}
479
Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
480
481
# ########################################################################
482
# Application Actions
483
# ########################################################################
484
485
Action.file.new.description=Create a new file
486
Action.file.new.accelerator=Shortcut+N
487
Action.file.new.icon=FILE_ALT
488
Action.file.new.text=_New
489
490
Action.file.open.description=Open a new file
491
Action.file.open.accelerator=Shortcut+O
492
Action.file.open.text=_Open...
493
Action.file.open.icon=FOLDER_OPEN_ALT
494
495
Action.file.close.description=Close the current document
496
Action.file.close.accelerator=Shortcut+W
497
Action.file.close.text=_Close
498
499
Action.file.close_all.description=Close all open documents
500
Action.file.close_all.accelerator=Ctrl+F4
501
Action.file.close_all.text=Close All
502
503
Action.file.save.description=Save the document
504
Action.file.save.accelerator=Shortcut+S
505
Action.file.save.text=_Save
506
Action.file.save.icon=FLOPPY_ALT
507
508
Action.file.save_as.description=Rename the current document
509
Action.file.save_as.text=Save _As
510
511
Action.file.save_all.description=Save all open documents
512
Action.file.save_all.accelerator=Shortcut+Shift+S
513
Action.file.save_all.text=Save A_ll
514
515
Action.file.export.pdf.description=Typeset the document
516
Action.file.export.pdf.accelerator=Shortcut+P
517
Action.file.export.pdf.text=_PDF
518
Action.file.export.pdf.icon=FILE_PDF_ALT
519
520
Action.file.export.pdf.dir.description=Typeset files in document directory
521
Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P
522
Action.file.export.pdf.dir.text=_Joined PDF
523
Action.file.export.pdf.dir.icon=FILE_PDF_ALT
524
525
Action.file.export.html_svg.description=Export the current document as HTML + SVG
526
Action.file.export.text=_Export As
527
Action.file.export.html_svg.text=HTML and S_VG
528
529
Action.file.export.html_tex.description=Export the current document as HTML + TeX
530
Action.file.export.html_tex.text=HTML and _TeX
531
532
Action.file.export.xhtml_tex.description=Export as XHTML + TeX
533
Action.file.export.xhtml_tex.text=_XHTML and TeX
534
535
Action.file.export.markdown.description=Export the current document as Markdown
536
Action.file.export.markdown.text=Markdown
537
538
Action.file.exit.description=Quit the application
539
Action.file.exit.text=E_xit
540
541
542
Action.edit.undo.description=Undo the previous edit
543
Action.edit.undo.accelerator=Shortcut+Z
544
Action.edit.undo.text=_Undo
545
Action.edit.undo.icon=UNDO
546
547
Action.edit.redo.description=Redo the previous edit
548
Action.edit.redo.accelerator=Shortcut+Y
549
Action.edit.redo.text=_Redo
550
Action.edit.redo.icon=REPEAT
551
552
Action.edit.cut.description=Delete the selected text or line
553
Action.edit.cut.accelerator=Shortcut+X
554
Action.edit.cut.text=Cu_t
555
Action.edit.cut.icon=CUT
556
557
Action.edit.copy.description=Copy the selected text
558
Action.edit.copy.accelerator=Shortcut+C
559
Action.edit.copy.text=_Copy
560
Action.edit.copy.icon=COPY
561
562
Action.edit.paste.description=Paste from the clipboard
563
Action.edit.paste.accelerator=Shortcut+V
564
Action.edit.paste.text=_Paste
565
Action.edit.paste.icon=PASTE
566
567
Action.edit.select_all.description=Highlight the current document text
568
Action.edit.select_all.accelerator=Shortcut+A
569
Action.edit.select_all.text=Select _All
570
571
Action.edit.find.description=Search for text in the document
572
Action.edit.find.accelerator=Shortcut+F
573
Action.edit.find.text=_Find
574
Action.edit.find.icon=SEARCH
575
576
Action.edit.find_next.description=Find next occurrence
577
Action.edit.find_next.accelerator=F3
578
Action.edit.find_next.text=Find _Next
579
580
Action.edit.find_prev.description=Find previous occurrence
581
Action.edit.find_prev.accelerator=Shift+F3
582
Action.edit.find_prev.text=Find _Prev
583
584
Action.edit.preferences.description=Edit user preferences
585
Action.edit.preferences.accelerator=Ctrl+Alt+S
586
Action.edit.preferences.text=_Preferences
587
588
589
Action.format.bold.description=Insert strong text
590
Action.format.bold.accelerator=Shortcut+B
591
Action.format.bold.text=_Bold
592
Action.format.bold.icon=BOLD
593
594
Action.format.italic.description=Insert text emphasis
595
Action.format.italic.accelerator=Shortcut+I
596
Action.format.italic.text=_Italic
597
Action.format.italic.icon=ITALIC
598
599
Action.format.monospace.description=Insert monospace text
600
Action.format.monospace.accelerator=Shortcut+`
601
Action.format.monospace.text=_Monospace
602
603
Action.format.superscript.description=Insert superscript text
604
Action.format.superscript.accelerator=Shortcut+[
605
Action.format.superscript.text=Su_perscript
606
Action.format.superscript.icon=SUPERSCRIPT
607
608
Action.format.subscript.description=Insert subscript text
609
Action.format.subscript.accelerator=Shortcut+]
610
Action.format.subscript.text=Su_bscript
611
Action.format.subscript.icon=SUBSCRIPT
612
613
Action.format.strikethrough.description=Insert struck text
614
Action.format.strikethrough.accelerator=Shortcut+T
615
Action.format.strikethrough.text=Stri_kethrough
616
Action.format.strikethrough.icon=STRIKETHROUGH
617
618
619
Action.insert.blockquote.description=Insert blockquote
620
Action.insert.blockquote.accelerator=Ctrl+Q
621
Action.insert.blockquote.text=_Blockquote
622
Action.insert.blockquote.icon=QUOTE_LEFT
623
624
Action.insert.code.description=Insert inline code
625
Action.insert.code.accelerator=Shortcut+K
626
Action.insert.code.text=Inline _Code
627
Action.insert.code.icon=CODE
628
629
Action.insert.fenced_code_block.description=Insert code block
630
Action.insert.fenced_code_block.accelerator=Shortcut+Shift+K
631
Action.insert.fenced_code_block.text=_Fenced Code Block
632
Action.insert.fenced_code_block.prompt.text=Enter code here
633
Action.insert.fenced_code_block.icon=FILE_CODE_ALT
634
635
Action.insert.link.description=Insert hyperlink
636
Action.insert.link.accelerator=Shortcut+L
637
Action.insert.link.text=_Link...
638
Action.insert.link.icon=LINK
639
640
Action.insert.image.description=Insert image
641
Action.insert.image.accelerator=Shortcut+G
642
Action.insert.image.text=_Image...
643
Action.insert.image.icon=PICTURE_ALT
644
645
Action.insert.heading.description=Insert heading level
646
Action.insert.heading.accelerator=Shortcut+
647
Action.insert.heading.icon=HEADER
648
649
Action.insert.heading_1.description=${Action.insert.heading.description} 1
650
Action.insert.heading_1.accelerator=${Action.insert.heading.accelerator}1
651
Action.insert.heading_1.text=Heading _1
652
Action.insert.heading_1.icon=${Action.insert.heading.icon}
653
654
Action.insert.heading_2.description=${Action.insert.heading.description} 2
655
Action.insert.heading_2.accelerator=${Action.insert.heading.accelerator}2
656
Action.insert.heading_2.text=Heading _2
657
Action.insert.heading_2.icon=${Action.insert.heading.icon}
658
659
Action.insert.heading_3.description=${Action.insert.heading.description} 3
660
Action.insert.heading_3.accelerator=${Action.insert.heading.accelerator}3
661
Action.insert.heading_3.text=Heading _3
662
Action.insert.heading_3.icon=${Action.insert.heading.icon}
663
664
Action.insert.unordered_list.description=Insert bulleted list
665
Action.insert.unordered_list.accelerator=Shortcut+U
666
Action.insert.unordered_list.text=_Unordered List
667
Action.insert.unordered_list.icon=LIST_UL
668
669
Action.insert.ordered_list.description=Insert enumerated list
670
Action.insert.ordered_list.accelerator=Shortcut+Shift+O
671
Action.insert.ordered_list.text=_Ordered List
672
Action.insert.ordered_list.icon=LIST_OL
673
674
Action.insert.horizontal_rule.description=Insert horizontal rule
675
Action.insert.horizontal_rule.accelerator=Shortcut+H
676
Action.insert.horizontal_rule.text=_Horizontal Rule
677
Action.insert.horizontal_rule.icon=LIST_OL
678
679
680
Action.definition.create.description=Create a new variable
681
Action.definition.create.text=_Create
682
Action.definition.create.icon=TREE
683
Action.definition.create.tooltip=Add new item (Insert)
684
685
Action.definition.rename.description=Rename the selected variable
686
Action.definition.rename.text=_Rename
687
Action.definition.rename.icon=EDIT
688
Action.definition.rename.tooltip=Rename selected item (F2)
689
690
Action.definition.delete.description=Delete the selected variables
691
Action.definition.delete.text=De_lete
692
Action.definition.delete.icon=TRASH
693
Action.definition.delete.tooltip=Delete selected items (Delete)
694
695
Action.definition.insert.description=Insert a variable
696
Action.definition.insert.accelerator=Ctrl+Space
697
Action.definition.insert.text=_Insert
698
Action.definition.insert.icon=STAR
699
700
701
Action.view.refresh.description=Clear all caches
702
Action.view.refresh.accelerator=F5
703
Action.view.refresh.text=Refresh
704
705
Action.view.preview.description=Open document preview
706
Action.view.preview.accelerator=F6
707
Action.view.preview.text=Preview
708
709
Action.view.outline.description=Open document outline
710
Action.view.outline.accelerator=F7
711
Action.view.outline.text=Outline
712
713
Action.view.statistics.description=Open document word counts
714
Action.view.statistics.accelerator=F8
715
Action.view.statistics.text=Statistics
716
717
Action.view.files.description=Open file manager
718
Action.view.files.accelerator=Ctrl+F8
719
Action.view.files.text=Files
720
721
Action.view.menubar.description=Toggle menu bar
722
Action.view.menubar.accelerator=Ctrl+F9
723
Action.view.menubar.text=Menu bar
724
725
Action.view.toolbar.description=Toggle toolbar
726
Action.view.toolbar.accelerator=Ctrl+Shift+F9
727
Action.view.toolbar.text=Toolbar
599728
600729
Action.view.statusbar.description=Toggle status bar
M src/main/resources/lexicons/README.md
2525
to complete (on modern hardware).
2626
27
# Lexicons
27
# Resources
2828
2929
There are numerous sources of word and frequency lists available, including:
M src/main/resources/lexicons/en.txt
Binary file
M src/test/java/com/keenwrite/definition/TreeViewTest.java
1919
import javafx.scene.control.TreeItem;
2020
import javafx.stage.Stage;
21
import org.assertj.core.util.Files;
2122
import org.testfx.framework.junit5.Start;
2223
...
4950
    final var transformer = new YamlTreeTransformer();
5051
    final var editor = new DefinitionEditor( transformer );
52
    final var file = Files.newTemporaryFile();
5153
5254
    final var tabPane1 = new DetachableTabPane();
5355
    tabPane1.addTab( "Editor", editor );
5456
5557
    final var tabPane2 = new DetachableTabPane();
5658
    final var tab21 =
5759
      tabPane2.addTab( "Picker", new ColorPicker() );
5860
    final var tab22 =
59
      tabPane2.addTab( "Editor", new MarkdownEditor( workspace ) );
61
      tabPane2.addTab( "Editor", new MarkdownEditor( file, workspace ) );
6062
    tab21.setTooltip( new Tooltip( "Colour Picker" ) );
6163
    tab22.setTooltip( new Tooltip( "Text Editor" ) );
M src/test/java/com/keenwrite/editors/markdown/MarkdownEditorTest.java
33
import com.keenwrite.AwaitFxExtension;
44
import com.keenwrite.preferences.Workspace;
5
import org.assertj.core.util.Files;
56
import org.junit.jupiter.api.Test;
67
import org.junit.jupiter.api.extension.ExtendWith;
78
import org.testfx.framework.junit5.ApplicationExtension;
89
10
import java.io.File;
911
import java.util.regex.Pattern;
1012
1113
import static java.util.regex.Pattern.compile;
1214
import static javafx.application.Platform.runLater;
1315
import static org.junit.jupiter.api.Assertions.assertEquals;
1416
import static org.junit.jupiter.api.Assertions.assertTrue;
1517
1618
@ExtendWith( {ApplicationExtension.class, AwaitFxExtension.class} )
1719
public class MarkdownEditorTest {
20
  private static final File TEMP_FILE = Files.newTemporaryFile();
21
1822
  private static final String[] WORDS = new String[]{
1923
    "Italicize",
...
107111
  private MarkdownEditor createMarkdownEditor() {
108112
    final var workspace = new Workspace();
109
    final var editor = new MarkdownEditor( workspace );
113
    final var editor = new MarkdownEditor( TEMP_FILE, workspace );
110114
    editor.setText( TEXT );
111115
    return editor;
M src/test/java/com/keenwrite/io/MediaTypeTest.java
44
import org.junit.jupiter.api.Test;
55
6
import java.net.URI;
76
import java.util.Map;
87
9
import static com.keenwrite.io.HttpFacade.httpGet;
108
import static com.keenwrite.io.MediaType.*;
9
import static com.keenwrite.io.downloads.DownloadManager.open;
1110
import static org.junit.jupiter.api.Assertions.*;
1211
...
5251
    //@formatter:off
5352
    final var map = Map.of(
54
       "https://stackoverflow.com/robots.txt", TEXT_PLAIN,
53
       "https://kroki.io/robots.txt", TEXT_PLAIN,
5554
       "https://place-hold.it/300x500", IMAGE_GIF,
5655
       "https://placekitten.com/g/200/300", IMAGE_JPEG,
5756
       "https://upload.wikimedia.org/wikipedia/commons/9/9f/Vimlogo.svg", IMAGE_SVG_XML,
5857
       "https://kroki.io//graphviz/svg/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", IMAGE_SVG_XML
5958
    );
6059
    //@formatter:on
6160
6261
    map.forEach( ( k, v ) -> {
63
      try( var response = httpGet( new URI( k ) ) ) {
62
      try( var response = open( k ) ) {
6463
        assertEquals( v, response.getMediaType() );
65
      } catch( Exception e ) {
64
      } catch( final Exception e ) {
6665
        fail();
6766
      }
A src/test/java/com/keenwrite/io/SysFileTest.java
1
/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import org.junit.jupiter.api.Test;
5
6
import static org.junit.jupiter.api.Assertions.assertEquals;
7
import static org.junit.jupiter.api.Assertions.assertTrue;
8
9
class SysFileTest {
10
  private static final String REG_PATH_PREFIX =
11
    "%USERPROFILE%";
12
  private static final String REG_PATH_SUFFIX =
13
    "\\AppData\\Local\\Microsoft\\WindowsApps;";
14
  private static final String REG_PATH = REG_PATH_PREFIX + REG_PATH_SUFFIX;
15
16
  @Test
17
  void test_Locate_ExistingExecutable_PathFound() {
18
    final var command = "ls";
19
    final var file = new SysFile( command );
20
    assertTrue( file.canRun() );
21
22
    final var located = file.locate();
23
    assertTrue( located.isPresent() );
24
25
    final var path = located.get();
26
    final var actual = path.toAbsolutePath().toString();
27
    final var expected = "/usr/bin/" + command;
28
29
    assertEquals( expected, actual );
30
  }
31
32
  @Test
33
  void test_Parse_RegistryEntry_ValueObtained() {
34
    final var file = new SysFile( "unused" );
35
    final var expected = REG_PATH;
36
    final var actual =
37
      file.parseRegEntry( "    path    REG_EXPAND_SZ    " + expected );
38
39
    assertEquals( expected, actual );
40
  }
41
42
  @Test
43
  void test_Expand_RegistryEntry_VariablesExpanded() {
44
    final var value = "UserProfile";
45
    final var file = new SysFile( "unused" );
46
    final var expected = value + REG_PATH_SUFFIX;
47
    final var actual = file.expand( REG_PATH, s -> value );
48
49
    assertEquals( expected, actual );
50
  }
51
}
152
A src/test/java/com/keenwrite/io/UserDataDirTest.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io;
3
4
import org.junit.jupiter.api.Test;
5
6
import java.io.FileNotFoundException;
7
8
import static org.junit.jupiter.api.Assertions.*;
9
10
class UserDataDirTest {
11
  @Test
12
  void test_Unix_GetAppDirectory_DirectoryExists()
13
    throws FileNotFoundException {
14
    final var path = UserDataDir.getAppPath( "test" );
15
    final var file = path.toFile();
16
17
    assertTrue( file.exists() );
18
    assertTrue( file.delete() );
19
    assertFalse( file.exists() );
20
  }
21
}
122
A src/test/java/com/keenwrite/io/downloads/DownloadManagerTest.java
1
/* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */
2
package com.keenwrite.io.downloads;
3
4
import org.junit.jupiter.api.Test;
5
6
import java.io.IOException;
7
import java.io.OutputStream;
8
import java.util.concurrent.ExecutionException;
9
import java.util.concurrent.Executors;
10
import java.util.concurrent.atomic.AtomicInteger;
11
import java.util.concurrent.atomic.AtomicLong;
12
13
import static com.keenwrite.io.downloads.DownloadManager.ProgressListener;
14
import static com.keenwrite.io.downloads.DownloadManager.open;
15
import static java.io.OutputStream.nullOutputStream;
16
import static java.lang.System.setProperty;
17
import static org.junit.jupiter.api.Assertions.*;
18
19
class DownloadManagerTest {
20
21
  static {
22
    // By default, this returns null, which is not a valid user agent.
23
    setProperty( "http.agent", DownloadManager.class.getCanonicalName() );
24
  }
25
26
  private static final String SITE = "https://github.com/";
27
  private static final String URL
28
    = SITE + "DaveJarvis/keenwrite/releases/latest/download/keenwrite.exe";
29
30
  @Test
31
  void test_Async_DownloadRequested_DownloadCompletes()
32
    throws IOException, InterruptedException, ExecutionException {
33
    final var complete = new AtomicInteger();
34
    final var transferred = new AtomicLong();
35
36
    final OutputStream output = nullOutputStream();
37
    final ProgressListener listener = ( percentage, bytes ) -> {
38
      complete.set( percentage );
39
      transferred.set( bytes );
40
    };
41
42
    final var token = open( URL );
43
    final var executor = Executors.newFixedThreadPool( 1 );
44
    final var result = token.download( output, listener );
45
    final var future = executor.submit( result );
46
47
    assertFalse( future.isDone() );
48
    assertTrue( complete.get() < 100 );
49
    assertTrue( transferred.get() > 100_000 );
50
51
    future.get();
52
53
    assertEquals( 100, complete.get() );
54
55
    token.close();
56
  }
57
}
158
M src/test/java/com/keenwrite/processors/html/XhtmlProcessorTest.java
4343
    return builder()
4444
      .with( ProcessorContext.Mutator::setExportFormat, format )
45
      .with( ProcessorContext.Mutator::setInputPath, Path.of( "f.md" ) )
45
      .with( ProcessorContext.Mutator::setSourcePath, Path.of( "f.md" ) )
4646
      .with( ProcessorContext.Mutator::setDefinitions, HashMap::new )
4747
      .with( ProcessorContext.Mutator::setLocale, () -> ENGLISH )
4848
      .with( ProcessorContext.Mutator::setMetadata, HashMap::new )
49
      .with( ProcessorContext.Mutator::setThemePath, () -> Path.of( "b" ) )
49
      .with( ProcessorContext.Mutator::setThemesPath, () -> Path.of( "b" ) )
5050
      .with( ProcessorContext.Mutator::setCaret, () -> caret )
51
      .with( ProcessorContext.Mutator::setImageDir, () -> new File( "i" ) )
51
      .with( ProcessorContext.Mutator::setImagesPath, () -> new File( "i" ) )
5252
      .with( ProcessorContext.Mutator::setImageOrder, () -> "" )
5353
      .with( ProcessorContext.Mutator::setImageServer, () -> "" )
5454
      .with( ProcessorContext.Mutator::setSigilBegan, () -> "" )
5555
      .with( ProcessorContext.Mutator::setSigilEnded, () -> "" )
5656
      .with( ProcessorContext.Mutator::setRScript, () -> "" )
5757
      .with( ProcessorContext.Mutator::setRWorkingDir, () -> Path.of( "r" ) )
5858
      .with( ProcessorContext.Mutator::setCurlQuotes, () -> true )
59
      .with( ProcessorContext.Mutator::setAutoClean, () -> true )
59
      .with( ProcessorContext.Mutator::setAutoRemove, () -> true )
6060
      .build();
6161
  }
M src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
141141
    return ProcessorContext
142142
      .builder()
143
      .with( ProcessorContext.Mutator::setInputPath, inputPath )
143
      .with( ProcessorContext.Mutator::setSourcePath, inputPath )
144144
      .with( ProcessorContext.Mutator::setExportFormat, XHTML_TEX )
145145
      .with( ProcessorContext.Mutator::setCaret, () -> Caret.builder().build() )
M src/test/java/com/keenwrite/tex/TeXRasterizationTest.java
3535
import com.whitemagicsoftware.tex.graphics.SvgDomGraphics2D;
3636
import com.whitemagicsoftware.tex.graphics.SvgGraphics2D;
37
import org.apache.batik.transcoder.TranscoderException;
37
import io.sf.carte.echosvg.transcoder.TranscoderException;
3838
import org.junit.jupiter.api.Test;
3939
import org.xml.sax.SAXException;