| 18 | 18 | *.exe binary |
| 19 | 19 | *.gif binary |
| 20 | *.ico binary | |
| 20 | 21 | *.jar binary |
| 21 | 22 | *.jpg binary |
| 10 | 10 | * [Gradle 7.6-rc-1](https://services.gradle.org/distributions/gradle-7.6-rc-1-bin.zip) |
| 11 | 11 | * [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. | |
| 12 | 15 | |
| 13 | 16 | ## Repository |
| 84 | 84 | # ----------------------------------------------------------------------------- |
| 85 | 85 | # 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 | |
| 87 | 88 | # is TRUE, this will return the ordinal name. This will not produce ordinals |
| 88 | 89 | # for numbers greater than 100. |
| 90 | # | |
| 91 | # If scaled is TRUE, this will write large numbers as comma-separated values. | |
| 89 | 92 | # ----------------------------------------------------------------------------- |
| 90 | cms <- function( n, ordinal = FALSE ) { | |
| 93 | cms <- function( n, ordinal = FALSE, scaled = TRUE ) { | |
| 91 | 94 | n <- x( n ) |
| 92 | 95 | |
| ... | ||
| 103 | 106 | result = "negative " |
| 104 | 107 | 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 ] ) ); | |
| 105 | 123 | } |
| 106 | 124 | |
| ... | ||
| 122 | 140 | # Samuel Langhorne Clemens noted English has too many exceptions. |
| 123 | 141 | 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" | |
| 128 | 145 | ) |
| 129 | 146 | |
| 130 | 147 | 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" | |
| 135 | 152 | ) |
| 136 | 153 | |
| ... | ||
| 179 | 196 | # Hyphenate the tens and the ones together. |
| 180 | 197 | concat( unit_10, concat( "-", unit_1 ) ) |
| 198 | } | |
| 199 | ||
| 200 | cms.big <- function( n ) { | |
| 181 | 201 | } |
| 202 | ||
| 182 | 203 | |
| 183 | 204 | # ----------------------------------------------------------------------------- |
| 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 | ||
| 1 | 82 |
| 1 |     | |
| 1 |     | |
| 2 | 2 | |
| 3 | 3 | #  |
| 4 | 4 | |
| 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. | |
| 6 | 6 | |
| 7 | 7 | ## Download |
| ... | ||
| 18 | 18 | |
| 19 | 19 | ### Windows |
| 20 | ||
| 21 | When upgrading to a new version, delete the following directory: | |
| 22 | ||
| 23 | C:\Users\%USERNAME%\AppData\Local\warp\packages\keenwrite.exe | |
| 24 | 20 | |
| 25 | 21 | Double-click the application to start; give the application permission to run. |
| 1 | 1 | import static org.gradle.api.JavaVersion.* |
| 2 | 2 | |
| 3 | buildscript { | |
| 4 | repositories { | |
| 5 | mavenCentral() | |
| 6 | } | |
| 7 | dependencies { | |
| 8 | classpath 'org.owasp:dependency-check-gradle:7.4.3' | |
| 9 | } | |
| 10 | } | |
| 11 | ||
| 3 | 12 | plugins { |
| 4 | 13 | id 'application' |
| 5 | 14 | id 'org.openjfx.javafxplugin' version '0.0.13' |
| 6 | 15 | id 'com.palantir.git-version' version '0.15.0' |
| 7 | 16 | } |
| 17 | ||
| 18 | apply plugin: 'org.owasp.dependencycheck' | |
| 8 | 19 | |
| 9 | 20 | repositories { |
| ... | ||
| 38 | 49 | |
| 39 | 50 | def moduleSecurity = [ |
| 51 | '--add-opens=javafx.graphics/javafx.scene=ALL-UNNAMED', | |
| 40 | 52 | '--add-opens=javafx.controls/javafx.scene.control=ALL-UNNAMED', |
| 41 | 53 | '--add-opens=javafx.controls/javafx.scene.control.skin=ALL-UNNAMED', |
| ... | ||
| 54 | 66 | javafx { |
| 55 | 67 | version = '19' |
| 56 | modules = ['javafx.controls', 'javafx.swing'] | |
| 68 | modules = ['javafx.base', 'javafx.controls', 'javafx.graphics', 'javafx.swing'] | |
| 57 | 69 | configuration = 'compileOnly' |
| 58 | 70 | } |
| 59 | 71 | |
| 60 | 72 | dependencies { |
| 61 | def v_junit = '5.9.1' | |
| 73 | def v_junit = '5.9.2' | |
| 62 | 74 | def v_flexmark = '0.64.0' |
| 63 | 75 | def v_jackson = '2.14.0' |
| 64 | def v_echosvg = '0.2.1' | |
| 76 | def v_echosvg = '0.2.2' | |
| 65 | 77 | def v_picocli = '4.7.0' |
| 66 | 78 | |
| 67 | 79 | // JavaFX |
| 68 | 80 | implementation 'org.controlsfx:controlsfx:11.1.2' |
| 69 | 81 | implementation 'org.fxmisc.richtext:richtextfx:0.11.0' |
| 70 | 82 | implementation 'org.fxmisc.flowless:flowless:0.7.0' |
| 71 | 83 | implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3' |
| 72 | 84 | implementation 'com.miglayout:miglayout-javafx:11.0' |
| 73 | 85 | implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.11.0' |
| 86 | implementation 'com.panemu:tiwulfx-dock:0.2' | |
| 74 | 87 | |
| 75 | 88 | // Markdown |
| ... | ||
| 86 | 99 | implementation "com.fasterxml.jackson.core:jackson-annotations:${v_jackson}" |
| 87 | 100 | implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${v_jackson}" |
| 88 | implementation 'org.yaml:snakeyaml:1.33' | |
| 89 | 101 | |
| 90 | 102 | // HTML parsing and rendering |
| ... | ||
| 158 | 170 | |
| 159 | 171 | 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 | ] | |
| 164 | 177 | } |
| 165 | 178 | |
| ... | ||
| 195 | 208 | distributions { |
| 196 | 209 | main { |
| 197 | distributionBaseName = applicationName | |
| 210 | distributionBaseName.set( applicationName ) | |
| 211 | ||
| 198 | 212 | contents { |
| 199 | 213 | from { ['LICENSE.md', 'README.md'] } |
| 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 | ||
| 1 | 9 | FROM alpine:latest |
| 10 | ENV ENV="/etc/profile" | |
| 11 | ENV PROFILE=/etc/profile | |
| 2 | 12 | |
| 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 | |
| 5 | 21 | |
| 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 | |
| 10 | 23 | |
| 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 | |
| 18 | 30 | |
| 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" | |
| 28 | 36 | |
| 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" | |
| 31 | 39 | |
| 32 | WORKDIR "/opt" | |
| 40 | # Underwood Quiet Tab | |
| 41 | ADD "https://site.xavier.edu/polt/typewriters/Underwood_Quiet_Tab.ttf" "Underwood_Quiet_Tab.ttf" | |
| 33 | 42 | |
| 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" | |
| 37 | 52 | |
| 38 | # Download ConTeXt. | |
| 53 | # Typesetting software | |
| 39 | 54 | 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 | |
| 46 | 55 | |
| 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 | |
| 54 | 62 | |
| 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 {} \; | |
| 59 | 112 | |
| 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 | |
| 62 | 120 | |
| 63 | 121 |
| 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 | ||
| 36 | 1 |
| 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 | ||
| 1 | 327 |
| 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 | ||
| 1 | 177 |
| 1 | # Overview | |
| 2 | ||
| 3 | Typesetting PDF files entails the following: | |
| 4 | ||
| 5 | * Download and install typesetting software | |
| 6 | * Download a theme pack | |
| 1 | # Typesetting | |
| 7 | 2 | |
| 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. | |
| 9 | 8 | |
| 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. | |
| 11 | 12 | |
| 12 | Download the typesetting software as follows: | |
| 13 | Run the installation wizard as follows: | |
| 13 | 14 | |
| 14 | 1. Start the text editor. | |
| 15 | 1. Start the application. | |
| 15 | 16 | 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 | 17 | |
| 27 | * [Windows](#windows) (includes Windows 7, Windows 10, and similar) | |
| 28 | * [Unix](#unix) (includes MacOS, FreeBSD, Linux, and similar) | |
| 18 | A wizard appears. | |
| 29 | 19 | |
| 30 | 20 | ## 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 | 21 | |
| 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 | 22 | |
| 167 | The [documentation library](https://wiki.contextgarden.net/Documentation) includes the following gems: | |
| 23 | ## Linux | |
| 168 | 24 | |
| 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 | 25 | |
| 175 | Expert-level documentation includes the [LuaTeX Reference Manual](https://www.pragma-ade.nl/general/manuals/luatex.pdf). | |
| 26 | ## macOS | |
| 176 | 27 | |
| 177 | 28 |
| 43 | 43 | DEPENDENCIES=( |
| 44 | 44 | "gradle,https://gradle.org" |
| 45 | "warp-packer,https://github.com/dgiagio/warp" | |
| 45 | "warp-packer,https://github.com/Reisz/warp/releases" | |
| 46 | 46 | "tar,https://www.gnu.org/software/tar" |
| 47 | "wine,https://www.winehq.org" | |
| 47 | 48 | "unzip,http://infozip.sourceforge.net" |
| 48 | 49 | ) |
| ... | ||
| 76 | 77 | |
| 77 | 78 | $do_create_launcher |
| 79 | ||
| 80 | $do_brand_windows | |
| 78 | 81 | |
| 79 | 82 | return 1 |
| ... | ||
| 90 | 93 | APP_EXTENSION="exe" |
| 91 | 94 | do_create_launch_script=utile_create_launch_script_windows |
| 95 | do_brand_windows=utile_brand_windows | |
| 92 | 96 | fi |
| 93 | 97 | } |
| ... | ||
| 177 | 181 | # This avoids any potential line conversion issues with the repository. |
| 178 | 182 | 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" | |
| 179 | 215 | } |
| 180 | 216 | |
| ... | ||
| 203 | 239 | |
| 204 | 240 | warp-packer \ |
| 241 | pack \ | |
| 205 | 242 | --arch "${ARG_JAVA_OS}-${ARG_JAVA_ARCH}" \ |
| 206 | --input_dir "${ARG_DIR_DIST}" \ | |
| 243 | --input-dir "${ARG_DIR_DIST}" \ | |
| 207 | 244 | --exec "${FILE_DIST_EXEC}" \ |
| 208 | 245 | --output "${FILE_APP_NAME}" > /dev/null |
| ... | ||
| 239 | 276 | do_copy_archive=utile_copy_archive |
| 240 | 277 | do_create_launcher=utile_create_launcher |
| 278 | do_brand_windows=: | |
| 241 | 279 | |
| 242 | 280 | main "$@" |
| 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 | ||
| 1 | 17 |
| 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 | ||
| 1 | 45 |
| 79 | 79 | final var context = args.createProcessorContext(); |
| 80 | 80 | 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(); | |
| 83 | 83 | final var chain = createProcessors( context ); |
| 84 | 84 | final var inputDoc = read( inputPath, concat ); |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.constants.Constants; |
| 5 | import com.keenwrite.io.UserDataDir; | |
| 5 | 6 | |
| 7 | import java.io.File; | |
| 6 | 8 | import java.io.InputStream; |
| 9 | import java.nio.file.Path; | |
| 7 | 10 | import java.util.Calendar; |
| 8 | 11 | import java.util.Properties; |
| ... | ||
| 29 | 32 | |
| 30 | 33 | public static String APP_TITLE; |
| 31 | public static String APP_TITLE_LOWERCASE; | |
| 32 | 34 | 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; | |
| 34 | 44 | |
| 35 | 45 | static { |
| 36 | 46 | try( final var in = openResource( PATH_BOOTSTRAP ) ) { |
| 37 | 47 | sP.load( in ); |
| 38 | 48 | |
| 39 | 49 | APP_TITLE = sP.getProperty( "application.title" ); |
| 50 | CONTAINER_VERSION = sP.getProperty( "container.version" ); | |
| 40 | 51 | } catch( final Exception ex ) { |
| 41 | 52 | APP_TITLE = "KeenWrite"; |
| 42 | 53 | |
| 43 | 54 | // Bootstrap properties cannot be found, use a default value. |
| 44 | 55 | final var fmt = "Unable to load %s resource, applying defaults.%n"; |
| 45 | 56 | 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"; | |
| 46 | 61 | } |
| 47 | 62 | |
| ... | ||
| 58 | 73 | } |
| 59 | 74 | |
| 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( "-.*", "" ); | |
| 60 | 79 | APP_YEAR = getYear(); |
| 61 | 80 | |
| 62 | 81 | // 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 | } | |
| 64 | 90 | } |
| 65 | 91 | |
| 155 | 155 | private static void disableLogging() { |
| 156 | 156 | 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. | |
| 164 | 158 | System.err.close(); |
| 165 | 159 | } |
| 26 | 26 | import com.keenwrite.spelling.impl.PermissiveSpeller; |
| 27 | 27 | 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 ) | |
| 1115 | 1083 | .with( Mutator::setExportFormat, NONE ) |
| 1116 | 1084 | .build(); |
| 7 | 7 | import com.keenwrite.sigils.SigilKeyOperator; |
| 8 | 8 | |
| 9 | import java.net.URI; | |
| 9 | 10 | import java.text.MessageFormat; |
| 10 | 11 | import java.util.ResourceBundle; |
| ... | ||
| 71 | 72 | public static String get( final String key, final Object... args ) { |
| 72 | 73 | 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 ) ); | |
| 73 | 86 | } |
| 74 | 87 | |
| 75 | 75 | required = true |
| 76 | 76 | ) |
| 77 | private Path mPathInput; | |
| 77 | private Path mSourcePath; | |
| 78 | 78 | |
| 79 | 79 | @CommandLine.Option( |
| 80 | 80 | names = {"--format-subtype"}, |
| 81 | 81 | description = |
| 82 | 82 | "Export TeX subtype for HTML formats: svg, delimited", |
| 83 | 83 | defaultValue = "svg", |
| 84 | 84 | paramLabel = "String" |
| 85 | 85 | ) |
| 86 | 86 | 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; | |
| 87 | 95 | |
| 88 | 96 | @CommandLine.Option( |
| 89 | 97 | names = {"--image-dir"}, |
| 90 | 98 | description = |
| 91 | 99 | "Directory containing images", |
| 92 | 100 | paramLabel = "DIR" |
| 93 | 101 | ) |
| 94 | private File mImageDir; | |
| 102 | private File mImagesDir; | |
| 95 | 103 | |
| 96 | 104 | @CommandLine.Option( |
| ... | ||
| 137 | 145 | required = true |
| 138 | 146 | ) |
| 139 | private Path mPathOutput; | |
| 147 | private Path mTargetPath; | |
| 140 | 148 | |
| 141 | 149 | @CommandLine.Option( |
| ... | ||
| 187 | 195 | paramLabel = "DIR" |
| 188 | 196 | ) |
| 189 | private Path mDirTheme; | |
| 197 | private Path mThemesDir; | |
| 190 | 198 | |
| 191 | 199 | @CommandLine.Option( |
| ... | ||
| 206 | 214 | throws IOException { |
| 207 | 215 | final var definitions = parse( mPathVariables ); |
| 208 | final var format = ExportFormat.valueFrom( mPathOutput, mFormatSubtype ); | |
| 216 | final var format = ExportFormat.valueFrom( mTargetPath, mFormatSubtype ); | |
| 209 | 217 | final var locale = lookupLocale( mLocale ); |
| 210 | 218 | final var rScript = read( mRScriptPath ); |
| 211 | 219 | |
| 212 | 220 | return ProcessorContext |
| 213 | 221 | .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 ) | |
| 216 | 229 | .with( Mutator::setExportFormat, format ) |
| 217 | 230 | .with( Mutator::setDefinitions, () -> definitions ) |
| 218 | 231 | .with( Mutator::setMetadata, () -> mMetadata ) |
| 219 | 232 | .with( Mutator::setLocale, () -> locale ) |
| 220 | .with( Mutator::setThemePath, () -> mDirTheme ) | |
| 221 | 233 | .with( Mutator::setConcatenate, mConcatenate ) |
| 222 | .with( Mutator::setImageDir, () -> mImageDir ) | |
| 223 | .with( Mutator::setImageServer, () -> mImageServer ) | |
| 224 | .with( Mutator::setImageOrder, () -> mImageOrder ) | |
| 225 | 234 | .with( Mutator::setSigilBegan, () -> mSigilBegan ) |
| 226 | 235 | .with( Mutator::setSigilEnded, () -> mSigilEnded ) |
| 227 | 236 | .with( Mutator::setRWorkingDir, () -> mRWorkingDir ) |
| 228 | 237 | .with( Mutator::setRScript, () -> rScript ) |
| 229 | 238 | .with( Mutator::setCurlQuotes, () -> mCurlQuotes ) |
| 230 | .with( Mutator::setAutoClean, () -> !mKeepFiles ) | |
| 239 | .with( Mutator::setAutoRemove, () -> !mKeepFiles ) | |
| 231 | 240 | .build(); |
| 232 | 241 | } |
| 9 | 9 | * Responsible for maintaining a circular queue where newly added items will |
| 10 | 10 | * overwrite existing items. |
| 11 | * | |
| 11 | * <p> | |
| 12 | 12 | * <strong>Warning:</strong> This class is not thread-safe. |
| 13 | * </p> | |
| 13 | 14 | * |
| 14 | 15 | * @param <E> The type of elements to store in this collection. |
| 12 | 12 | |
| 13 | 13 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; |
| 14 | import static com.keenwrite.Bootstrap.USER_DATA_DIR; | |
| 14 | 15 | import static com.keenwrite.preferences.LocaleScripts.withScript; |
| 15 | 16 | import static java.io.File.separator; |
| 16 | 17 | import static java.lang.String.format; |
| 17 | 18 | import static java.lang.System.getProperty; |
| 19 | import static org.apache.commons.lang3.SystemUtils.*; | |
| 18 | 20 | |
| 19 | 21 | /** |
| ... | ||
| 266 | 268 | APP_TITLE_LOWERCASE |
| 267 | 269 | ) ); |
| 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(); | |
| 268 | 302 | } |
| 269 | 303 | } |
| 65 | 65 | * optional because usually a status message isn't an application error. |
| 66 | 66 | * |
| 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. | |
| 68 | 68 | */ |
| 69 | 69 | public String getProblem() { |
| ... | ||
| 94 | 94 | } |
| 95 | 95 | |
| 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 | */ | |
| 96 | 102 | private static boolean filter( final StackTraceElement e ) { |
| 97 | 103 | final var clazz = e.getClassName(); |
| 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 | } | |
| 1 | 31 |
| 13 | 13 | * Constructs a new event that indicates the source of a file system event. |
| 14 | 14 | * |
| 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. | |
| 16 | 16 | */ |
| 17 | 17 | public FileEvent( final File file ) { |
| 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 | } | |
| 162 | 1 |
| 18 | 18 | * Media Types</a> |
| 19 | 19 | */ |
| 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. | |
| 341 | 353 | * |
| 342 | 354 | * @return The unique media type identifier. |
| 3 | 3 | |
| 4 | 4 | import java.io.*; |
| 5 | import java.nio.file.Path; | |
| 6 | 5 | import java.util.LinkedHashMap; |
| 7 | 6 | import java.util.Map; |
| 8 | 7 | |
| 9 | 8 | import static com.keenwrite.io.MediaType.*; |
| 10 | 9 | import static java.lang.System.arraycopy; |
| 11 | 10 | |
| 12 | 11 | /** |
| 13 | 12 | * Associates file signatures with IANA-defined {@link MediaType}s. See: |
| 14 | 13 | * <a href="https://www.garykessler.net/library/file_sigs.html"> |
| 15 | * Kessler's List | |
| 14 | * Gary Kessler's List | |
| 16 | 15 | * </a>, |
| 17 | 16 | * <a href="https://en.wikipedia.org/wiki/List_of_file_signatures"> |
| ... | ||
| 28 | 27 | private static final Map<int[], MediaType> FORMAT = new LinkedHashMap<>(); |
| 29 | 28 | |
| 30 | private static void add( final int[] data, final MediaType mediaType ) { | |
| 29 | private static void put( final int[] data, final MediaType mediaType ) { | |
| 31 | 30 | FORMAT.put( data, mediaType ); |
| 32 | 31 | } |
| 33 | 32 | |
| 34 | 33 | static { |
| 35 | 34 | //@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 ); | |
| 72 | 71 | //@formatter:on |
| 73 | 72 | } |
| ... | ||
| 105 | 104 | |
| 106 | 105 | 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() ); | |
| 120 | 106 | } |
| 121 | 107 | |
| ... | ||
| 128 | 114 | * @return The IANA-defined {@link MediaType}, or |
| 129 | 115 | * {@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}. | |
| 131 | 117 | */ |
| 132 | 118 | public static MediaType getMediaType( final File file ) |
| ... | ||
| 147 | 133 | * @return The IANA-defined {@link MediaType}, or |
| 148 | 134 | * {@link MediaType#UNDEFINED} if indeterminate. |
| 149 | * @throws IOException Could not read from the {@link SysFile}. | |
| 135 | * @throws IOException Could not read from the stream. | |
| 150 | 136 | */ |
| 151 | 137 | public static MediaType getMediaType( final BufferedInputStream bis ) |
| ... | ||
| 159 | 145 | |
| 160 | 146 | /** |
| 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. | |
| 164 | 151 | * |
| 165 | 152 | * @param is Data source to ascertain the {@link MediaType}. |
| 166 | 153 | * @return The IANA-defined {@link MediaType}, or |
| 167 | 154 | * {@link MediaType#UNDEFINED} if indeterminate. |
| 168 | 155 | * @throws IOException Could not read from the {@link InputStream}. |
| 169 | * @see #getMediaType(BufferedInputStream) to perform a non-destructive | |
| 170 | * read. | |
| 171 | 156 | */ |
| 172 | 157 | private static MediaType getMediaType( final InputStream is ) |
| ... | ||
| 185 | 170 | |
| 186 | 171 | /** |
| 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}. | |
| 189 | 174 | * |
| 190 | 175 | * @param data The input byte values to pad. |
| 191 | 176 | * @return The data with padding. |
| 192 | 177 | */ |
| 193 | 178 | private static int[] ints( final int... data ) { |
| 194 | final var magic = new int[ FORMAT_LENGTH ]; | |
| 179 | final var magic = new int[ FORMAT_LENGTH + 1 ]; | |
| 195 | 180 | int i = -1; |
| 196 | 181 | |
| 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 | } | |
| 1 | 75 |
| 1 | /* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.io; |
| 3 | 3 | |
| 4 | import java.io.File; | |
| 5 | import java.io.FileInputStream; | |
| 6 | import java.io.IOException; | |
| 4 | 7 | 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; | |
| 7 | 13 | |
| 14 | import static com.keenwrite.util.DataTypeConverter.toHex; | |
| 8 | 15 | import static java.lang.System.getenv; |
| 9 | 16 | import static java.nio.file.Files.isExecutable; |
| 17 | import static java.util.regex.Pattern.compile; | |
| 10 | 18 | import static java.util.regex.Pattern.quote; |
| 19 | import static org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS; | |
| 11 | 20 | |
| 12 | 21 | /** |
| 13 | 22 | * Responsible for file-related functionality. |
| 14 | 23 | */ |
| 15 | public class SysFile extends java.io.File { | |
| 24 | public final class SysFile extends java.io.File { | |
| 16 | 25 | /** |
| 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. | |
| 18 | 28 | */ |
| 19 | 29 | 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 ); | |
| 21 | 52 | |
| 22 | 53 | /** |
| 23 | 54 | * Creates a new instance for a given file name. |
| 24 | 55 | * |
| 25 | * @param pathname File name to represent for subsequent operations. | |
| 56 | * @param filename Filename to query existence as executable. | |
| 26 | 57 | */ |
| 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(); | |
| 29 | 80 | } |
| 30 | 81 | |
| 31 | 82 | /** |
| 32 | 83 | * 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 | |
| 34 | 85 | * environment variable. This will search the PATH each time the method |
| 35 | 86 | * is invoked, triggering a full directory scan for all paths listed in |
| 36 | 87 | * the environment variable. The result is not cached, so avoid calling |
| 37 | 88 | * 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. | |
| 38 | 94 | * |
| 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. | |
| 41 | 98 | */ |
| 42 | public boolean canRun() { | |
| 99 | public Optional<Path> locate( final Function<String, String> map ) { | |
| 43 | 100 | final var exe = getName(); |
| 44 | final var paths = getenv( "PATH" ).split( quote( pathSeparator ) ); | |
| 101 | final var paths = paths( map ).split( quote( pathSeparator ) ); | |
| 45 | 102 | |
| 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 ); | |
| 49 | 105 | |
| 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 ); | |
| 54 | 111 | } |
| 112 | } | |
| 113 | } | |
| 55 | 114 | |
| 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 ) ); | |
| 57 | 173 | } |
| 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 | } | |
| 59 | 266 | } |
| 60 | 267 | } |
| 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 | } | |
| 1 | 159 |
| 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 | } | |
| 1 | 136 |
| 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 | } | |
| 1 | 299 |
| 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 | } | |
| 1 | 38 |
| 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 | } | |
| 1 | 44 |
| 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 | } | |
| 1 | 36 |
| 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 | } | |
| 1 | 19 |
| 29 | 29 | public static final Key KEY_R_DELIM_ENDED = key( KEY_R_DELIM, "ended" ); |
| 30 | 30 | |
| 31 | ||
| 31 | 32 | public static final Key KEY_IMAGES = key( KEY_ROOT, "images" ); |
| 33 | public static final Key KEY_CACHES_DIR = key( KEY_IMAGES, "cache" ); | |
| 32 | 34 | public static final Key KEY_IMAGES_DIR = key( KEY_IMAGES, "dir" ); |
| 33 | 35 | public static final Key KEY_IMAGES_ORDER = key( KEY_IMAGES, "order" ); |
| ... | ||
| 83 | 85 | public static final Key KEY_TYPESET = key( KEY_ROOT, "typeset" ); |
| 84 | 86 | 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" ); | |
| 85 | 89 | public static final Key KEY_TYPESET_CONTEXT_THEMES = key( KEY_TYPESET_CONTEXT, "themes" ); |
| 86 | 90 | public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" ); |
| 22 | 22 | import static com.dlsc.preferencesfx.PreferencesFxEvent.EVENT_PREFERENCES_SAVED; |
| 23 | 23 | 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; | |
| 355 | 392 | } |
| 356 | 393 |
| 14 | 14 | import java.util.function.Function; |
| 15 | 15 | |
| 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() { | |
| 565 | 568 | final var dir = getFile( KEY_TYPESET_CONTEXT_THEMES_PATH ); |
| 566 | 569 | final var name = getString( KEY_TYPESET_CONTEXT_THEME_SELECTION ); |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.preview; |
| 3 | 3 | |
| ... | ||
| 15 | 15 | |
| 16 | 16 | import static com.keenwrite.events.StatusEvent.clue; |
| 17 | import static com.keenwrite.io.HttpFacade.httpGet; | |
| 17 | import static com.keenwrite.io.downloads.DownloadManager.open; | |
| 18 | 18 | import static com.keenwrite.preview.MathRenderer.MATH_RENDERER; |
| 19 | 19 | import static com.keenwrite.preview.SvgRasterizer.BROKEN_IMAGE_PLACEHOLDER; |
| ... | ||
| 51 | 51 | case HTML_IMAGE -> { |
| 52 | 52 | final var source = e.getAttribute( HTML_IMAGE_SRC ); |
| 53 | var mediaType = MediaType.fromFilename( source ); | |
| 53 | ||
| 54 | 54 | URI uri = null; |
| 55 | 55 | |
| 56 | 56 | 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 | ); | |
| 62 | 64 | } |
| 63 | 65 | |
| 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 ); | |
| 68 | 67 | } |
| 69 | 68 | } |
| 70 | else if( mediaType.isSvg() ) { | |
| 69 | else if( MediaType.fromFilename( source ).isSvg() ) { | |
| 71 | 70 | // Attempt to rasterize based on file name. |
| 72 | 71 | final var path = Path.of( new URI( source ).getPath() ); |
| ... | ||
| 98 | 97 | } |
| 99 | 98 | |
| 100 | return image; | |
| 99 | return image == null ? BROKEN_IMAGE : image; | |
| 101 | 100 | } |
| 102 | 101 | |
| 4 | 4 | import com.keenwrite.typesetting.Typesetter; |
| 5 | 5 | |
| 6 | import java.io.IOException; | |
| 7 | ||
| 8 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; | |
| 6 | import static com.keenwrite.Bootstrap.APP_TITLE_ABBR; | |
| 9 | 7 | import static com.keenwrite.events.StatusEvent.clue; |
| 10 | 8 | import static com.keenwrite.io.MediaType.TEXT_XML; |
| ... | ||
| 18 | 16 | */ |
| 19 | 17 | public final class PdfProcessor extends ExecutorProcessor<String> { |
| 20 | private final ProcessorContext mContext; | |
| 18 | private final ProcessorContext mProcessorContext; | |
| 21 | 19 | |
| 22 | 20 | public PdfProcessor( final ProcessorContext context ) { |
| 23 | 21 | assert context != null; |
| 24 | mContext = context; | |
| 22 | mProcessorContext = context; | |
| 25 | 23 | } |
| 26 | 24 | |
| ... | ||
| 36 | 34 | try { |
| 37 | 35 | 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 ); | |
| 40 | 40 | final var typesetter = Typesetter |
| 41 | 41 | .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() ) | |
| 46 | 49 | .build(); |
| 47 | 50 | |
| 48 | 51 | typesetter.typeset(); |
| 49 | 52 | |
| 50 | 53 | // Smote the temporary file after typesetting the document. |
| 51 | if( typesetter.autoclean() ) { | |
| 54 | if( typesetter.autoRemove() ) { | |
| 52 | 55 | deleteIfExists( document ); |
| 53 | 56 | } |
| 54 | } catch( final IOException | InterruptedException ex ) { | |
| 57 | } catch( final Exception ex ) { | |
| 55 | 58 | // Typesetter runtime exceptions will pass up the call stack. |
| 56 | 59 | clue( "Main.status.typeset.failed", ex ); |
| 20 | 20 | import java.util.function.Supplier; |
| 21 | 21 | |
| 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() ); | |
| 367 | 399 | } |
| 368 | 400 |
| 62 | 62 | }; |
| 63 | 63 | |
| 64 | final var inputType = context.getInputFileType(); | |
| 64 | final var inputType = context.getSourceFileType(); | |
| 65 | 65 | final Processor<String> processor; |
| 66 | 66 |
| 88 | 88 | @Override |
| 89 | 89 | public String apply( final String text ) { |
| 90 | assert text != null; | |
| 91 | ||
| 90 | 92 | return replace( text, getDefinitions() ); |
| 91 | 93 | } |
| ... | ||
| 100 | 102 | */ |
| 101 | 103 | protected Map<String, String> entoken( final Map<String, String> map ) { |
| 104 | assert map != null; | |
| 105 | ||
| 102 | 106 | final var result = new HashMap<String, String>( map.size() ); |
| 103 | 107 | |
| 1 | /* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */ | |
| 1 | /* Copyright 2022 White Magic Software, Ltd. -- All rights reserved. */ | |
| 2 | 2 | package com.keenwrite.processors; |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.dom.DocumentParser; |
| 5 | import com.keenwrite.io.MediaTypeExtension; | |
| 5 | 6 | import com.keenwrite.ui.heuristics.WordCounter; |
| 7 | import com.keenwrite.util.DataTypeConverter; | |
| 6 | 8 | import com.whitemagicsoftware.keenquotes.parser.Contractions; |
| 7 | 9 | import com.whitemagicsoftware.keenquotes.parser.Curler; |
| ... | ||
| 15 | 17 | import java.util.Map; |
| 16 | 18 | |
| 17 | import static com.keenwrite.Bootstrap.APP_TITLE_LOWERCASE; | |
| 19 | import static com.keenwrite.Bootstrap.APP_TITLE_ABBR; | |
| 18 | 20 | import static com.keenwrite.dom.DocumentParser.createMeta; |
| 19 | 21 | import static com.keenwrite.dom.DocumentParser.visit; |
| 20 | 22 | import static com.keenwrite.events.StatusEvent.clue; |
| 21 | import static com.keenwrite.io.HttpFacade.httpGet; | |
| 23 | import static com.keenwrite.io.downloads.DownloadManager.open; | |
| 22 | 24 | import static com.keenwrite.util.ProtocolScheme.getProtocol; |
| 23 | 25 | import static com.whitemagicsoftware.keenquotes.lex.FilterType.FILTER_XML; |
| ... | ||
| 81 | 83 | |
| 82 | 84 | if( attr != null ) { |
| 83 | final var imageFile = exportImage( attr.getTextContent() ); | |
| 85 | final var src = attr.getTextContent(); | |
| 86 | final Path location; | |
| 87 | final Path imagesDir; | |
| 84 | 88 | |
| 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() ); | |
| 86 | 104 | } |
| 87 | 105 | } catch( final Exception ex ) { |
| ... | ||
| 158 | 176 | |
| 159 | 177 | /** |
| 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. | |
| 163 | 181 | * |
| 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. | |
| 167 | 185 | */ |
| 168 | private Path exportImage( final String src ) throws Exception { | |
| 169 | return getProtocol( src ).isRemote() | |
| 170 | ? downloadImage( src ) | |
| 171 | : resolveImage( src ); | |
| 172 | } | |
| 173 | ||
| 174 | 186 | private Path downloadImage( final String src ) throws Exception { |
| 175 | 187 | final Path imageFile; |
| 188 | final var cachesPath = getCachesPath(); | |
| 176 | 189 | |
| 177 | 190 | clue( "Main.status.image.xhtml.image.download", src ); |
| 178 | 191 | |
| 179 | try( final var response = httpGet( src ) ) { | |
| 192 | try( final var response = open( src ) ) { | |
| 180 | 193 | final var mediaType = response.getMediaType(); |
| 181 | 194 | |
| 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 | } | |
| 184 | 205 | |
| 185 | 206 | try( final var image = response.getInputStream() ) { |
| ... | ||
| 196 | 217 | |
| 197 | 218 | private Path resolveImage( final String src ) throws Exception { |
| 198 | var imagePath = getImagePath(); | |
| 219 | var imagePath = getImagesPath(); | |
| 199 | 220 | var found = false; |
| 200 | 221 | |
| 201 | 222 | Path imageFile = null; |
| 202 | 223 | |
| 203 | 224 | clue( "Main.status.image.xhtml.image.resolve", src ); |
| 204 | 225 | |
| 205 | 226 | for( final var extension : getImageOrder() ) { |
| 206 | 227 | final var filename = format( |
| 207 | 228 | "%s%s%s", src, extension.isBlank() ? "" : ".", extension ); |
| 208 | imageFile = Path.of( imagePath, filename ); | |
| 229 | imageFile = imagePath.resolve( filename ); | |
| 209 | 230 | |
| 210 | 231 | if( imageFile.toFile().exists() ) { |
| 211 | 232 | found = true; |
| 212 | 233 | break; |
| 213 | 234 | } |
| 214 | 235 | } |
| 215 | 236 | |
| 216 | 237 | if( !found ) { |
| 217 | imagePath = getDocumentDir().toString(); | |
| 218 | imageFile = Path.of( imagePath, src ); | |
| 238 | imagePath = getDocumentDir(); | |
| 239 | imageFile = imagePath.resolve( src ); | |
| 219 | 240 | |
| 220 | 241 | if( !imageFile.toFile().exists() ) { |
| ... | ||
| 231 | 252 | } |
| 232 | 253 | |
| 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(); | |
| 235 | 260 | } |
| 236 | 261 | |
| ... | ||
| 261 | 286 | } |
| 262 | 287 | |
| 263 | private boolean autoclean() { | |
| 264 | return mContext.getAutoClean(); | |
| 288 | private boolean autoRemove() { | |
| 289 | return mContext.getAutoRemove(); | |
| 265 | 290 | } |
| 266 | 291 | |
| 58 | 58 | @Override |
| 59 | 59 | List<Extension> createExtensions( final ProcessorContext context ) { |
| 60 | final var inputPath = context.getInputPath(); | |
| 60 | final var inputPath = context.getSourcePath(); | |
| 61 | 61 | final var mediaType = MediaType.valueFrom( inputPath ); |
| 62 | 62 | final Processor<String> processor; |
| 98 | 98 | final var fqfn = Path.of( baseDir.toString(), uri ).toFile(); |
| 99 | 99 | |
| 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 ) { | |
| 105 | 102 | return valid( link, uri ); |
| 106 | 103 | } |
| ... | ||
| 139 | 136 | |
| 140 | 137 | private Path getImageDir() { |
| 141 | return mContext.getImageDir(); | |
| 138 | return mContext.getImagesPath(); | |
| 142 | 139 | } |
| 143 | 140 | |
| 11 | 11 | * Optimization: Cache keys until the map changes. |
| 12 | 12 | */ |
| 13 | private String[] mKeys; | |
| 13 | private String[] mKeys = new String[ 0 ]; | |
| 14 | 14 | |
| 15 | 15 | /** |
| 16 | 16 | * Optimization: Cache values until the map changes. |
| 17 | 17 | */ |
| 18 | private String[] mValues; | |
| 18 | private String[] mValues = new String[ 0 ]; | |
| 19 | 19 | |
| 20 | 20 | /** |
| 21 | 21 | * Optimization: Detect when the map changes. |
| 22 | 22 | */ |
| 23 | 23 | private int mMapHash; |
| 24 | ||
| 25 | private final Object mMutex = new Object(); | |
| 24 | 26 | |
| 25 | 27 | /** |
| 26 | 28 | * Default (empty) constructor. |
| 27 | 29 | */ |
| 28 | protected AbstractTextReplacer() { | |
| 29 | } | |
| 30 | protected AbstractTextReplacer() { } | |
| 30 | 31 | |
| 31 | 32 | 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 | } | |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | 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 | } | |
| 41 | 44 | } |
| 42 | 45 | |
| 43 | 46 | 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 | } | |
| 48 | 53 | } |
| 49 | 54 | } |
| 18 | 18 | @Override |
| 19 | 19 | public String replace( final String text, final Map<String, String> map ) { |
| 20 | assert text != null; | |
| 21 | assert map != null; | |
| 22 | ||
| 20 | 23 | // Create a buffer sufficiently large that re-allocations are minimized. |
| 21 | 24 | final var sb = new StringBuilder( (int) (text.length() * 1.25) ); |
| 22 | 25 | |
| 23 | 26 | // Definition names cannot overlap. |
| 24 | 27 | 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 | } | |
| 26 | 33 | |
| 27 | 34 | int index = 0; |
| 37 | 37 | public static String replace( |
| 38 | 38 | final String haystack, final Map<String, String> needles ) { |
| 39 | assert haystack != null; | |
| 40 | assert needles != null; | |
| 41 | ||
| 39 | 42 | return getTextReplacer( haystack.length() ).replace( haystack, needles ); |
| 40 | 43 | } |
| 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 | } | |
| 1 | 129 |
| 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 | } | |
| 1 | 247 |
| 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 | } | |
| 1 | 61 |
| 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 ) ); | |
| 440 | 255 | } |
| 441 | 256 | } |
| 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 | } | |
| 1 | 70 |
| 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 | } | |
| 1 | 208 |
| 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 | } | |
| 1 | 23 |
| 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 | } | |
| 1 | 96 |
| 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 | } | |
| 1 | 12 |
| 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 | } | |
| 1 | 123 |
| 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 | } | |
| 1 | 316 |
| 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 | } | |
| 1 | 24 |
| 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 | } | |
| 1 | 29 |
| 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 | } | |
| 1 | 88 |
| 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 | } | |
| 1 | 27 |
| 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 | } | |
| 1 | 78 |
| 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 | } | |
| 1 | 19 |
| 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 | } | |
| 1 | 151 |
| 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 | } | |
| 1 | 32 |
| 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 | } | |
| 1 | 102 |
| 47 | 47 | import static com.keenwrite.Messages.get; |
| 48 | 48 | 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(); } | |
| 528 | 549 | |
| 529 | 550 | public void view_statistics() { |
| 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 | } | |
| 1 | 32 |
| 39 | 39 | import static javafx.geometry.Pos.CENTER; |
| 40 | 40 | 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; | |
| 42 | 42 | |
| 43 | 43 | /** |
| ... | ||
| 71 | 71 | |
| 72 | 72 | setResultConverter( button -> button == OK ? settings : null ); |
| 73 | initComboBox( mComboBox, mSettings, readThemes( themesDir ) ); | |
| 74 | 73 | |
| 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 | } | |
| 77 | 86 | |
| 78 | 87 | var title = "Dialog.typesetting.settings.header."; |
| 86 | 86 | public void setInitialFilename( final File file ) { |
| 87 | 87 | assert file != null; |
| 88 | ||
| 88 | 89 | mChooser.setInitialFileName( file.getName() ); |
| 89 | 90 | } |
| 90 | 91 | |
| 91 | 92 | @Override |
| 92 | 93 | public void setInitialDirectory( final Path path ) { |
| 93 | 94 | 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 | ); | |
| 95 | 101 | } |
| 96 | 102 |
| 3 | 3 | |
| 4 | 4 | import com.keenwrite.events.StatusEvent; |
| 5 | import com.keenwrite.ui.clipboard.Clipboard; | |
| 5 | 6 | import javafx.beans.property.SimpleStringProperty; |
| 6 | 7 | import javafx.beans.property.StringProperty; |
| 7 | 8 | import javafx.collections.ObservableList; |
| 8 | 9 | import javafx.scene.control.*; |
| 9 | import javafx.scene.input.ClipboardContent; | |
| 10 | 10 | import javafx.scene.input.KeyCodeCombination; |
| 11 | 11 | import javafx.stage.Stage; |
| ... | ||
| 29 | 29 | import static javafx.scene.control.ButtonType.OK; |
| 30 | 30 | import static javafx.scene.control.SelectionMode.MULTIPLE; |
| 31 | import static javafx.scene.input.Clipboard.getSystemClipboard; | |
| 32 | 31 | import static javafx.scene.input.KeyCode.C; |
| 33 | 32 | import static javafx.scene.input.KeyCode.INSERT; |
| ... | ||
| 249 | 248 | } |
| 250 | 249 | |
| 251 | final var contents = new ClipboardContent(); | |
| 252 | contents.putString( sb.toString() ); | |
| 253 | getSystemClipboard().setContent( contents ); | |
| 250 | Clipboard.write( sb ); | |
| 254 | 251 | } |
| 255 | 252 | } |
| 70 | 70 | final var offset = change.getPosition(); |
| 71 | 71 | 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 | } | |
| 75 | 84 | |
| 76 | 85 | // Prevent doubling-up styles. |
| 114 | 114 | if( (result = thisChunkLength - thatChunkLength) == 0 ) { |
| 115 | 115 | 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 ); | |
| 118 | 117 | |
| 119 | 118 | if( result != 0 ) { |
| 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 | } | |
| 1 | 49 |
| 48 | 48 | return getProtocol( new URI( uri ) ); |
| 49 | 49 | } 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 | |
| 51 | 51 | // reference a resource using the parent URL's security model. This |
| 52 | 52 | // is known as a protocol-relative URL. |
| 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 | } | |
| 1 | 34 |
| 10 | 10 | requires annotations; |
| 11 | 11 | |
| 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; | |
| 18 | 18 | |
| 19 | 19 | requires com.dlsc.formsfx; |
| ... | ||
| 42 | 42 | requires info.picocli; |
| 43 | 43 | requires jsymspell; |
| 44 | requires plexus.utils; | |
| 45 | 44 | requires tiwulfx.dock; |
| 46 | 45 | requires wellbehavedfx; |
| 47 | 46 | requires xml.apis.ext; |
| 48 | 47 | requires java.logging; |
| 49 | 48 | } |
| 50 | ||
| 51 | 49 | |
| 1 | # Used by the Gradle build script and the application. | |
| 2 | 1 | application.title=KeenWrite |
| 3 | ||
| 2 | container.version=2.11.5 | |
| 4 | 3 |
| 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 | ||
| 22 | 1 |
| 28 | 28 | workspace.typeset.context.clean.desc=Delete ancillary files after an unsuccessful export. |
| 29 | 29 | 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 | |
| 599 | 728 | |
| 600 | 729 | Action.view.statusbar.description=Toggle status bar |
| 25 | 25 | to complete (on modern hardware). |
| 26 | 26 | |
| 27 | # Lexicons | |
| 27 | # Resources | |
| 28 | 28 | |
| 29 | 29 | There are numerous sources of word and frequency lists available, including: |
| 19 | 19 | import javafx.scene.control.TreeItem; |
| 20 | 20 | import javafx.stage.Stage; |
| 21 | import org.assertj.core.util.Files; | |
| 21 | 22 | import org.testfx.framework.junit5.Start; |
| 22 | 23 | |
| ... | ||
| 49 | 50 | final var transformer = new YamlTreeTransformer(); |
| 50 | 51 | final var editor = new DefinitionEditor( transformer ); |
| 52 | final var file = Files.newTemporaryFile(); | |
| 51 | 53 | |
| 52 | 54 | final var tabPane1 = new DetachableTabPane(); |
| 53 | 55 | tabPane1.addTab( "Editor", editor ); |
| 54 | 56 | |
| 55 | 57 | final var tabPane2 = new DetachableTabPane(); |
| 56 | 58 | final var tab21 = |
| 57 | 59 | tabPane2.addTab( "Picker", new ColorPicker() ); |
| 58 | 60 | final var tab22 = |
| 59 | tabPane2.addTab( "Editor", new MarkdownEditor( workspace ) ); | |
| 61 | tabPane2.addTab( "Editor", new MarkdownEditor( file, workspace ) ); | |
| 60 | 62 | tab21.setTooltip( new Tooltip( "Colour Picker" ) ); |
| 61 | 63 | tab22.setTooltip( new Tooltip( "Text Editor" ) ); |
| 3 | 3 | import com.keenwrite.AwaitFxExtension; |
| 4 | 4 | import com.keenwrite.preferences.Workspace; |
| 5 | import org.assertj.core.util.Files; | |
| 5 | 6 | import org.junit.jupiter.api.Test; |
| 6 | 7 | import org.junit.jupiter.api.extension.ExtendWith; |
| 7 | 8 | import org.testfx.framework.junit5.ApplicationExtension; |
| 8 | 9 | |
| 10 | import java.io.File; | |
| 9 | 11 | import java.util.regex.Pattern; |
| 10 | 12 | |
| 11 | 13 | import static java.util.regex.Pattern.compile; |
| 12 | 14 | import static javafx.application.Platform.runLater; |
| 13 | 15 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 14 | 16 | import static org.junit.jupiter.api.Assertions.assertTrue; |
| 15 | 17 | |
| 16 | 18 | @ExtendWith( {ApplicationExtension.class, AwaitFxExtension.class} ) |
| 17 | 19 | public class MarkdownEditorTest { |
| 20 | private static final File TEMP_FILE = Files.newTemporaryFile(); | |
| 21 | ||
| 18 | 22 | private static final String[] WORDS = new String[]{ |
| 19 | 23 | "Italicize", |
| ... | ||
| 107 | 111 | private MarkdownEditor createMarkdownEditor() { |
| 108 | 112 | final var workspace = new Workspace(); |
| 109 | final var editor = new MarkdownEditor( workspace ); | |
| 113 | final var editor = new MarkdownEditor( TEMP_FILE, workspace ); | |
| 110 | 114 | editor.setText( TEXT ); |
| 111 | 115 | return editor; |
| 4 | 4 | import org.junit.jupiter.api.Test; |
| 5 | 5 | |
| 6 | import java.net.URI; | |
| 7 | 6 | import java.util.Map; |
| 8 | 7 | |
| 9 | import static com.keenwrite.io.HttpFacade.httpGet; | |
| 10 | 8 | import static com.keenwrite.io.MediaType.*; |
| 9 | import static com.keenwrite.io.downloads.DownloadManager.open; | |
| 11 | 10 | import static org.junit.jupiter.api.Assertions.*; |
| 12 | 11 | |
| ... | ||
| 52 | 51 | //@formatter:off |
| 53 | 52 | final var map = Map.of( |
| 54 | "https://stackoverflow.com/robots.txt", TEXT_PLAIN, | |
| 53 | "https://kroki.io/robots.txt", TEXT_PLAIN, | |
| 55 | 54 | "https://place-hold.it/300x500", IMAGE_GIF, |
| 56 | 55 | "https://placekitten.com/g/200/300", IMAGE_JPEG, |
| 57 | 56 | "https://upload.wikimedia.org/wikipedia/commons/9/9f/Vimlogo.svg", IMAGE_SVG_XML, |
| 58 | 57 | "https://kroki.io//graphviz/svg/eNpLyUwvSizIUHBXqPZIzcnJ17ULzy_KSanlAgB1EAjQ", IMAGE_SVG_XML |
| 59 | 58 | ); |
| 60 | 59 | //@formatter:on |
| 61 | 60 | |
| 62 | 61 | map.forEach( ( k, v ) -> { |
| 63 | try( var response = httpGet( new URI( k ) ) ) { | |
| 62 | try( var response = open( k ) ) { | |
| 64 | 63 | assertEquals( v, response.getMediaType() ); |
| 65 | } catch( Exception e ) { | |
| 64 | } catch( final Exception e ) { | |
| 66 | 65 | fail(); |
| 67 | 66 | } |
| 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 | } | |
| 1 | 52 |
| 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 | } | |
| 1 | 22 |
| 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 | } | |
| 1 | 58 |
| 43 | 43 | return builder() |
| 44 | 44 | .with( ProcessorContext.Mutator::setExportFormat, format ) |
| 45 | .with( ProcessorContext.Mutator::setInputPath, Path.of( "f.md" ) ) | |
| 45 | .with( ProcessorContext.Mutator::setSourcePath, Path.of( "f.md" ) ) | |
| 46 | 46 | .with( ProcessorContext.Mutator::setDefinitions, HashMap::new ) |
| 47 | 47 | .with( ProcessorContext.Mutator::setLocale, () -> ENGLISH ) |
| 48 | 48 | .with( ProcessorContext.Mutator::setMetadata, HashMap::new ) |
| 49 | .with( ProcessorContext.Mutator::setThemePath, () -> Path.of( "b" ) ) | |
| 49 | .with( ProcessorContext.Mutator::setThemesPath, () -> Path.of( "b" ) ) | |
| 50 | 50 | .with( ProcessorContext.Mutator::setCaret, () -> caret ) |
| 51 | .with( ProcessorContext.Mutator::setImageDir, () -> new File( "i" ) ) | |
| 51 | .with( ProcessorContext.Mutator::setImagesPath, () -> new File( "i" ) ) | |
| 52 | 52 | .with( ProcessorContext.Mutator::setImageOrder, () -> "" ) |
| 53 | 53 | .with( ProcessorContext.Mutator::setImageServer, () -> "" ) |
| 54 | 54 | .with( ProcessorContext.Mutator::setSigilBegan, () -> "" ) |
| 55 | 55 | .with( ProcessorContext.Mutator::setSigilEnded, () -> "" ) |
| 56 | 56 | .with( ProcessorContext.Mutator::setRScript, () -> "" ) |
| 57 | 57 | .with( ProcessorContext.Mutator::setRWorkingDir, () -> Path.of( "r" ) ) |
| 58 | 58 | .with( ProcessorContext.Mutator::setCurlQuotes, () -> true ) |
| 59 | .with( ProcessorContext.Mutator::setAutoClean, () -> true ) | |
| 59 | .with( ProcessorContext.Mutator::setAutoRemove, () -> true ) | |
| 60 | 60 | .build(); |
| 61 | 61 | } |
| 141 | 141 | return ProcessorContext |
| 142 | 142 | .builder() |
| 143 | .with( ProcessorContext.Mutator::setInputPath, inputPath ) | |
| 143 | .with( ProcessorContext.Mutator::setSourcePath, inputPath ) | |
| 144 | 144 | .with( ProcessorContext.Mutator::setExportFormat, XHTML_TEX ) |
| 145 | 145 | .with( ProcessorContext.Mutator::setCaret, () -> Caret.builder().build() ) |
| 35 | 35 | import com.whitemagicsoftware.tex.graphics.SvgDomGraphics2D; |
| 36 | 36 | import com.whitemagicsoftware.tex.graphics.SvgGraphics2D; |
| 37 | import org.apache.batik.transcoder.TranscoderException; | |
| 37 | import io.sf.carte.echosvg.transcoder.TranscoderException; | |
| 38 | 38 | import org.junit.jupiter.api.Test; |
| 39 | 39 | import org.xml.sax.SAXException; |