Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git

Export as joined PDF, control log purging

AuthorDaveJarvis <email>
Date2021-05-15 14:54:45 GMT-0700
Commit60f5d1a3e5e65f535c586853301ba00080c5d2a8
Parent7ccfdd5
src/main/java/com/keenwrite/MainApp.java
@Override
public void start( final Stage stage ) {
- // Must be instantiated after the UI is initialized (i.e., not in main).
+ // Must be instantiated after the UI is initialized (i.e., not in main)
+ // because it interacts with GUI properties.
mWorkspace = new Workspace();
src/main/java/com/keenwrite/preferences/PreferencesController.java
Setting.of( label( KEY_TYPESET_CONTEXT_THEMES_PATH ) ),
Setting.of( title( KEY_TYPESET_CONTEXT_THEMES_PATH ),
- fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true )
+ fileProperty( KEY_TYPESET_CONTEXT_THEMES_PATH ), true ),
+ Setting.of( label( KEY_TYPESET_CONTEXT_CLEAN ) ),
+ Setting.of( title( KEY_TYPESET_CONTEXT_CLEAN ),
+ booleanProperty( KEY_TYPESET_CONTEXT_CLEAN ) )
)
),
src/main/java/com/keenwrite/preferences/Workspace.java
entry( KEY_LANGUAGE_LOCALE, asLocaleProperty( LOCALE_DEFAULT ) ),
+ entry( KEY_TYPESET_CONTEXT_CLEAN, asBooleanProperty(true) ),
entry( KEY_TYPESET_CONTEXT_THEMES_PATH, asFileProperty( USER_DIRECTORY ) ),
entry( KEY_TYPESET_CONTEXT_THEME_SELECTION, asStringProperty( "boschet" ) )
}
- public BooleanProperty booleanProperty(final Key key) {
+ public BooleanProperty booleanProperty( final Key key ) {
assert key != null;
return valuesProperty( key );
src/main/java/com/keenwrite/preferences/WorkspaceKeys.java
public static final Key KEY_TYPESET_CONTEXT_THEMES_PATH = key( KEY_TYPESET_CONTEXT_THEMES, "path" );
public static final Key KEY_TYPESET_CONTEXT_THEME_SELECTION = key( KEY_TYPESET_CONTEXT_THEMES, "selection" );
+ public static final Key KEY_TYPESET_CONTEXT_CLEAN = key( KEY_TYPESET_CONTEXT, "clean" );
//@formatter:on
src/main/java/com/keenwrite/typesetting/Typesetter.java
import static com.keenwrite.constants.Constants.DEFAULT_DIRECTORY;
import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH;
-import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION;
-import static java.lang.ProcessBuilder.Redirect.DISCARD;
-import static java.lang.String.format;
-import static java.lang.System.currentTimeMillis;
-import static java.lang.System.getProperty;
-import static java.nio.file.Files.*;
-import static java.util.Arrays.asList;
-import static java.util.concurrent.TimeUnit.*;
-import static org.apache.commons.io.FilenameUtils.removeExtension;
-
-/**
- * Responsible for invoking an executable to typeset text. This will
- * construct suitable command-line arguments to invoke the typesetting engine.
- */
-public class Typesetter {
- private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
-
- private final Workspace mWorkspace;
-
- /**
- * Creates a new {@link Typesetter} instance capable of configuring the
- * typesetter used to generate a typeset document.
- */
- public Typesetter( final Workspace workspace ) {
- mWorkspace = workspace;
- }
-
- public static boolean canRun() {
- return TYPESETTER.canRun();
- }
-
- /**
- * This will typeset the document using a new process. The return value only
- * indicates whether the typesetter exists, not whether the typesetting was
- * successful.
- *
- * @param in The input document to typeset.
- * @param out Path to the finished typeset document.
- * @throws IOException If the process could not be started.
- * @throws InterruptedException If the process was killed.
- * @throws TypesetterNotFoundException When no typesetter is along the PATH.
- */
- public void typeset( final Path in, final Path out )
- throws IOException, InterruptedException, TypesetterNotFoundException {
- if( TYPESETTER.canRun() ) {
- clue( "Main.status.typeset.began", out );
- final var task = new TypesetTask( in, out );
- final var time = currentTimeMillis();
- final var success = task.typeset();
-
- clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
- out, since( time )
- );
- }
- else {
- throw new TypesetterNotFoundException( TYPESETTER.toString() );
- }
- }
-
- /**
- * Calculates the time that has elapsed from the current time to the
- * given moment in time.
- *
- * @param start The starting time, which should be before the current time.
- * @return A human-readable formatted time.
- * @see #asElapsed(long)
- */
- private static String since( final long start ) {
- return asElapsed( currentTimeMillis() - start );
- }
-
- /**
- * Converts an elapsed time to a human-readable format (hours, minutes,
- * seconds, and milliseconds).
- *
- * @param elapsed An elapsed time, in milliseconds.
- * @return Human-readable elapsed time.
- */
- private static String asElapsed( final long elapsed ) {
- final var hours = MILLISECONDS.toHours( elapsed );
- final var eHours = elapsed - HOURS.toMillis( hours );
- final var minutes = MILLISECONDS.toMinutes( eHours );
- final var eMinutes = eHours - MINUTES.toMillis( minutes );
- final var seconds = MILLISECONDS.toSeconds( eMinutes );
- final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
- final var milliseconds = MILLISECONDS.toMillis( eSeconds );
-
- return format( "%02d:%02d:%02d.%03d",
- hours, minutes, seconds, milliseconds );
- }
-
- /**
- * Launches a task to typeset a document.
- */
- private class TypesetTask implements Callable<Boolean> {
- private final List<String> mArgs = new ArrayList<>();
- private final Path mInput;
- private final Path mOutput;
-
- /**
- * Working directory must be set because ConTeXt cannot write the
- * result to an arbitrary location.
- */
- private final Path mDirectory;
-
- private TypesetTask( final Path input, final Path output ) {
- assert input != null;
- assert output != null;
-
- final var parentDir = output.getParent();
- mInput = input;
- mOutput = output;
- mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
- }
-
- /**
- * Initializes ConTeXt, which means creating the cache directory if it
- * doesn't already exist. The theme entry point must be named 'main.tex'.
- *
- * @return {@code true} if the cache directory exists.
- */
- private boolean reinitialize() {
- final var filename = mOutput.getFileName();
- final var themes = getThemesPath();
- final var theme = getThemesSelection();
- final var cacheExists = !isEmpty( getCacheDir().toPath() );
-
- // Ensure invoking multiple times will load the correct arguments.
- mArgs.clear();
- mArgs.add( TYPESETTER.getName() );
-
- if( cacheExists ) {
- mArgs.add( "--autogenerate" );
- mArgs.add( "--script" );
- mArgs.add( "mtx-context" );
- mArgs.add( "--batchmode" );
- mArgs.add( "--nonstopmode" );
- mArgs.add( "--purgeall" );
- mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" );
- mArgs.add( "--environment='main'" );
- mArgs.add( "--result='" + filename + "'" );
- mArgs.add( mInput.toString() );
-
- final var sb = new StringBuilder( 128 );
- mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
- clue( sb.toString() );
- }
- else {
- mArgs.add( "--generate" );
- }
-
- return cacheExists;
- }
-
- /**
- * Setting {@code TEXMFCACHE} when run on a fresh system fails on first
- * run. If the cache directory doesn't exist, attempt to create it, then
- * call ConTeXt to generate the PDF. This is brittle because if the
- * directory is empty, or not populated with cached data, a false positive
- * will be returned, resulting in no PDF being created.
- *
- * @return {@code true} if the document was typeset successfully.
- * @throws IOException If the process could not be started.
- * @throws InterruptedException If the process was killed.
- */
- private boolean typeset() throws IOException, InterruptedException {
- return reinitialize() ? call() : call() && reinitialize() && call();
- }
-
- @Override
- public Boolean call() throws IOException, InterruptedException {
- final var stdout = new BoundedCache<String, String>( 150 );
- final var builder = new ProcessBuilder( mArgs );
- builder.directory( mDirectory.toFile() );
- builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
-
- // Without redirecting (or draining) stderr, the command may not
- // terminate successfully.
- builder.redirectError( DISCARD );
-
- final var process = builder.start();
-
- // Reading from stdout allows slurping page numbers while generating.
- final var listener = new PaginationListener(
- process.getInputStream(), stdout );
- listener.start();
-
- process.waitFor();
- final var exit = process.exitValue();
- process.destroy();
-
- // If there was an error, the typesetter will leave behind log, pdf, and
- // error files.
- if( exit > 0 ) {
- final var xmlName = mInput.getFileName().toString();
- final var srcName = mOutput.getFileName().toString();
- final var logName = newExtension( xmlName, ".log" );
- final var errName = newExtension( xmlName, "-error.log" );
- final var pdfName = newExtension( xmlName, ".pdf" );
- final var tuaName = newExtension( xmlName, ".tua" );
- final var badName = newExtension( srcName, ".log" );
-
- log( badName );
- log( logName );
- log( errName );
- log( stdout.keySet().stream().toList() );
-
- deleteIfExists( logName );
- deleteIfExists( errName );
- deleteIfExists( pdfName );
- deleteIfExists( badName );
- deleteIfExists( tuaName );
- }
-
- // Exit value for a successful invocation of the typesetter. This value
- // value is returned when creating the cache on the first run as well as
- // creating PDFs on subsequent runs (after the cache has been created).
- // Users don't care about exit codes, only whether the PDF was generated.
- return exit == 0;
- }
-
- private Path newExtension( final String baseName, final String ext ) {
- return mOutput.resolveSibling( removeExtension( baseName ) + ext );
- }
-
- /**
- * Fires a status message for each line in the given file. The file format
- * is somewhat machine-readable, but no effort beyond line splitting is
- * made to parse the text.
- *
- * @param path Path to the file containing error messages.
- */
- private void log( final Path path ) throws IOException {
- if( exists( path ) ) {
- log( readAllLines( path ) );
- }
- }
-
- private void log( final List<String> lines ) {
- final var splits = new ArrayList<String>( lines.size() * 2 );
-
- for( final var line : lines ) {
- splits.addAll( asList( line.split( "\\\\n" ) ) );
- }
-
- clue( splits );
- }
-
- /**
- * Returns the location of the cache directory.
- *
- * @return A fully qualified path to the location to store temporary
- * files between typesetting runs.
- */
- private java.io.File getCacheDir() {
- final var temp = getProperty( "java.io.tmpdir" );
- final var cache = Path.of( temp, "luatex-cache" );
- return cache.toFile();
- }
-
- /**
- * Answers whether the given directory is empty. The typesetting software
- * creates a non-empty directory by default. The return value from this
- * method is a proxy to answering whether the typesetter has been run for
- * the first time or not.
- *
- * @param path The directory to check for emptiness.
- * @return {@code true} if the directory is empty.
- */
- private boolean isEmpty( final Path path ) {
- try( final var stream = newDirectoryStream( path ) ) {
- return !stream.iterator().hasNext();
- } catch( final NoSuchFileException | FileNotFoundException ex ) {
- // A missing directory means it doesn't exist, ergo is empty.
- return true;
- } catch( final IOException ex ) {
- throw new RuntimeException( ex );
- }
- }
- }
-
- /**
- * Responsible for parsing the output from the typesetting engine and
- * updating the status bar to provide assurance that typesetting is
- * executing.
- *
- * <p>
- * Example lines written to standard output:
- * </p>
- * <pre>{@code
- * pages > flushing realpage 15, userpage 15, subpage 15
- * pages > flushing realpage 16, userpage 16, subpage 16
- * pages > flushing realpage 1, userpage 1, subpage 1
- * pages > flushing realpage 2, userpage 2, subpage 2
- * }</pre>
- * <p>
- * The lines are parsed; the first number is displayed in a status bar
- * message.
- * </p>
- */
- private static class PaginationListener extends Thread {
- private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" );
-
- private final InputStream mInputStream;
-
- private final Map<String, String> mCache;
-
- public PaginationListener(
- final InputStream in, final Map<String, String> cache ) {
- mInputStream = in;
- mCache = cache;
- }
-
- @Override
- public void run() {
- try( final var reader = createReader() ) {
- int pageCount = 1;
- int passCount = 1;
- int pageTotal = 0;
- String line;
-
- while( (line = reader.readLine()) != null ) {
- mCache.put( line, "" );
-
- if( line.startsWith( "pages" ) ) {
- // The bottleneck will be the typesetting engine writing to stdout,
- // not the parsing of stdout.
- final var scanner = new Scanner( line ).useDelimiter( DIGITS );
- final var digits = scanner.next();
- final var page = Integer.parseInt( digits );
-
- // If the page number is less than the previous page count, it
- // means that the typesetting engine has started another pass.
- if( page < pageCount ) {
- passCount++;
- pageTotal = pageCount;
- }
-
- pageCount = page;
-
- // Let the user know that something is happening in the background.
- clue( "Main.status.typeset.page",
- pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
- );
- }
- }
- } catch( final IOException ex ) {
- throw new RuntimeException( ex );
- }
- }
-
- private BufferedReader createReader() {
- return new BufferedReader( new InputStreamReader( mInputStream ) );
- }
- }
-
- private File getThemesPath() {
- return mWorkspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
- }
-
- private String getThemesSelection() {
- return mWorkspace.toString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
+import static com.keenwrite.preferences.WorkspaceKeys.*;
+import static java.lang.ProcessBuilder.Redirect.DISCARD;
+import static java.lang.String.format;
+import static java.lang.System.currentTimeMillis;
+import static java.lang.System.getProperty;
+import static java.nio.file.Files.*;
+import static java.util.Arrays.asList;
+import static java.util.concurrent.TimeUnit.*;
+import static org.apache.commons.io.FilenameUtils.removeExtension;
+
+/**
+ * Responsible for invoking an executable to typeset text. This will
+ * construct suitable command-line arguments to invoke the typesetting engine.
+ */
+public class Typesetter {
+ private static final SysFile TYPESETTER = new SysFile( "mtxrun" );
+
+ private final Workspace mWorkspace;
+
+ /**
+ * Creates a new {@link Typesetter} instance capable of configuring the
+ * typesetter used to generate a typeset document.
+ */
+ public Typesetter( final Workspace workspace ) {
+ mWorkspace = workspace;
+ }
+
+ public static boolean canRun() {
+ return TYPESETTER.canRun();
+ }
+
+ /**
+ * This will typeset the document using a new process. The return value only
+ * indicates whether the typesetter exists, not whether the typesetting was
+ * successful.
+ *
+ * @param in The input document to typeset.
+ * @param out Path to the finished typeset document.
+ * @throws IOException If the process could not be started.
+ * @throws InterruptedException If the process was killed.
+ * @throws TypesetterNotFoundException When no typesetter is along the PATH.
+ */
+ public void typeset( final Path in, final Path out )
+ throws IOException, InterruptedException, TypesetterNotFoundException {
+ if( TYPESETTER.canRun() ) {
+ clue( "Main.status.typeset.began", out );
+ final var task = new TypesetTask( in, out );
+ final var time = currentTimeMillis();
+ final var success = task.typeset();
+
+ clue( "Main.status.typeset.ended." + (success ? "success" : "failure"),
+ out, since( time )
+ );
+ }
+ else {
+ throw new TypesetterNotFoundException( TYPESETTER.toString() );
+ }
+ }
+
+ /**
+ * Calculates the time that has elapsed from the current time to the
+ * given moment in time.
+ *
+ * @param start The starting time, which should be before the current time.
+ * @return A human-readable formatted time.
+ * @see #asElapsed(long)
+ */
+ private static String since( final long start ) {
+ return asElapsed( currentTimeMillis() - start );
+ }
+
+ /**
+ * Converts an elapsed time to a human-readable format (hours, minutes,
+ * seconds, and milliseconds).
+ *
+ * @param elapsed An elapsed time, in milliseconds.
+ * @return Human-readable elapsed time.
+ */
+ private static String asElapsed( final long elapsed ) {
+ final var hours = MILLISECONDS.toHours( elapsed );
+ final var eHours = elapsed - HOURS.toMillis( hours );
+ final var minutes = MILLISECONDS.toMinutes( eHours );
+ final var eMinutes = eHours - MINUTES.toMillis( minutes );
+ final var seconds = MILLISECONDS.toSeconds( eMinutes );
+ final var eSeconds = eMinutes - SECONDS.toMillis( seconds );
+ final var milliseconds = MILLISECONDS.toMillis( eSeconds );
+
+ return format( "%02d:%02d:%02d.%03d",
+ hours, minutes, seconds, milliseconds );
+ }
+
+ /**
+ * Launches a task to typeset a document.
+ */
+ private class TypesetTask implements Callable<Boolean> {
+ private final List<String> mArgs = new ArrayList<>();
+ private final Path mInput;
+ private final Path mOutput;
+
+ /**
+ * Working directory must be set because ConTeXt cannot write the
+ * result to an arbitrary location.
+ */
+ private final Path mDirectory;
+
+ private TypesetTask( final Path input, final Path output ) {
+ assert input != null;
+ assert output != null;
+
+ final var parentDir = output.getParent();
+ mInput = input;
+ mOutput = output;
+ mDirectory = parentDir == null ? DEFAULT_DIRECTORY : parentDir;
+ }
+
+ /**
+ * Initializes ConTeXt, which means creating the cache directory if it
+ * doesn't already exist. The theme entry point must be named 'main.tex'.
+ *
+ * @return {@code true} if the cache directory exists.
+ */
+ private boolean reinitialize() {
+ final var filename = mOutput.getFileName();
+ final var themes = getThemesPath();
+ final var theme = getThemesSelection();
+ final var cacheExists = !isEmpty( getCacheDir().toPath() );
+
+ // Ensure invoking multiple times will load the correct arguments.
+ mArgs.clear();
+ mArgs.add( TYPESETTER.getName() );
+
+ if( cacheExists ) {
+ mArgs.add( "--autogenerate" );
+ mArgs.add( "--script" );
+ mArgs.add( "mtx-context" );
+ mArgs.add( "--batchmode" );
+ mArgs.add( "--nonstopmode" );
+ mArgs.add( "--purgeall" );
+ mArgs.add( "--path='" + Path.of( themes.toString(), theme ) + "'" );
+ mArgs.add( "--environment='main'" );
+ mArgs.add( "--result='" + filename + "'" );
+ mArgs.add( mInput.toString() );
+
+ final var sb = new StringBuilder( 128 );
+ mArgs.forEach( arg -> sb.append( arg ).append( " " ) );
+ clue( sb.toString() );
+ }
+ else {
+ mArgs.add( "--generate" );
+ }
+
+ return cacheExists;
+ }
+
+ /**
+ * Setting {@code TEXMFCACHE} when run on a fresh system fails on first
+ * run. If the cache directory doesn't exist, attempt to create it, then
+ * call ConTeXt to generate the PDF. This is brittle because if the
+ * directory is empty, or not populated with cached data, a false positive
+ * will be returned, resulting in no PDF being created.
+ *
+ * @return {@code true} if the document was typeset successfully.
+ * @throws IOException If the process could not be started.
+ * @throws InterruptedException If the process was killed.
+ */
+ private boolean typeset() throws IOException, InterruptedException {
+ return reinitialize() ? call() : call() && reinitialize() && call();
+ }
+
+ @Override
+ public Boolean call() throws IOException, InterruptedException {
+ final var stdout = new BoundedCache<String, String>( 150 );
+ final var builder = new ProcessBuilder( mArgs );
+ builder.directory( mDirectory.toFile() );
+ builder.environment().put( "TEXMFCACHE", getCacheDir().toString() );
+
+ // Without redirecting (or draining) stderr, the command may not
+ // terminate successfully.
+ builder.redirectError( DISCARD );
+
+ final var process = builder.start();
+
+ // Reading from stdout allows slurping page numbers while generating.
+ final var listener = new PaginationListener(
+ process.getInputStream(), stdout );
+ listener.start();
+
+ process.waitFor();
+ final var exit = process.exitValue();
+ process.destroy();
+
+ // If there was an error, the typesetter will leave behind log, pdf, and
+ // error files.
+ if( exit > 0 ) {
+ final var xmlName = mInput.getFileName().toString();
+ final var srcName = mOutput.getFileName().toString();
+ final var logName = newExtension( xmlName, ".log" );
+ final var errName = newExtension( xmlName, "-error.log" );
+ final var pdfName = newExtension( xmlName, ".pdf" );
+ final var tuaName = newExtension( xmlName, ".tua" );
+ final var badName = newExtension( srcName, ".log" );
+
+ log( badName );
+ log( logName );
+ log( errName );
+ log( stdout.keySet().stream().toList() );
+
+ // Users may opt to keep these files around for debugging purposes.
+ if( autoclean() ) {
+ deleteIfExists( logName );
+ deleteIfExists( errName );
+ deleteIfExists( pdfName );
+ deleteIfExists( badName );
+ deleteIfExists( tuaName );
+ }
+ }
+
+ // Exit value for a successful invocation of the typesetter. This value
+ // value is returned when creating the cache on the first run as well as
+ // creating PDFs on subsequent runs (after the cache has been created).
+ // Users don't care about exit codes, only whether the PDF was generated.
+ return exit == 0;
+ }
+
+ private Path newExtension( final String baseName, final String ext ) {
+ return mOutput.resolveSibling( removeExtension( baseName ) + ext );
+ }
+
+ /**
+ * Fires a status message for each line in the given file. The file format
+ * is somewhat machine-readable, but no effort beyond line splitting is
+ * made to parse the text.
+ *
+ * @param path Path to the file containing error messages.
+ */
+ private void log( final Path path ) throws IOException {
+ if( exists( path ) ) {
+ log( readAllLines( path ) );
+ }
+ }
+
+ private void log( final List<String> lines ) {
+ final var splits = new ArrayList<String>( lines.size() * 2 );
+
+ for( final var line : lines ) {
+ splits.addAll( asList( line.split( "\\\\n" ) ) );
+ }
+
+ clue( splits );
+ }
+
+ /**
+ * Returns the location of the cache directory.
+ *
+ * @return A fully qualified path to the location to store temporary
+ * files between typesetting runs.
+ */
+ private java.io.File getCacheDir() {
+ final var temp = getProperty( "java.io.tmpdir" );
+ final var cache = Path.of( temp, "luatex-cache" );
+ return cache.toFile();
+ }
+
+ /**
+ * Answers whether the given directory is empty. The typesetting software
+ * creates a non-empty directory by default. The return value from this
+ * method is a proxy to answering whether the typesetter has been run for
+ * the first time or not.
+ *
+ * @param path The directory to check for emptiness.
+ * @return {@code true} if the directory is empty.
+ */
+ private boolean isEmpty( final Path path ) {
+ try( final var stream = newDirectoryStream( path ) ) {
+ return !stream.iterator().hasNext();
+ } catch( final NoSuchFileException | FileNotFoundException ex ) {
+ // A missing directory means it doesn't exist, ergo is empty.
+ return true;
+ } catch( final IOException ex ) {
+ throw new RuntimeException( ex );
+ }
+ }
+ }
+
+ /**
+ * Responsible for parsing the output from the typesetting engine and
+ * updating the status bar to provide assurance that typesetting is
+ * executing.
+ *
+ * <p>
+ * Example lines written to standard output:
+ * </p>
+ * <pre>{@code
+ * pages > flushing realpage 15, userpage 15, subpage 15
+ * pages > flushing realpage 16, userpage 16, subpage 16
+ * pages > flushing realpage 1, userpage 1, subpage 1
+ * pages > flushing realpage 2, userpage 2, subpage 2
+ * }</pre>
+ * <p>
+ * The lines are parsed; the first number is displayed in a status bar
+ * message.
+ * </p>
+ */
+ private static class PaginationListener extends Thread {
+ private static final Pattern DIGITS = Pattern.compile( "[^\\d]+" );
+
+ private final InputStream mInputStream;
+
+ private final Map<String, String> mCache;
+
+ public PaginationListener(
+ final InputStream in, final Map<String, String> cache ) {
+ mInputStream = in;
+ mCache = cache;
+ }
+
+ @Override
+ public void run() {
+ try( final var reader = createReader() ) {
+ int pageCount = 1;
+ int passCount = 1;
+ int pageTotal = 0;
+ String line;
+
+ while( (line = reader.readLine()) != null ) {
+ mCache.put( line, "" );
+
+ if( line.startsWith( "pages" ) ) {
+ // The bottleneck will be the typesetting engine writing to stdout,
+ // not the parsing of stdout.
+ final var scanner = new Scanner( line ).useDelimiter( DIGITS );
+ final var digits = scanner.next();
+ final var page = Integer.parseInt( digits );
+
+ // If the page number is less than the previous page count, it
+ // means that the typesetting engine has started another pass.
+ if( page < pageCount ) {
+ passCount++;
+ pageTotal = pageCount;
+ }
+
+ pageCount = page;
+
+ // Let the user know that something is happening in the background.
+ clue( "Main.status.typeset.page",
+ pageCount, pageTotal < 1 ? "?" : pageTotal, passCount
+ );
+ }
+ }
+ } catch( final IOException ex ) {
+ throw new RuntimeException( ex );
+ }
+ }
+
+ private BufferedReader createReader() {
+ return new BufferedReader( new InputStreamReader( mInputStream ) );
+ }
+ }
+
+ private File getThemesPath() {
+ return mWorkspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
+ }
+
+ private String getThemesSelection() {
+ return mWorkspace.toString( KEY_TYPESET_CONTEXT_THEME_SELECTION );
+ }
+
+ /**
+ * Answers whether logs and other files should be deleted upon error. The
+ * log files are useful for debugging.
+ *
+ * @return {@code true} to delete generated files.
+ */
+ private boolean autoclean() {
+ return mWorkspace.toBoolean( KEY_TYPESET_CONTEXT_CLEAN );
}
}
src/main/java/com/keenwrite/ui/actions/ApplicationActions.java
import com.keenwrite.ui.explorer.FilePickerFactory;
import com.keenwrite.ui.logging.LogView;
-import com.vladsch.flexmark.ast.Link;
-import javafx.concurrent.Task;
-import javafx.scene.control.Alert;
-import javafx.scene.control.Dialog;
-import javafx.stage.Window;
-import javafx.stage.WindowEvent;
-
-import java.io.File;
-import java.nio.file.Path;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.ExecutorService;
-
-import static com.keenwrite.Bootstrap.*;
-import static com.keenwrite.ExportFormat.*;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH;
-import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION;
-import static com.keenwrite.processors.ProcessorFactory.createProcessors;
-import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
-import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
-import static java.nio.file.Files.writeString;
-import static java.util.concurrent.Executors.newFixedThreadPool;
-import static javafx.application.Platform.runLater;
-import static javafx.event.Event.fireEvent;
-import static javafx.scene.control.Alert.AlertType.INFORMATION;
-import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
-
-/**
- * Responsible for abstracting how functionality is mapped to the application.
- * This allows users to customize accelerator keys and will provide pluggable
- * functionality so that different text markup languages can change documents
- * using their respective syntax.
- */
-@SuppressWarnings( "NonAsciiCharacters" )
-public final class ApplicationActions {
- private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
-
- private static final String STYLE_SEARCH = "search";
-
- /**
- * When an action is executed, this is one of the recipients.
- */
- private final MainPane mMainPane;
-
- private final MainScene mMainScene;
-
- private final LogView mLogView;
-
- /**
- * Tracks finding text in the active document.
- */
- private final SearchModel mSearchModel;
-
- public ApplicationActions( final MainScene scene, final MainPane pane ) {
- mMainScene = scene;
- mMainPane = pane;
- mLogView = new LogView();
- mSearchModel = new SearchModel();
- mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
- final var editor = getActiveTextEditor();
-
- // Clear highlighted areas before highlighting a new region.
- if( o != null ) {
- editor.unstylize( STYLE_SEARCH );
- }
-
- if( n != null ) {
- editor.moveTo( n.getStart() );
- editor.stylize( n, STYLE_SEARCH );
- }
- } );
-
- // When the active text editor changes, update the haystack.
- mMainPane.activeTextEditorProperty().addListener(
- ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
- );
- }
-
- public void file‿new() {
- getMainPane().newTextEditor();
- }
-
- public void file‿open() {
- pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
- }
-
- public void file‿close() {
- getMainPane().close();
- }
-
- public void file‿close_all() {
- getMainPane().closeAll();
- }
-
- public void file‿save() {
- getMainPane().save();
- }
-
- public void file‿save_as() {
- pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
- }
-
- public void file‿save_all() {
- getMainPane().saveAll();
- }
-
- private void file‿export( final ExportFormat format ) {
- final var main = getMainPane();
- final var editor = main.getActiveTextEditor();
- final var filename = format.toExportFilename( editor.getPath() );
- final var selection = pickFiles( filename, FILE_EXPORT );
-
- selection.ifPresent( ( files ) -> {
- final var file = files.get( 0 );
- final var path = file.toPath();
- final var document = editor.getText();
- final var context = main.createProcessorContext( path, format );
-
- final var task = new Task<Path>() {
- @Override
- protected Path call() throws Exception {
- final var chain = createProcessors( context );
- final var export = chain.apply( document );
-
- // Processors can export binary files. In such cases, processors
- // return null to prevent further processing.
- return export == null ? null : writeString( path, export );
- }
- };
-
- task.setOnSucceeded(
- e -> {
- final var result = task.getValue();
-
- // Binary formats must notify users of success independently.
- if( result != null ) {
- clue( "Main.status.export.success", result );
- }
- }
- );
-
- task.setOnFailed( e -> {
- final var ex = task.getException();
- clue( ex );
-
- if( ex instanceof TypeNotPresentException ) {
- fireExportFailedEvent();
- }
- } );
-
- sExecutor.execute( task );
- } );
- }
-
- public void file‿export‿pdf() {
- final var workspace = getWorkspace();
- final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
- final var theme = workspace.stringProperty(
- KEY_TYPESET_CONTEXT_THEME_SELECTION );
-
- if( Typesetter.canRun() ) {
- // If the typesetter is installed, allow the user to select a theme. If
- // the themes aren't installed, a status message will appear.
- if( ThemePicker.choose( themes, theme ) ) {
- file‿export( APPLICATION_PDF );
- }
- }
- else {
- fireExportFailedEvent();
- }
- }
-
- public void file‿export‿html_svg() {
- file‿export( HTML_TEX_SVG );
- }
-
- public void file‿export‿html_tex() {
- file‿export( HTML_TEX_DELIMITED );
- }
-
- public void file‿export‿xhtml_tex() {
- file‿export( XHTML_TEX );
- }
-
- public void file‿export‿markdown() {
- file‿export( MARKDOWN_PLAIN );
- }
-
- private void fireExportFailedEvent() {
- runLater( ExportFailedEvent::fireExportFailedEvent );
- }
-
- public void file‿exit() {
- final var window = getWindow();
- fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
- }
-
- public void edit‿undo() {
- getActiveTextEditor().undo();
- }
-
- public void edit‿redo() {
- getActiveTextEditor().redo();
- }
-
- public void edit‿cut() {
- getActiveTextEditor().cut();
- }
-
- public void edit‿copy() {
- getActiveTextEditor().copy();
- }
-
- public void edit‿paste() {
- getActiveTextEditor().paste();
- }
-
- public void edit‿select_all() {
- getActiveTextEditor().selectAll();
- }
-
- public void edit‿find() {
- final var nodes = getMainScene().getStatusBar().getLeftItems();
-
- if( nodes.isEmpty() ) {
- final var searchBar = new SearchBar();
-
- searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
- searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
-
- searchBar.setOnCancelAction( ( event ) -> {
- final var editor = getActiveTextEditor();
- nodes.remove( searchBar );
- editor.unstylize( STYLE_SEARCH );
- editor.getNode().requestFocus();
- } );
-
- searchBar.addInputListener( ( c, o, n ) -> {
- if( n != null && !n.isEmpty() ) {
- mSearchModel.search( n, getActiveTextEditor().getText() );
- }
- } );
-
- searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
- searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
-
- nodes.add( searchBar );
- searchBar.requestFocus();
- }
- else {
- nodes.clear();
- }
- }
-
- public void edit‿find_next() {
- mSearchModel.advance();
- }
-
- public void edit‿find_prev() {
- mSearchModel.retreat();
- }
-
- public void edit‿preferences() {
- try {
- new PreferencesController( getWorkspace() ).show();
- } catch( final Exception ex ) {
- clue( ex );
- }
- }
-
- public void format‿bold() {
- getActiveTextEditor().bold();
- }
-
- public void format‿italic() {
- getActiveTextEditor().italic();
- }
-
- public void format‿superscript() {
- getActiveTextEditor().superscript();
- }
-
- public void format‿subscript() {
- getActiveTextEditor().subscript();
- }
-
- public void format‿strikethrough() {
- getActiveTextEditor().strikethrough();
- }
-
- public void insert‿blockquote() {
- getActiveTextEditor().blockquote();
- }
-
- public void insert‿code() {
- getActiveTextEditor().code();
- }
-
- public void insert‿fenced_code_block() {
- getActiveTextEditor().fencedCodeBlock();
- }
-
- public void insert‿link() {
- insertObject( createLinkDialog() );
- }
-
- public void insert‿image() {
- insertObject( createImageDialog() );
- }
-
- private void insertObject( final Dialog<String> dialog ) {
- final var textArea = getActiveTextEditor().getTextArea();
- dialog.showAndWait().ifPresent( textArea::replaceSelection );
- }
-
- private Dialog<String> createLinkDialog() {
- return new LinkDialog( getWindow(), createHyperlinkModel() );
- }
-
- private Dialog<String> createImageDialog() {
- final var path = getActiveTextEditor().getPath();
- final var parentDir = path.getParent();
- return new ImageDialog( getWindow(), parentDir );
- }
-
- /**
- * Returns one of: selected text, word under cursor, or parsed hyperlink from
- * the Markdown AST.
- *
- * @return An instance containing the link URL and display text.
- */
- private HyperlinkModel createHyperlinkModel() {
- final var context = getMainPane().createProcessorContext();
- final var editor = getActiveTextEditor();
- final var textArea = editor.getTextArea();
- final var selectedText = textArea.getSelectedText();
-
- // Convert current paragraph to Markdown nodes.
- final var mp = MarkdownProcessor.create( context );
- final var p = textArea.getCurrentParagraph();
- final var paragraph = textArea.getText( p );
- final var node = mp.toNode( paragraph );
- final var visitor = new LinkVisitor( textArea.getCaretColumn() );
- final var link = visitor.process( node );
-
- if( link != null ) {
- textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
- }
-
- return createHyperlinkModel( link, selectedText );
- }
-
- private HyperlinkModel createHyperlinkModel(
- final Link link, final String selection ) {
-
- return link == null
- ? new HyperlinkModel( selection, "https://localhost" )
- : new HyperlinkModel( link );
- }
-
- public void insert‿heading_1() {
- insert‿heading( 1 );
- }
-
- public void insert‿heading_2() {
- insert‿heading( 2 );
- }
-
- public void insert‿heading_3() {
- insert‿heading( 3 );
- }
-
- private void insert‿heading( final int level ) {
- getActiveTextEditor().heading( level );
- }
-
- public void insert‿unordered_list() {
- getActiveTextEditor().unorderedList();
- }
-
- public void insert‿ordered_list() {
- getActiveTextEditor().orderedList();
- }
-
- public void insert‿horizontal_rule() {
- getActiveTextEditor().horizontalRule();
- }
-
- public void definition‿create() {
- getActiveTextDefinition().createDefinition();
- }
-
- public void definition‿rename() {
- getActiveTextDefinition().renameDefinition();
- }
-
- public void definition‿delete() {
- getActiveTextDefinition().deleteDefinitions();
- }
-
- public void definition‿autoinsert() {
- getMainPane().autoinsert();
- }
-
- public void view‿refresh() {
- getMainPane().viewRefresh();
- }
-
- public void view‿preview() {
- getMainPane().viewPreview();
- }
-
- public void view‿outline() {
- getMainPane().viewOutline();
- }
-
- public void view‿files() { getMainPane().viewFiles(); }
-
- public void view‿statistics() {
- getMainPane().viewStatistics();
- }
-
- public void view‿menubar() {
- getMainScene().toggleMenuBar();
- }
-
- public void view‿toolbar() {
- getMainScene().toggleToolBar();
- }
-
- public void view‿statusbar() {
- getMainScene().toggleStatusBar();
- }
-
- public void view‿log() {
- mLogView.view();
- }
-
- public void help‿about() {
- final var alert = new Alert( INFORMATION );
- final var prefix = "Dialog.about.";
- alert.setTitle( get( prefix + "title", APP_TITLE ) );
- alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
- alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
- alert.setGraphic( ICON_DIALOG_NODE );
- alert.initOwner( getWindow() );
- alert.showAndWait();
+import com.keenwrite.util.AlphanumComparator;
+import com.keenwrite.util.FileWalker;
+import com.vladsch.flexmark.ast.Link;
+import javafx.concurrent.Task;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Dialog;
+import javafx.stage.Window;
+import javafx.stage.WindowEvent;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+
+import static com.keenwrite.Bootstrap.*;
+import static com.keenwrite.ExportFormat.*;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEMES_PATH;
+import static com.keenwrite.preferences.WorkspaceKeys.KEY_TYPESET_CONTEXT_THEME_SELECTION;
+import static com.keenwrite.processors.ProcessorFactory.createProcessors;
+import static com.keenwrite.ui.explorer.FilePickerFactory.Options;
+import static com.keenwrite.ui.explorer.FilePickerFactory.Options.*;
+import static java.nio.file.Files.*;
+import static java.nio.file.Files.writeString;
+import static java.util.concurrent.Executors.newFixedThreadPool;
+import static javafx.application.Platform.runLater;
+import static javafx.event.Event.fireEvent;
+import static javafx.scene.control.Alert.AlertType.INFORMATION;
+import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
+import static org.apache.commons.io.FilenameUtils.getExtension;
+
+/**
+ * Responsible for abstracting how functionality is mapped to the application.
+ * This allows users to customize accelerator keys and will provide pluggable
+ * functionality so that different text markup languages can change documents
+ * using their respective syntax.
+ */
+@SuppressWarnings( "NonAsciiCharacters" )
+public final class ApplicationActions {
+ private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
+
+ private static final String STYLE_SEARCH = "search";
+
+ // Sci-fi genres typically fall below 150,000 words at 6 chars per word.
+ private static final int AVERAGE_NOVEL_LENGTH = 150_000 * 6;
+
+ /**
+ * When an action is executed, this is one of the recipients.
+ */
+ private final MainPane mMainPane;
+
+ private final MainScene mMainScene;
+
+ private final LogView mLogView;
+
+ /**
+ * Tracks finding text in the active document.
+ */
+ private final SearchModel mSearchModel;
+
+ public ApplicationActions( final MainScene scene, final MainPane pane ) {
+ mMainScene = scene;
+ mMainPane = pane;
+ mLogView = new LogView();
+ mSearchModel = new SearchModel();
+ mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
+ final var editor = getActiveTextEditor();
+
+ // Clear highlighted areas before highlighting a new region.
+ if( o != null ) {
+ editor.unstylize( STYLE_SEARCH );
+ }
+
+ if( n != null ) {
+ editor.moveTo( n.getStart() );
+ editor.stylize( n, STYLE_SEARCH );
+ }
+ } );
+
+ // When the active text editor changes, update the haystack.
+ mMainPane.activeTextEditorProperty().addListener(
+ ( c, o, n ) -> mSearchModel.search( getActiveTextEditor().getText() )
+ );
+ }
+
+ public void file‿new() {
+ getMainPane().newTextEditor();
+ }
+
+ public void file‿open() {
+ pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
+ }
+
+ public void file‿close() {
+ getMainPane().close();
+ }
+
+ public void file‿close_all() {
+ getMainPane().closeAll();
+ }
+
+ public void file‿save() {
+ getMainPane().save();
+ }
+
+ public void file‿save_as() {
+ pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
+ }
+
+ public void file‿save_all() {
+ getMainPane().saveAll();
+ }
+
+ /**
+ * Converts the actively edited file in the given file format.
+ *
+ * @param format The destination file format.
+ */
+ private void file‿export( final ExportFormat format ) {
+ file‿export( format, false );
+ }
+
+ /**
+ * Converts one or more files into the given file format. If {@code dir}
+ * is set to true, this will first append all files in the same directory
+ * as the actively edited file.
+ *
+ * @param format The destination file format.
+ * @param dir Export all files in the actively edited file's directory.
+ */
+ private void file‿export( final ExportFormat format, final boolean dir ) {
+ final var main = getMainPane();
+ final var editor = main.getActiveTextEditor();
+ final var filename = format.toExportFilename( editor.getPath() );
+ final var selection = pickFiles( filename, FILE_EXPORT );
+
+ selection.ifPresent( ( files ) -> {
+ final var file = files.get( 0 );
+ final var path = file.toPath();
+ final var document = dir ? append( editor ) : editor.getText();
+ final var context = main.createProcessorContext( path, format );
+
+ final var task = new Task<Path>() {
+ @Override
+ protected Path call() throws Exception {
+ final var chain = createProcessors( context );
+ final var export = chain.apply( document );
+
+ // Processors can export binary files. In such cases, processors
+ // return null to prevent further processing.
+ return export == null ? null : writeString( path, export );
+ }
+ };
+
+ task.setOnSucceeded(
+ e -> {
+ final var result = task.getValue();
+
+ // Binary formats must notify users of success independently.
+ if( result != null ) {
+ clue( "Main.status.export.success", result );
+ }
+ }
+ );
+
+ task.setOnFailed( e -> {
+ final var ex = task.getException();
+ clue( ex );
+
+ if( ex instanceof TypeNotPresentException ) {
+ fireExportFailedEvent();
+ }
+ } );
+
+ sExecutor.execute( task );
+ } );
+ }
+
+ /**
+ * @param dir {@code true} means to export all files in the active file
+ * editor's directory; {@code false} means to export only the
+ * actively edited file.
+ */
+ private void file‿export‿pdf( final boolean dir ) {
+ final var workspace = getWorkspace();
+ final var themes = workspace.toFile( KEY_TYPESET_CONTEXT_THEMES_PATH );
+ final var theme = workspace.stringProperty(
+ KEY_TYPESET_CONTEXT_THEME_SELECTION );
+
+ if( Typesetter.canRun() ) {
+ // If the typesetter is installed, allow the user to select a theme. If
+ // the themes aren't installed, a status message will appear.
+ if( ThemePicker.choose( themes, theme ) ) {
+ file‿export( APPLICATION_PDF, dir );
+ }
+ }
+ else {
+ fireExportFailedEvent();
+ }
+ }
+
+ public void file‿export‿pdf() {
+ file‿export‿pdf( false );
+ }
+
+ public void file‿export‿pdf‿dir() {
+ file‿export‿pdf( true );
+ }
+
+ public void file‿export‿html_svg() {
+ file‿export( HTML_TEX_SVG );
+ }
+
+ public void file‿export‿html_tex() {
+ file‿export( HTML_TEX_DELIMITED );
+ }
+
+ public void file‿export‿xhtml_tex() {
+ file‿export( XHTML_TEX );
+ }
+
+ public void file‿export‿markdown() {
+ file‿export( MARKDOWN_PLAIN );
+ }
+
+ private void fireExportFailedEvent() {
+ runLater( ExportFailedEvent::fireExportFailedEvent );
+ }
+
+ public void file‿exit() {
+ final var window = getWindow();
+ fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
+ }
+
+ public void edit‿undo() {
+ getActiveTextEditor().undo();
+ }
+
+ public void edit‿redo() {
+ getActiveTextEditor().redo();
+ }
+
+ public void edit‿cut() {
+ getActiveTextEditor().cut();
+ }
+
+ public void edit‿copy() {
+ getActiveTextEditor().copy();
+ }
+
+ public void edit‿paste() {
+ getActiveTextEditor().paste();
+ }
+
+ public void edit‿select_all() {
+ getActiveTextEditor().selectAll();
+ }
+
+ public void edit‿find() {
+ final var nodes = getMainScene().getStatusBar().getLeftItems();
+
+ if( nodes.isEmpty() ) {
+ final var searchBar = new SearchBar();
+
+ searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
+ searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
+
+ searchBar.setOnCancelAction( ( event ) -> {
+ final var editor = getActiveTextEditor();
+ nodes.remove( searchBar );
+ editor.unstylize( STYLE_SEARCH );
+ editor.getNode().requestFocus();
+ } );
+
+ searchBar.addInputListener( ( c, o, n ) -> {
+ if( n != null && !n.isEmpty() ) {
+ mSearchModel.search( n, getActiveTextEditor().getText() );
+ }
+ } );
+
+ searchBar.setOnNextAction( ( event ) -> edit‿find_next() );
+ searchBar.setOnPrevAction( ( event ) -> edit‿find_prev() );
+
+ nodes.add( searchBar );
+ searchBar.requestFocus();
+ }
+ else {
+ nodes.clear();
+ }
+ }
+
+ public void edit‿find_next() {
+ mSearchModel.advance();
+ }
+
+ public void edit‿find_prev() {
+ mSearchModel.retreat();
+ }
+
+ public void edit‿preferences() {
+ try {
+ new PreferencesController( getWorkspace() ).show();
+ } catch( final Exception ex ) {
+ clue( ex );
+ }
+ }
+
+ public void format‿bold() {
+ getActiveTextEditor().bold();
+ }
+
+ public void format‿italic() {
+ getActiveTextEditor().italic();
+ }
+
+ public void format‿superscript() {
+ getActiveTextEditor().superscript();
+ }
+
+ public void format‿subscript() {
+ getActiveTextEditor().subscript();
+ }
+
+ public void format‿strikethrough() {
+ getActiveTextEditor().strikethrough();
+ }
+
+ public void insert‿blockquote() {
+ getActiveTextEditor().blockquote();
+ }
+
+ public void insert‿code() {
+ getActiveTextEditor().code();
+ }
+
+ public void insert‿fenced_code_block() {
+ getActiveTextEditor().fencedCodeBlock();
+ }
+
+ public void insert‿link() {
+ insertObject( createLinkDialog() );
+ }
+
+ public void insert‿image() {
+ insertObject( createImageDialog() );
+ }
+
+ private void insertObject( final Dialog<String> dialog ) {
+ final var textArea = getActiveTextEditor().getTextArea();
+ dialog.showAndWait().ifPresent( textArea::replaceSelection );
+ }
+
+ private Dialog<String> createLinkDialog() {
+ return new LinkDialog( getWindow(), createHyperlinkModel() );
+ }
+
+ private Dialog<String> createImageDialog() {
+ final var path = getActiveTextEditor().getPath();
+ final var parentDir = path.getParent();
+ return new ImageDialog( getWindow(), parentDir );
+ }
+
+ /**
+ * Returns one of: selected text, word under cursor, or parsed hyperlink from
+ * the Markdown AST.
+ *
+ * @return An instance containing the link URL and display text.
+ */
+ private HyperlinkModel createHyperlinkModel() {
+ final var context = getMainPane().createProcessorContext();
+ final var editor = getActiveTextEditor();
+ final var textArea = editor.getTextArea();
+ final var selectedText = textArea.getSelectedText();
+
+ // Convert current paragraph to Markdown nodes.
+ final var mp = MarkdownProcessor.create( context );
+ final var p = textArea.getCurrentParagraph();
+ final var paragraph = textArea.getText( p );
+ final var node = mp.toNode( paragraph );
+ final var visitor = new LinkVisitor( textArea.getCaretColumn() );
+ final var link = visitor.process( node );
+
+ if( link != null ) {
+ textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
+ }
+
+ return createHyperlinkModel( link, selectedText );
+ }
+
+ private HyperlinkModel createHyperlinkModel(
+ final Link link, final String selection ) {
+
+ return link == null
+ ? new HyperlinkModel( selection, "https://localhost" )
+ : new HyperlinkModel( link );
+ }
+
+ public void insert‿heading_1() {
+ insert‿heading( 1 );
+ }
+
+ public void insert‿heading_2() {
+ insert‿heading( 2 );
+ }
+
+ public void insert‿heading_3() {
+ insert‿heading( 3 );
+ }
+
+ private void insert‿heading( final int level ) {
+ getActiveTextEditor().heading( level );
+ }
+
+ public void insert‿unordered_list() {
+ getActiveTextEditor().unorderedList();
+ }
+
+ public void insert‿ordered_list() {
+ getActiveTextEditor().orderedList();
+ }
+
+ public void insert‿horizontal_rule() {
+ getActiveTextEditor().horizontalRule();
+ }
+
+ public void definition‿create() {
+ getActiveTextDefinition().createDefinition();
+ }
+
+ public void definition‿rename() {
+ getActiveTextDefinition().renameDefinition();
+ }
+
+ public void definition‿delete() {
+ getActiveTextDefinition().deleteDefinitions();
+ }
+
+ public void definition‿autoinsert() {
+ getMainPane().autoinsert();
+ }
+
+ public void view‿refresh() {
+ getMainPane().viewRefresh();
+ }
+
+ public void view‿preview() {
+ getMainPane().viewPreview();
+ }
+
+ public void view‿outline() {
+ getMainPane().viewOutline();
+ }
+
+ public void view‿files() { getMainPane().viewFiles(); }
+
+ public void view‿statistics() {
+ getMainPane().viewStatistics();
+ }
+
+ public void view‿menubar() {
+ getMainScene().toggleMenuBar();
+ }
+
+ public void view‿toolbar() {
+ getMainScene().toggleToolBar();
+ }
+
+ public void view‿statusbar() {
+ getMainScene().toggleStatusBar();
+ }
+
+ public void view‿log() {
+ mLogView.view();
+ }
+
+ public void help‿about() {
+ final var alert = new Alert( INFORMATION );
+ final var prefix = "Dialog.about.";
+ alert.setTitle( get( prefix + "title", APP_TITLE ) );
+ alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
+ alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
+ alert.setGraphic( ICON_DIALOG_NODE );
+ alert.initOwner( getWindow() );
+ alert.showAndWait();
+ }
+
+ /**
+ * Concatenates all the files in the same directory as the given file into
+ * a string. The extension is determined by the given file name pattern; the
+ * order files are concatenated is based on their numeric sort order (this
+ * avoids lexicographic sorting).
+ * <p>
+ * If the parent path to the file being edited in the text editor cannot
+ * be found then this will return the editor's text, without iterating through
+ * the parent directory. (Should never happen, but who knows?)
+ * </p>
+ * <p>
+ * New lines are automatically appended to separate each file.
+ * </p>
+ *
+ * @param editor The text editor containing
+ * @return All files in the same directory as the file being edited
+ * concatenated into a single string.
+ */
+ private String append( final TextEditor editor ) {
+ final var pattern = editor.getPath();
+ final var parent = pattern.getParent();
+
+ // Short-circuit because nothing else can be done.
+ if( parent == null ) {
+ clue( "Main.status.export.concat.parent", pattern );
+ return editor.getText();
+ }
+
+ final var filename = pattern.getFileName().toString();
+ final var extension = getExtension( filename );
+
+ if( extension == null || extension.isBlank() ) {
+ clue( "Main.status.export.concat.extension", filename );
+ return editor.getText();
+ }
+
+ try {
+ final var glob = "**." + extension;
+ final ArrayList<Path> files = new ArrayList<>();
+ FileWalker.walk( parent, glob, files::add );
+ files.sort( new AlphanumComparator<>() );
+
+ final var text = new StringBuilder( AVERAGE_NOVEL_LENGTH );
+
+ files.forEach( ( file ) -> {
+ try {
+ clue( "Main.status.export.concat", file );
+ text.append( readString( file ) );
+ } catch( final IOException ex ) {
+ clue( "Main.status.export.concat.io", file );
+ }
+ } );
+
+ return text.toString();
+ } catch( final Throwable t ) {
+ clue( t );
+ return editor.getText();
+ }
}
src/main/java/com/keenwrite/ui/actions/ApplicationBars.java
.addSubActions(
addAction( "file.export.pdf", e -> actions.file‿export‿pdf() ),
+ addAction( "file.export.pdf.dir", e -> actions.file‿export‿pdf‿dir() ),
addAction( "file.export.html_svg", e -> actions.file‿export‿html_svg() ),
addAction( "file.export.html_tex", e -> actions.file‿export‿html_tex() ),
src/main/java/com/keenwrite/util/AlphanumComparator.java
+/*
+ * The Alphanum Algorithm is an improved sorting algorithm for strings
+ * containing numbers. Rather than sort numbers in ASCII order like
+ * a standard sort, this algorithm sorts numbers in numeric order.
+ *
+ * The Alphanum Algorithm is discussed at http://www.DaveKoelle.com
+ *
+ * Released under the MIT License - https://opensource.org/licenses/MIT
+ *
+ * Copyright 2007-2017 David Koelle
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included
+ * in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.keenwrite.util;
+
+import java.util.Comparator;
+
+import static java.lang.Character.isDigit;
+
+/**
+ * Responsible for sorting lists that may contain numeric values. Usage:
+ * <pre>
+ * Collections.sort(list, new AlphanumComparator());
+ * </pre>
+ * <p>
+ * Where "list" is the list to sort alphanumerically, not lexicographically.
+ * </p>
+ */
+public final class AlphanumComparator<T> implements Comparator<T> {
+ /**
+ * Returns a chunk of text that is continuous with respect to digits or
+ * non-digits.
+ *
+ * @param s The string to compare.
+ * @param length The string length, for improved efficiency.
+ * @param marker The current index into a subset of the given string.
+ * @return The substring {@code s} that is a continuous text chunk of the
+ * same character type.
+ */
+ private StringBuilder chunk( final String s, final int length, int marker ) {
+ assert s != null;
+ assert length >= 0;
+ assert marker < length;
+
+ // Prevent any possible memory re-allocations by using the length.
+ final var chunk = new StringBuilder( length );
+ var c = s.charAt( marker );
+ final var chunkType = isDigit( c );
+
+ // While the character at the current position is the same type (numeric or
+ // alphabetic), append the character to the current chunk.
+ while( marker < length &&
+ isDigit( c = s.charAt( marker++ ) ) == chunkType ) {
+ chunk.append( c );
+ }
+
+ return chunk;
+ }
+
+ /**
+ * Performs an alphanumeric comparison of two strings, sorting numerically
+ * first when numbers are found within the string. If either argument is
+ * {@code null}, this will return zero.
+ *
+ * @param o1 The object to compare against {@code s2}, converted to string.
+ * @param o2 The object to compare against {@code s1}, converted to string.
+ * @return a negative integer, zero, or a positive integer if the first
+ * argument is less than, equal to, or greater than the second, respectively.
+ */
+ @Override
+ public int compare( final T o1, final T o2 ) {
+ if( o1 == null || o2 == null ) {
+ return 0;
+ }
+
+ final var s1 = o1.toString();
+ final var s2 = o2.toString();
+ final var s1Length = s1.length();
+ final var s2Length = s2.length();
+
+ var thisMarker = 0;
+ var thatMarker = 0;
+
+ while( thisMarker < s1Length && thatMarker < s2Length ) {
+ final var thisChunk = chunk( s1, s1Length, thisMarker );
+ final var thisChunkLength = thisChunk.length();
+ thisMarker += thisChunkLength;
+ final var thatChunk = chunk( s2, s2Length, thatMarker );
+ final var thatChunkLength = thatChunk.length();
+ thatMarker += thatChunkLength;
+
+ // If both chunks contain numeric characters, sort them numerically
+ int result;
+
+ if( isDigit( thisChunk.charAt( 0 ) ) &&
+ isDigit( thatChunk.charAt( 0 ) ) ) {
+ // If equal, the first different number counts
+ if( (result = thisChunkLength - thatChunkLength) == 0 ) {
+ for( var i = 0; i < thisChunkLength; i++ ) {
+ if( (result = thisChunk.charAt( i ) - thatChunk.charAt( i )) != 0 ) {
+ return result;
+ }
+ }
+ }
+ }
+ else {
+ result = thisChunk.compareTo( thatChunk );
+ }
+
+ if( result != 0 ) {
+ return result;
+ }
+ }
+
+ return s1Length - s2Length;
+ }
+}
src/main/resources/com/keenwrite/messages.properties
workspace.document.date.title=Timestamp
-workspace.r=R
-workspace.r.script=Startup Script
-workspace.r.script.desc=Script runs prior to executing R statements within the document.
-workspace.r.dir=Working Directory
-workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script.
-workspace.r.dir.title=Directory
-workspace.r.delimiter.began=Delimiter Prefix
-workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables.
-workspace.r.delimiter.began.title=Opening
-workspace.r.delimiter.ended=Delimiter Suffix
-workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables.
-workspace.r.delimiter.ended.title=Closing
-
-workspace.images=Images
-workspace.images.dir=Absolute Directory
-workspace.images.dir.desc=Path to search for local file system images.
-workspace.images.dir.title=Directory
-workspace.images.order=Extensions
-workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
-workspace.images.order.title=Extensions
-workspace.images.resize=Resize
-workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically.
-workspace.images.resize.title=Resize
-
-workspace.definition=Variable
-workspace.definition.path=File name
-workspace.definition.path.desc=Absolute path to interpolated string variables.
-workspace.definition.path.title=Path
-workspace.definition.delimiter.began=Delimiter Prefix
-workspace.definition.delimiter.began.desc=Indicates when a variable name is starting.
-workspace.definition.delimiter.began.title=Opening
-workspace.definition.delimiter.ended=Delimiter Suffix
-workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending.
-workspace.definition.delimiter.ended.title=Closing
-
-workspace.ui.skin=Skins
-workspace.ui.skin.selection=Bundled
-workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light).
-workspace.ui.skin.selection.title=Name
-workspace.ui.skin.custom=Custom
-workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
-workspace.ui.skin.custom.title=Path
-
-workspace.ui.font=Fonts
-workspace.ui.font.editor=Editor Font
-workspace.ui.font.editor.name=Name
-workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
-workspace.ui.font.editor.name.title=Family
-workspace.ui.font.editor.size=Size
-workspace.ui.font.editor.size.desc=Font size.
-workspace.ui.font.editor.size.title=Points
-workspace.ui.font.preview=Preview Font
-workspace.ui.font.preview.name=Name
-workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
-workspace.ui.font.preview.name.title=Family
-workspace.ui.font.preview.size=Size
-workspace.ui.font.preview.size.desc=Font size.
-workspace.ui.font.preview.size.title=Points
-workspace.ui.font.preview.mono.name=Name
-workspace.ui.font.preview.mono.name.desc=Monospace font name.
-workspace.ui.font.preview.mono.name.title=Family
-workspace.ui.font.preview.mono.size=Size
-workspace.ui.font.preview.mono.size.desc=Monospace font size.
-workspace.ui.font.preview.mono.size.title=Points
-
-workspace.language=Language
-workspace.language.locale=Internationalization
-workspace.language.locale.desc=Language for application and HTML export.
-workspace.language.locale.title=Locale
-
-workspace.typeset=Typesetting
-workspace.typeset.context=ConTeXt
-workspace.typeset.context.themes.path=Paths
-workspace.typeset.context.themes.path.desc=Directory containing theme subdirectories.
-workspace.typeset.context.themes.path.title=Themes
-
-# ########################################################################
-# Menu Bar
-# ########################################################################
-
-Main.menu.file=_File
-Main.menu.edit=_Edit
-Main.menu.insert=_Insert
-Main.menu.format=Forma_t
-Main.menu.definition=_Variable
-Main.menu.view=Vie_w
-Main.menu.help=_Help
-
-# ########################################################################
-# Detachable Tabs
-# ########################################################################
-
-# {0} is the application title; {1} is a unique window ID.
-Detach.tab.title={0} - {1}
-
-# ########################################################################
-# Status Bar
-# ########################################################################
-
-Main.status.text.offset=offset
-Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
-Main.status.state.default=OK
-Main.status.export.success=Saved as ''{0}''
-
-Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
-
-Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
-Main.status.error.def.blank=Move the caret to a word before inserting a variable
-Main.status.error.def.empty=Create a variable before inserting one
-Main.status.error.def.missing=No variable value found for ''{0}''
-Main.status.error.r=Error with [{0}...]: {1}
-Main.status.error.file.missing=Not found: ''{0}''
-Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
-
-Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
-Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
-
-Main.status.error.undo=Cannot undo; beginning of undo history reached
-Main.status.error.redo=Cannot redo; end of redo history reached
-
-Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'')
-Main.status.error.theme.name=Cannot find theme name for ''{0}''
-
-Main.status.image.request.init=Initializing HTTP request
-Main.status.image.request.fetch=Requesting content type from ''{0}''
-Main.status.image.request.success=Determined content type ''{0}''
-Main.status.image.request.error.media=No media type for ''{0}''
-Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
-
-Main.status.font.search.missing=No font name starting with ''{0}'' was found
-
-Main.status.typeset.create=Creating typesetter
-Main.status.typeset.xhtml=Export document as XHTML
-Main.status.typeset.began=Started typesetting ''{0}''
-Main.status.typeset.failed=Could not generate PDF file
-Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
-Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
-Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
-
-# ########################################################################
-# Search Bar
-# ########################################################################
-
-Main.search.stop.tooltip=Close search bar
-Main.search.stop.icon=CLOSE
-Main.search.next.tooltip=Find next match
-Main.search.next.icon=CHEVRON_DOWN
-Main.search.prev.tooltip=Find previous match
-Main.search.prev.icon=CHEVRON_UP
-Main.search.find.tooltip=Search document for text
-Main.search.find.icon=SEARCH
-Main.search.match.none=No matches
-Main.search.match.some={0} of {1} matches
-
-# ########################################################################
-# Definition Pane and its Tree View
-# ########################################################################
-
-Definition.menu.add.default=Undefined
-
-# ########################################################################
-# Variable Definitions Pane
-# ########################################################################
-
-Pane.definition.node.root.title=Variables
-
-# ########################################################################
-# HTML Preview Pane
-# ########################################################################
-
-Pane.preview.title=Preview
-
-# ########################################################################
-# Document Outline Pane
-# ########################################################################
-
-Pane.outline.title=Outline
-
-# ########################################################################
-# File Manager Pane
-# ########################################################################
-
-Pane.files.title=Files
-
-# ########################################################################
-# Document Outline Pane
-# ########################################################################
-
-Pane.statistics.title=Statistics
-
-# ########################################################################
-# Failure messages with respect to YAML files.
-# ########################################################################
-
-yaml.error.open=Could not open YAML file (ensure non-empty file).
-yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
-yaml.error.missing=Empty variable value for key ''{0}''.
-yaml.error.tree.form=Unassigned variable near ''{0}''.
-
-# ########################################################################
-# Text Resource
-# ########################################################################
-
-TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
-TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
-
-# ########################################################################
-# Text Resources
-# ########################################################################
-
-TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
-TextResource.saveFailed.title=Save
-
-# ########################################################################
-# File Open
-# ########################################################################
-
-Dialog.file.choose.open.title=Open File
-Dialog.file.choose.save.title=Save File
-Dialog.file.choose.export.title=Export File
-
-Dialog.file.choose.filter.title.source=Source Files
-Dialog.file.choose.filter.title.definition=Variable Files
-Dialog.file.choose.filter.title.xml=XML Files
-Dialog.file.choose.filter.title.all=All Files
-
-# ########################################################################
-# Browse File
-# ########################################################################
-
-BrowseFileButton.chooser.title=Open local file
-BrowseFileButton.chooser.allFilesFilter=All Files
-BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
-
-# ########################################################################
-# Browse Directory
-# ########################################################################
-
-BrowseDirectoryButton.chooser.title=Open local directory
-BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
-
-# ########################################################################
-# Alert Dialog
-# ########################################################################
-
-Alert.file.close.title=Close
-Alert.file.close.text=Save changes to {0}?
-
-# ########################################################################
-# Typesetting Alert Dialog
-# ########################################################################
-
-Alert.typesetter.missing.title=Missing Typesetter
-Alert.typesetter.missing.header=Install typesetter
-Alert.typesetter.missing.version=for {0} {1} {2}-bit
-Alert.typesetter.missing.installer.text=Download and install ConTeXt
-Alert.typesetter.missing.installer.url=https://wiki.contextgarden.net/Installation
-
-# ########################################################################
-# Image Dialog
-# ########################################################################
-
-Dialog.image.title=Image
-Dialog.image.chooser.imagesFilter=Images
-Dialog.image.previewLabel.text=Markdown Preview\:
-Dialog.image.textLabel.text=Alternate Text\:
-Dialog.image.titleLabel.text=Title (tooltip)\:
-Dialog.image.urlLabel.text=Image URL\:
-
-# ########################################################################
-# Hyperlink Dialog
-# ########################################################################
-
-Dialog.link.title=Link
-Dialog.link.previewLabel.text=Markdown Preview\:
-Dialog.link.textLabel.text=Link Text\:
-Dialog.link.titleLabel.text=Title (tooltip)\:
-Dialog.link.urlLabel.text=Link URL\:
-
-# ########################################################################
-# Themes Dialog
-# ########################################################################
-
-Dialog.theme.title=Typesetting theme
-Dialog.theme.header=Choose a typesetting theme
-
-# ########################################################################
-# About Dialog
-# ########################################################################
-
-Dialog.about.title=About {0}
-Dialog.about.header={0}
-Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
-
-# ########################################################################
-# Application Actions
-# ########################################################################
-
-Action.file.new.description=Create a new file
-Action.file.new.accelerator=Shortcut+N
-Action.file.new.icon=FILE_ALT
-Action.file.new.text=_New
-
-Action.file.open.description=Open a new file
-Action.file.open.accelerator=Shortcut+O
-Action.file.open.text=_Open...
-Action.file.open.icon=FOLDER_OPEN_ALT
-
-Action.file.close.description=Close the current document
-Action.file.close.accelerator=Shortcut+W
-Action.file.close.text=_Close
-
-Action.file.close_all.description=Close all open documents
-Action.file.close_all.accelerator=Ctrl+F4
-Action.file.close_all.text=Close All
-
-Action.file.save.description=Save the document
-Action.file.save.accelerator=Shortcut+S
-Action.file.save.text=_Save
-Action.file.save.icon=FLOPPY_ALT
-
-Action.file.save_as.description=Rename the current document
-Action.file.save_as.text=Save _As
-
-Action.file.save_all.description=Save all open documents
-Action.file.save_all.accelerator=Shortcut+Shift+S
-Action.file.save_all.text=Save A_ll
-
-Action.file.export.pdf.description=Typeset the document
-Action.file.export.pdf.accelerator=Shortcut+P
-Action.file.export.pdf.text=_PDF
-Action.file.export.pdf.icon=FILE_PDF_ALT
+workspace.typeset=Typesetting
+workspace.typeset.context=ConTeXt
+workspace.typeset.context.themes.path=Paths
+workspace.typeset.context.themes.path.desc=Directory containing theme subdirectories.
+workspace.typeset.context.themes.path.title=Themes
+workspace.typeset.context.clean=Clean
+workspace.typeset.context.clean.desc=Delete ancillary files after an unsuccessful export.
+workspace.typeset.context.clean.title=Purge
+
+workspace.r=R
+workspace.r.script=Startup Script
+workspace.r.script.desc=Script runs prior to executing R statements within the document.
+workspace.r.dir=Working Directory
+workspace.r.dir.desc=Value assigned to {0}application.r.working.directory{1} and usable in the startup script.
+workspace.r.dir.title=Directory
+workspace.r.delimiter.began=Delimiter Prefix
+workspace.r.delimiter.began.desc=Prefix of expression that wraps inserted variables.
+workspace.r.delimiter.began.title=Opening
+workspace.r.delimiter.ended=Delimiter Suffix
+workspace.r.delimiter.ended.desc=Suffix of expression that wraps inserted variables.
+workspace.r.delimiter.ended.title=Closing
+
+workspace.images=Images
+workspace.images.dir=Absolute Directory
+workspace.images.dir.desc=Path to search for local file system images.
+workspace.images.dir.title=Directory
+workspace.images.order=Extensions
+workspace.images.order.desc=Preferred order of image file types to embed, separated by spaces.
+workspace.images.order.title=Extensions
+workspace.images.resize=Resize
+workspace.images.resize.desc=Scale images to fit the preview panel when resizing, automatically.
+workspace.images.resize.title=Resize
+
+workspace.definition=Variable
+workspace.definition.path=File name
+workspace.definition.path.desc=Absolute path to interpolated string variables.
+workspace.definition.path.title=Path
+workspace.definition.delimiter.began=Delimiter Prefix
+workspace.definition.delimiter.began.desc=Indicates when a variable name is starting.
+workspace.definition.delimiter.began.title=Opening
+workspace.definition.delimiter.ended=Delimiter Suffix
+workspace.definition.delimiter.ended.desc=Indicates when a variable name is ending.
+workspace.definition.delimiter.ended.title=Closing
+
+workspace.ui.skin=Skins
+workspace.ui.skin.selection=Bundled
+workspace.ui.skin.selection.desc=Pre-packaged application style (default: Modena Light).
+workspace.ui.skin.selection.title=Name
+workspace.ui.skin.custom=Custom
+workspace.ui.skin.custom.desc=User-defined JavaFX cascading stylesheet file.
+workspace.ui.skin.custom.title=Path
+
+workspace.ui.font=Fonts
+workspace.ui.font.editor=Editor Font
+workspace.ui.font.editor.name=Name
+workspace.ui.font.editor.name.desc=Text editor font name (sans-serif font recommended).
+workspace.ui.font.editor.name.title=Family
+workspace.ui.font.editor.size=Size
+workspace.ui.font.editor.size.desc=Font size.
+workspace.ui.font.editor.size.title=Points
+workspace.ui.font.preview=Preview Font
+workspace.ui.font.preview.name=Name
+workspace.ui.font.preview.name.desc=Preview pane font name (must support ligatures, serif font recommended).
+workspace.ui.font.preview.name.title=Family
+workspace.ui.font.preview.size=Size
+workspace.ui.font.preview.size.desc=Font size.
+workspace.ui.font.preview.size.title=Points
+workspace.ui.font.preview.mono.name=Name
+workspace.ui.font.preview.mono.name.desc=Monospace font name.
+workspace.ui.font.preview.mono.name.title=Family
+workspace.ui.font.preview.mono.size=Size
+workspace.ui.font.preview.mono.size.desc=Monospace font size.
+workspace.ui.font.preview.mono.size.title=Points
+
+workspace.language=Language
+workspace.language.locale=Internationalization
+workspace.language.locale.desc=Language for application and HTML export.
+workspace.language.locale.title=Locale
+
+# ########################################################################
+# Menu Bar
+# ########################################################################
+
+Main.menu.file=_File
+Main.menu.edit=_Edit
+Main.menu.insert=_Insert
+Main.menu.format=Forma_t
+Main.menu.definition=_Variable
+Main.menu.view=Vie_w
+Main.menu.help=_Help
+
+# ########################################################################
+# Detachable Tabs
+# ########################################################################
+
+# {0} is the application title; {1} is a unique window ID.
+Detach.tab.title={0} - {1}
+
+# ########################################################################
+# Status Bar
+# ########################################################################
+
+Main.status.text.offset=offset
+Main.status.line=Line {0} of {1}, ${Main.status.text.offset} {2}
+Main.status.state.default=OK
+Main.status.export.success=Saved as ''{0}''
+
+Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
+
+Main.status.error.parse={0} (near ${Main.status.text.offset} {1})
+Main.status.error.def.blank=Move the caret to a word before inserting a variable
+Main.status.error.def.empty=Create a variable before inserting one
+Main.status.error.def.missing=No variable value found for ''{0}''
+Main.status.error.r=Error with [{0}...]: {1}
+Main.status.error.file.missing=Not found: ''{0}''
+Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
+
+Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
+Main.status.error.messages.syntax=Missing ''}'' in ''{0}''
+
+Main.status.error.undo=Cannot undo; beginning of undo history reached
+Main.status.error.redo=Cannot redo; end of redo history reached
+
+Main.status.error.theme.missing=Install themes before exporting (no themes found at ''{0}'')
+Main.status.error.theme.name=Cannot find theme name for ''{0}''
+
+Main.status.image.request.init=Initializing HTTP request
+Main.status.image.request.fetch=Requesting content type from ''{0}''
+Main.status.image.request.success=Determined content type ''{0}''
+Main.status.image.request.error.media=No media type for ''{0}''
+Main.status.image.request.error.cert=Could not accept certificate for ''{0}''
+
+Main.status.font.search.missing=No font name starting with ''{0}'' was found
+
+Main.status.export.concat=Concatenating ''{0}''
+Main.status.export.concat.parent=No parent directory found for ''{0}''
+Main.status.export.concat.extension=File name must have an extension ''{0}''
+Main.status.export.concat.io=Could not read from ''{0}''
+
+Main.status.typeset.create=Creating typesetter
+Main.status.typeset.xhtml=Export document as XHTML
+Main.status.typeset.began=Started typesetting ''{0}''
+Main.status.typeset.failed=Could not generate PDF file
+Main.status.typeset.page=Typesetting page {0} of {1} (pass {2})
+Main.status.typeset.ended.success=Finished typesetting ''{0}'' ({1} elapsed)
+Main.status.typeset.ended.failure=Failed to typeset ''{0}'' ({1} elapsed)
+
+# ########################################################################
+# Search Bar
+# ########################################################################
+
+Main.search.stop.tooltip=Close search bar
+Main.search.stop.icon=CLOSE
+Main.search.next.tooltip=Find next match
+Main.search.next.icon=CHEVRON_DOWN
+Main.search.prev.tooltip=Find previous match
+Main.search.prev.icon=CHEVRON_UP
+Main.search.find.tooltip=Search document for text
+Main.search.find.icon=SEARCH
+Main.search.match.none=No matches
+Main.search.match.some={0} of {1} matches
+
+# ########################################################################
+# Definition Pane and its Tree View
+# ########################################################################
+
+Definition.menu.add.default=Undefined
+
+# ########################################################################
+# Variable Definitions Pane
+# ########################################################################
+
+Pane.definition.node.root.title=Variables
+
+# ########################################################################
+# HTML Preview Pane
+# ########################################################################
+
+Pane.preview.title=Preview
+
+# ########################################################################
+# Document Outline Pane
+# ########################################################################
+
+Pane.outline.title=Outline
+
+# ########################################################################
+# File Manager Pane
+# ########################################################################
+
+Pane.files.title=Files
+
+# ########################################################################
+# Document Outline Pane
+# ########################################################################
+
+Pane.statistics.title=Statistics
+
+# ########################################################################
+# Failure messages with respect to YAML files.
+# ########################################################################
+
+yaml.error.open=Could not open YAML file (ensure non-empty file).
+yaml.error.unresolvable=Too much indirection for: ''{0}'' = ''{1}''.
+yaml.error.missing=Empty variable value for key ''{0}''.
+yaml.error.tree.form=Unassigned variable near ''{0}''.
+
+# ########################################################################
+# Text Resource
+# ########################################################################
+
+TextResource.load.error.unsaved=The file ''{0}'' is unsaved or does not exist.
+TextResource.load.error.permissions=The file ''{0}'' must be readable and writable.
+
+# ########################################################################
+# Text Resources
+# ########################################################################
+
+TextResource.saveFailed.message=Failed to save ''{0}''.\n\nReason: {1}
+TextResource.saveFailed.title=Save
+
+# ########################################################################
+# File Open
+# ########################################################################
+
+Dialog.file.choose.open.title=Open File
+Dialog.file.choose.save.title=Save File
+Dialog.file.choose.export.title=Export File
+
+Dialog.file.choose.filter.title.source=Source Files
+Dialog.file.choose.filter.title.definition=Variable Files
+Dialog.file.choose.filter.title.xml=XML Files
+Dialog.file.choose.filter.title.all=All Files
+
+# ########################################################################
+# Browse File
+# ########################################################################
+
+BrowseFileButton.chooser.title=Open local file
+BrowseFileButton.chooser.allFilesFilter=All Files
+BrowseFileButton.tooltip=${BrowseFileButton.chooser.title}
+
+# ########################################################################
+# Browse Directory
+# ########################################################################
+
+BrowseDirectoryButton.chooser.title=Open local directory
+BrowseDirectoryButton.tooltip=${BrowseDirectoryButton.chooser.title}
+
+# ########################################################################
+# Alert Dialog
+# ########################################################################
+
+Alert.file.close.title=Close
+Alert.file.close.text=Save changes to {0}?
+
+# ########################################################################
+# Typesetting Alert Dialog
+# ########################################################################
+
+Alert.typesetter.missing.title=Missing Typesetter
+Alert.typesetter.missing.header=Install typesetter
+Alert.typesetter.missing.version=for {0} {1} {2}-bit
+Alert.typesetter.missing.installer.text=Download and install ConTeXt
+Alert.typesetter.missing.installer.url=https://wiki.contextgarden.net/Installation
+
+# ########################################################################
+# Image Dialog
+# ########################################################################
+
+Dialog.image.title=Image
+Dialog.image.chooser.imagesFilter=Images
+Dialog.image.previewLabel.text=Markdown Preview\:
+Dialog.image.textLabel.text=Alternate Text\:
+Dialog.image.titleLabel.text=Title (tooltip)\:
+Dialog.image.urlLabel.text=Image URL\:
+
+# ########################################################################
+# Hyperlink Dialog
+# ########################################################################
+
+Dialog.link.title=Link
+Dialog.link.previewLabel.text=Markdown Preview\:
+Dialog.link.textLabel.text=Link Text\:
+Dialog.link.titleLabel.text=Title (tooltip)\:
+Dialog.link.urlLabel.text=Link URL\:
+
+# ########################################################################
+# Themes Dialog
+# ########################################################################
+
+Dialog.theme.title=Typesetting theme
+Dialog.theme.header=Choose a typesetting theme
+
+# ########################################################################
+# About Dialog
+# ########################################################################
+
+Dialog.about.title=About {0}
+Dialog.about.header={0}
+Dialog.about.content=Copyright 2016-{0} White Magic Software, Ltd.\n\nVersion: {1}
+
+# ########################################################################
+# Application Actions
+# ########################################################################
+
+Action.file.new.description=Create a new file
+Action.file.new.accelerator=Shortcut+N
+Action.file.new.icon=FILE_ALT
+Action.file.new.text=_New
+
+Action.file.open.description=Open a new file
+Action.file.open.accelerator=Shortcut+O
+Action.file.open.text=_Open...
+Action.file.open.icon=FOLDER_OPEN_ALT
+
+Action.file.close.description=Close the current document
+Action.file.close.accelerator=Shortcut+W
+Action.file.close.text=_Close
+
+Action.file.close_all.description=Close all open documents
+Action.file.close_all.accelerator=Ctrl+F4
+Action.file.close_all.text=Close All
+
+Action.file.save.description=Save the document
+Action.file.save.accelerator=Shortcut+S
+Action.file.save.text=_Save
+Action.file.save.icon=FLOPPY_ALT
+
+Action.file.save_as.description=Rename the current document
+Action.file.save_as.text=Save _As
+
+Action.file.save_all.description=Save all open documents
+Action.file.save_all.accelerator=Shortcut+Shift+S
+Action.file.save_all.text=Save A_ll
+
+Action.file.export.pdf.description=Typeset the document
+Action.file.export.pdf.accelerator=Shortcut+P
+Action.file.export.pdf.text=_PDF
+Action.file.export.pdf.icon=FILE_PDF_ALT
+
+Action.file.export.pdf.dir.description=Typeset files in document directory
+Action.file.export.pdf.dir.accelerator=Shortcut+Shift+P
+Action.file.export.pdf.dir.text=_Joined PDF
+Action.file.export.pdf.dir.icon=FILE_PDF_ALT
Action.file.export.html_svg.description=Export the current document as HTML + SVG
src/test/java/com/keenwrite/util/AlphanumComparatorTest.java
+/* Copyright 2020-2021 White Magic Software, Ltd. -- All rights reserved. */
+package com.keenwrite.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Responsible for testing the http://www.davekoelle.com/alphanum.html
+ * implementation.
+ */
+class AlphanumComparatorTest {
+
+ /**
+ * Test that a randomly sorted list containing a mix of alphanumeric
+ * characters ("chunks") will be sorted according to numeric and alphabetic
+ * order.
+ */
+ @Test
+ public void test_Sort_UnsortedList_SortedAlphanumerically() {
+ final var expected = Arrays.asList(
+ "10X Radonius",
+ "20X Radonius",
+ "20X Radonius Prime",
+ "30X Radonius",
+ "40X Radonius",
+ "200X Radonius",
+ "1000X Radonius Maximus",
+ "Allegia 6R Clasteron",
+ "Allegia 50 Clasteron",
+ "Allegia 50B Clasteron",
+ "Allegia 51 Clasteron",
+ "Allegia 500 Clasteron",
+ "Alpha 2",
+ "Alpha 2A",
+ "Alpha 2A-900",
+ "Alpha 2A-8000",
+ "Alpha 100",
+ "Alpha 200",
+ "Callisto Morphamax",
+ "Callisto Morphamax 500",
+ "Callisto Morphamax 600",
+ "Callisto Morphamax 700",
+ "Callisto Morphamax 5000",
+ "Callisto Morphamax 6000 SE",
+ "Callisto Morphamax 6000 SE2",
+ "Callisto Morphamax 7000",
+ "Xiph Xlater 5",
+ "Xiph Xlater 40",
+ "Xiph Xlater 50",
+ "Xiph Xlater 58",
+ "Xiph Xlater 300",
+ "Xiph Xlater 500",
+ "Xiph Xlater 2000",
+ "Xiph Xlater 5000",
+ "Xiph Xlater 10000"
+ );
+ final var actual = new ArrayList<>( expected );
+
+ Collections.shuffle( actual );
+ actual.sort( new AlphanumComparator<>() );
+ assertEquals( expected, actual );
+ }
+}
Delta1481 lines added, 1148 lines removed, 333-line increase