Dave Jarvis' Repositories

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

Specifies chapter ranges via command-line

AuthorDaveJarvis <email>
Date2023-03-28 16:32:54 GMT-0700
Commite912a3281606d5ab8102d8086b8d72b7b50e4f1c
Parenta742aa2
src/main/java/com/keenwrite/AppCommands.java
import com.keenwrite.cmdline.Arguments;
-import com.keenwrite.processors.RBootstrapProcessor;
+import com.keenwrite.commands.ConcatenateCommand;
import com.keenwrite.processors.Processor;
import com.keenwrite.processors.ProcessorContext;
-import com.keenwrite.util.AlphanumComparator;
+import com.keenwrite.processors.RBootstrapProcessor;
import java.io.IOException;
import java.nio.file.Path;
-import java.util.ArrayList;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
import static com.keenwrite.Launcher.terminate;
import static com.keenwrite.events.StatusEvent.clue;
import static com.keenwrite.io.MediaType.TEXT_R_MARKDOWN;
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
-import static com.keenwrite.util.FileWalker.walk;
-import static java.lang.System.lineSeparator;
import static java.nio.file.Files.readString;
import static java.nio.file.Files.writeString;
public class AppCommands {
private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
-
- /**
- * Sci-fi genres, which are can be longer than other genres, typically fall
- * below 150,000 words at 6 chars per word. This reduces re-allocations of
- * memory when concatenating files together when exporting novels.
- */
- private static final int DOCUMENT_LENGTH = 150_000 * 6;
private AppCommands() {
try {
final var context = args.createProcessorContext();
- final var concat = context.getConcatenate();
- final var inputPath = context.getSourcePath();
final var outputPath = context.getTargetPath();
final var chain = createProcessors( context );
final var processor = createBootstrapProcessor( chain, context );
- final var inputDoc = read( inputPath, concat );
+ final var inputDoc = read( context );
final var outputDoc = processor.apply( inputDoc );
* </p>
*
- * @param inputPath The path to the source file to read.
- * @param concat {@code true} to concatenate all files with the same
- * extension as the source path.
+ * @param context The {@link ProcessorContext} containing input path,
+ * and other command-line parameters.
* @return All files in the same directory as the file being edited
* concatenated into a single string.
*/
- private static String read( final Path inputPath, final boolean concat )
+ private static String read( final ProcessorContext context )
throws IOException {
+ final var concat = context.getConcatenate();
+ final var inputPath = context.getSourcePath();
final var parent = inputPath.getParent();
final var filename = inputPath.getFileName().toString();
final var extension = getExtension( filename );
// Short-circuit because: only one file was requested; there is no parent
// directory to scan for files; or there's no extension for globbing.
if( !concat || parent == null || extension.isBlank() ) {
return readString( inputPath );
- }
-
- final var glob = "**/*." + extension;
- final var files = new ArrayList<Path>();
- walk( parent, glob, files::add );
- files.sort( new AlphanumComparator<>() );
-
- final var text = new StringBuilder( DOCUMENT_LENGTH );
- final var eol = lineSeparator();
-
- for( final var file : files ) {
- text.append( readString( file ) );
- text.append( eol );
}
- return text.toString();
+ final var command = new ConcatenateCommand(
+ parent, extension, context.getChapters() );
+ return command.call();
}
}
src/main/java/com/keenwrite/cmdline/Arguments.java
@CommandLine.Option(
+ names = {"-c", "--chapters"},
+ description =
+ "Range of chapters to export (e.g., 1-5,7-11,15-)",
+ paramLabel = "String"
+ )
+ private String mChapters;
+
+ @CommandLine.Option(
names = {"--curl-quotes"},
description =
.with( Mutator::setMetadata, () -> mMetadata )
.with( Mutator::setLocale, () -> locale )
- .with( Mutator::setConcatenate, mConcatenate )
+ .with( Mutator::setConcatenate, () -> mConcatenate )
+ .with( Mutator::setChapters, () -> mChapters )
.with( Mutator::setSigilBegan, () -> mSigilBegan )
.with( Mutator::setSigilEnded, () -> mSigilEnded )
src/main/java/com/keenwrite/commands/ConcatenateCommand.java
+/* Copyright 2023 White Magic Software, Ltd. -- All rights reserved.
+ *
+ * SPDX-License-Identifier: MIT
+ */
+package com.keenwrite.commands;
+
+import com.keenwrite.util.AlphanumComparator;
+import com.keenwrite.util.RangeValidator;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.util.FileWalker.walk;
+import static java.lang.System.lineSeparator;
+import static java.nio.file.Files.readString;
+
+/**
+ * Responsible for concatenating files according to user-defined chapter ranges.
+ */
+public class ConcatenateCommand implements Callable<String> {
+ /**
+ * Sci-fi genres, which are can be longer than other genres, typically fall
+ * below 150,000 words at 6 chars per word. This reduces re-allocations of
+ * memory when concatenating files together when exporting novels.
+ */
+ private static final int DOCUMENT_LENGTH = 150_000 * 6;
+
+ private final Path mParent;
+ private final String mExtension;
+ private final String mRange;
+
+ public ConcatenateCommand(
+ final Path parent,
+ final String extension,
+ final String range ) {
+ assert parent != null;
+ assert extension != null;
+ assert range != null;
+
+ mParent = parent;
+ mExtension = extension;
+ mRange = range;
+ }
+
+ public String call() throws IOException {
+ final var glob = "**/*." + mExtension;
+ final var files = new ArrayList<Path>();
+ final var text = new StringBuilder( DOCUMENT_LENGTH );
+ final var chapter = new AtomicInteger();
+ final var eol = lineSeparator();
+
+ final var validator = new RangeValidator( mRange );
+
+ walk( mParent, glob, files::add );
+ files.sort( new AlphanumComparator<>() );
+ files.forEach( file -> {
+ try {
+ clue( "Main.status.export.concat", file );
+
+ if( validator.test( chapter.incrementAndGet() ) ) {
+ text.append( readString( file ) )
+ .append( eol );
+ }
+ } catch( final IOException ex ) {
+ clue( "Main.status.export.concat.io", file );
+ }
+ } );
+
+ return text.toString();
+ }
+}
src/main/java/com/keenwrite/processors/ProcessorContext.java
private Path mTargetPath;
private ExportFormat mExportFormat;
- private boolean mConcatenate;
-
- private Supplier<Path> mThemesPath = USER_DIRECTORY::toPath;
- private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
-
- private Supplier<Map<String, String>> mDefinitions = HashMap::new;
- private Supplier<Map<String, String>> mMetadata = HashMap::new;
- private Supplier<Caret> mCaret = () -> Caret.builder().build();
-
- private Supplier<Path> mFontsPath = () -> getFontDirectory().toPath();
-
- private Supplier<Path> mImagesPath = USER_DIRECTORY::toPath;
- private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
- private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
-
- private Supplier<Path> mCachesPath = USER_CACHE_DIR::toPath;
-
- private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
- private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
-
- private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath;
- private Supplier<String> mRScript = () -> "";
-
- private Supplier<Boolean> mCurlQuotes = () -> true;
- private Supplier<Boolean> mAutoRemove = () -> true;
-
- public void setSourcePath( final Path sourcePath ) {
- assert sourcePath != null;
- mSourcePath = sourcePath;
- }
-
- public void setTargetPath( final Path outputPath ) {
- assert outputPath != null;
- mTargetPath = outputPath;
- }
-
- public void setTargetPath( final File targetPath ) {
- assert targetPath != null;
- setTargetPath( targetPath.toPath() );
- }
-
- public void setThemesPath( final Supplier<Path> themesPath ) {
- assert themesPath != null;
- mThemesPath = themesPath;
- }
-
- public void setCachesPath( final Supplier<File> cachesDir ) {
- assert cachesDir != null;
-
- mCachesPath = () -> {
- final var dir = cachesDir.get();
-
- return (dir == null ? USER_DATA_DIR.toFile() : dir).toPath();
- };
- }
-
- public void setImagesPath( final Supplier<File> imagesDir ) {
- assert imagesDir != null;
-
- mImagesPath = () -> {
- final var dir = imagesDir.get();
-
- return (dir == null ? USER_DIRECTORY : dir).toPath();
- };
- }
-
- public void setImageOrder( final Supplier<String> imageOrder ) {
- assert imageOrder != null;
- mImageOrder = imageOrder;
- }
-
- public void setImageServer( final Supplier<String> imageServer ) {
- assert imageServer != null;
- mImageServer = imageServer;
- }
-
- public void setFontsPath( final Supplier<File> fontsPath ) {
- assert fontsPath != null;
- mFontsPath = () -> {
- final var dir = fontsPath.get();
-
- return (dir == null ? USER_DIRECTORY : dir).toPath();
- };
- }
-
- public void setExportFormat( final ExportFormat exportFormat ) {
- assert exportFormat != null;
- mExportFormat = exportFormat;
- }
-
- public void setConcatenate( final boolean concatenate ) {
- mConcatenate = concatenate;
- }
-
- public void setLocale( final Supplier<Locale> locale ) {
- assert locale != null;
- mLocale = locale;
- }
-
- /**
- * Sets the list of fully interpolated key-value pairs to use when
- * substituting variable names back into the document as variable values.
- * This uses a {@link Callable} reference so that GUI and command-line
- * usage can insert their respective behaviours. That is, this method
- * prevents coupling the GUI to the CLI.
- *
- * @param supplier Defines how to retrieve the definitions.
- */
- public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
- assert supplier != null;
- mDefinitions = supplier;
- }
-
- public void setMetadata( final Supplier<Map<String, String>> metadata ) {
- assert metadata != null;
- mMetadata = metadata.get() == null ? HashMap::new : metadata;
- }
-
- /**
- * Sets the source for deriving the {@link Caret}. Typically, this is
- * the text editor that has focus.
- *
- * @param caret The source for the currently active caret.
- */
- public void setCaret( final Supplier<Caret> caret ) {
- assert caret != null;
- mCaret = caret;
- }
-
- public void setSigilBegan( final Supplier<String> sigilBegan ) {
- assert sigilBegan != null;
- mSigilBegan = sigilBegan;
- }
-
- public void setSigilEnded( final Supplier<String> sigilEnded ) {
- assert sigilEnded != null;
- mSigilEnded = sigilEnded;
- }
-
- public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
- assert rWorkingDir != null;
-
- mRWorkingDir = rWorkingDir;
- }
-
- public void setRScript( final Supplier<String> rScript ) {
- assert rScript != null;
- mRScript = rScript;
- }
-
- public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
- assert curlQuotes != null;
- mCurlQuotes = curlQuotes;
- }
-
- public void setAutoRemove( final Supplier<Boolean> autoRemove ) {
- assert autoRemove != null;
- mAutoRemove = autoRemove;
- }
-
- private boolean isExportFormat( final ExportFormat format ) {
- return mExportFormat == format;
- }
- }
-
- public static GenericBuilder<Mutator, ProcessorContext> builder() {
- return GenericBuilder.of( Mutator::new, ProcessorContext::new );
- }
-
- /**
- * Creates a new context for use by the {@link ProcessorFactory} when
- * instantiating new {@link Processor} instances. Although all the
- * parameters are required, not all {@link Processor} instances will use
- * all parameters.
- */
- private ProcessorContext( final Mutator mutator ) {
- assert mutator != null;
-
- mMutator = mutator;
- }
-
- public Path getSourcePath() {
- return mMutator.mSourcePath;
- }
-
- /**
- * Answers what type of input document is to be processed.
- *
- * @return The input document's {@link MediaType}.
- */
- public MediaType getSourceType() {
- return MediaTypeExtension.fromPath( mMutator.mSourcePath );
- }
-
- /**
- * Fully qualified file name to use when exporting (e.g., document.pdf).
- *
- * @return Full path to a file name.
- */
- public Path getTargetPath() {
- return mMutator.mTargetPath;
- }
-
- public ExportFormat getExportFormat() {
- return mMutator.mExportFormat;
- }
-
- public Locale getLocale() {
- return mMutator.mLocale.get();
- }
-
- /**
- * Returns the variable map of definitions, without interpolation.
- *
- * @return A map to help dereference variables.
- */
- public Map<String, String> getDefinitions() {
- return mMutator.mDefinitions.get();
- }
-
- /**
- * Returns the variable map of definitions, with interpolation.
- *
- * @return A map to help dereference variables.
- */
- public InterpolatingMap getInterpolatedDefinitions() {
- return new InterpolatingMap(
- createDefinitionKeyOperator(), getDefinitions()
- ).interpolate();
- }
-
- public Map<String, String> getMetadata() {
- return mMutator.mMetadata.get();
- }
-
- /**
- * Returns the current caret position in the document being edited and is
- * always up-to-date.
- *
- * @return Caret position in the document.
- */
- public Supplier<Caret> getCaret() {
- return mMutator.mCaret;
- }
-
- /**
- * Returns the directory that contains the file being edited. When
- * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is
- * {@code null}. This will get absolute path to the file before trying to
- * get te parent path, which should always be a valid path. In the unlikely
- * event that the base path cannot be determined by the path alone, the
- * default user directory is returned. This is necessary for the creation
- * of new files.
- *
- * @return Path to the directory containing a file being edited, or the
- * default user directory if the base path cannot be determined.
- */
- public Path getBaseDir() {
- final var path = getSourcePath().toAbsolutePath().getParent();
- return path == null ? DEFAULT_DIRECTORY : path;
- }
-
- FileType getSourceFileType() {
- return lookup( getSourcePath() );
- }
-
- public Path getThemesPath() {
- return mMutator.mThemesPath.get();
- }
-
- public Path getImagesPath() {
- return mMutator.mImagesPath.get();
- }
-
- public Path getCachesPath() {
- return mMutator.mCachesPath.get();
- }
-
- public Iterable<String> getImageOrder() {
- assert mMutator.mImageOrder != null;
-
- final var order = mMutator.mImageOrder.get();
- final var token = order.contains( "," ) ? ',' : ' ';
-
- return Splitter.on( token ).split( token + order );
- }
-
- public String getImageServer() {
- return mMutator.mImageServer.get();
- }
-
- public Path getFontsPath() {
- return mMutator.mFontsPath.get();
- }
-
- public boolean getAutoRemove() {
- return mMutator.mAutoRemove.get();
- }
-
- public Path getRWorkingDir() {
- return mMutator.mRWorkingDir.get();
- }
-
- public String getRScript() {
- return mMutator.mRScript.get();
- }
-
- public boolean getCurlQuotes() {
- return mMutator.mCurlQuotes.get();
- }
-
- /**
- * Answers whether to process a single text file or all text files in
- * the same directory as a single text file. See {@link #getSourcePath()}
- * for the file to process (or all files in its directory).
- *
- * @return {@code true} means to process all text files, {@code false}
- * means to process a single file.
- */
- public boolean getConcatenate() {
- return mMutator.mConcatenate;
+ private Supplier<Boolean> mConcatenate = () -> true;
+ private Supplier<String> mChapters = () -> "";
+
+ private Supplier<Path> mThemesPath = USER_DIRECTORY::toPath;
+ private Supplier<Locale> mLocale = () -> Locale.ENGLISH;
+
+ private Supplier<Map<String, String>> mDefinitions = HashMap::new;
+ private Supplier<Map<String, String>> mMetadata = HashMap::new;
+ private Supplier<Caret> mCaret = () -> Caret.builder().build();
+
+ private Supplier<Path> mFontsPath = () -> getFontDirectory().toPath();
+
+ private Supplier<Path> mImagesPath = USER_DIRECTORY::toPath;
+ private Supplier<String> mImageServer = () -> DIAGRAM_SERVER_NAME;
+ private Supplier<String> mImageOrder = () -> PERSIST_IMAGES_DEFAULT;
+
+ private Supplier<Path> mCachesPath = USER_CACHE_DIR::toPath;
+
+ private Supplier<String> mSigilBegan = () -> DEF_DELIM_BEGAN_DEFAULT;
+ private Supplier<String> mSigilEnded = () -> DEF_DELIM_ENDED_DEFAULT;
+
+ private Supplier<Path> mRWorkingDir = USER_DIRECTORY::toPath;
+ private Supplier<String> mRScript = () -> "";
+
+ private Supplier<Boolean> mCurlQuotes = () -> true;
+ private Supplier<Boolean> mAutoRemove = () -> true;
+
+ public void setSourcePath( final Path sourcePath ) {
+ assert sourcePath != null;
+ mSourcePath = sourcePath;
+ }
+
+ public void setTargetPath( final Path outputPath ) {
+ assert outputPath != null;
+ mTargetPath = outputPath;
+ }
+
+ public void setTargetPath( final File targetPath ) {
+ assert targetPath != null;
+ setTargetPath( targetPath.toPath() );
+ }
+
+ public void setThemesPath( final Supplier<Path> themesPath ) {
+ assert themesPath != null;
+ mThemesPath = themesPath;
+ }
+
+ public void setCachesPath( final Supplier<File> cachesDir ) {
+ assert cachesDir != null;
+
+ mCachesPath = () -> {
+ final var dir = cachesDir.get();
+
+ return (dir == null ? USER_DATA_DIR.toFile() : dir).toPath();
+ };
+ }
+
+ public void setImagesPath( final Supplier<File> imagesDir ) {
+ assert imagesDir != null;
+
+ mImagesPath = () -> {
+ final var dir = imagesDir.get();
+
+ return (dir == null ? USER_DIRECTORY : dir).toPath();
+ };
+ }
+
+ public void setImageOrder( final Supplier<String> imageOrder ) {
+ assert imageOrder != null;
+ mImageOrder = imageOrder;
+ }
+
+ public void setImageServer( final Supplier<String> imageServer ) {
+ assert imageServer != null;
+ mImageServer = imageServer;
+ }
+
+ public void setFontsPath( final Supplier<File> fontsPath ) {
+ assert fontsPath != null;
+ mFontsPath = () -> {
+ final var dir = fontsPath.get();
+
+ return (dir == null ? USER_DIRECTORY : dir).toPath();
+ };
+ }
+
+ public void setExportFormat( final ExportFormat exportFormat ) {
+ assert exportFormat != null;
+ mExportFormat = exportFormat;
+ }
+
+ public void setConcatenate( final Supplier<Boolean> concatenate ) {
+ mConcatenate = concatenate;
+ }
+
+ public void setChapters( final Supplier<String> chapters ) {
+ mChapters = chapters;
+ }
+
+ public void setLocale( final Supplier<Locale> locale ) {
+ assert locale != null;
+ mLocale = locale;
+ }
+
+ /**
+ * Sets the list of fully interpolated key-value pairs to use when
+ * substituting variable names back into the document as variable values.
+ * This uses a {@link Callable} reference so that GUI and command-line
+ * usage can insert their respective behaviours. That is, this method
+ * prevents coupling the GUI to the CLI.
+ *
+ * @param supplier Defines how to retrieve the definitions.
+ */
+ public void setDefinitions( final Supplier<Map<String, String>> supplier ) {
+ assert supplier != null;
+ mDefinitions = supplier;
+ }
+
+ public void setMetadata( final Supplier<Map<String, String>> metadata ) {
+ assert metadata != null;
+ mMetadata = metadata.get() == null ? HashMap::new : metadata;
+ }
+
+ /**
+ * Sets the source for deriving the {@link Caret}. Typically, this is
+ * the text editor that has focus.
+ *
+ * @param caret The source for the currently active caret.
+ */
+ public void setCaret( final Supplier<Caret> caret ) {
+ assert caret != null;
+ mCaret = caret;
+ }
+
+ public void setSigilBegan( final Supplier<String> sigilBegan ) {
+ assert sigilBegan != null;
+ mSigilBegan = sigilBegan;
+ }
+
+ public void setSigilEnded( final Supplier<String> sigilEnded ) {
+ assert sigilEnded != null;
+ mSigilEnded = sigilEnded;
+ }
+
+ public void setRWorkingDir( final Supplier<Path> rWorkingDir ) {
+ assert rWorkingDir != null;
+
+ mRWorkingDir = rWorkingDir;
+ }
+
+ public void setRScript( final Supplier<String> rScript ) {
+ assert rScript != null;
+ mRScript = rScript;
+ }
+
+ public void setCurlQuotes( final Supplier<Boolean> curlQuotes ) {
+ assert curlQuotes != null;
+ mCurlQuotes = curlQuotes;
+ }
+
+ public void setAutoRemove( final Supplier<Boolean> autoRemove ) {
+ assert autoRemove != null;
+ mAutoRemove = autoRemove;
+ }
+
+ private boolean isExportFormat( final ExportFormat format ) {
+ return mExportFormat == format;
+ }
+ }
+
+ public static GenericBuilder<Mutator, ProcessorContext> builder() {
+ return GenericBuilder.of( Mutator::new, ProcessorContext::new );
+ }
+
+ /**
+ * Creates a new context for use by the {@link ProcessorFactory} when
+ * instantiating new {@link Processor} instances. Although all the
+ * parameters are required, not all {@link Processor} instances will use
+ * all parameters.
+ */
+ private ProcessorContext( final Mutator mutator ) {
+ assert mutator != null;
+
+ mMutator = mutator;
+ }
+
+ public Path getSourcePath() {
+ return mMutator.mSourcePath;
+ }
+
+ /**
+ * Answers what type of input document is to be processed.
+ *
+ * @return The input document's {@link MediaType}.
+ */
+ public MediaType getSourceType() {
+ return MediaTypeExtension.fromPath( mMutator.mSourcePath );
+ }
+
+ /**
+ * Fully qualified file name to use when exporting (e.g., document.pdf).
+ *
+ * @return Full path to a file name.
+ */
+ public Path getTargetPath() {
+ return mMutator.mTargetPath;
+ }
+
+ public ExportFormat getExportFormat() {
+ return mMutator.mExportFormat;
+ }
+
+ public Locale getLocale() {
+ return mMutator.mLocale.get();
+ }
+
+ /**
+ * Returns the variable map of definitions, without interpolation.
+ *
+ * @return A map to help dereference variables.
+ */
+ public Map<String, String> getDefinitions() {
+ return mMutator.mDefinitions.get();
+ }
+
+ /**
+ * Returns the variable map of definitions, with interpolation.
+ *
+ * @return A map to help dereference variables.
+ */
+ public InterpolatingMap getInterpolatedDefinitions() {
+ return new InterpolatingMap(
+ createDefinitionKeyOperator(), getDefinitions()
+ ).interpolate();
+ }
+
+ public Map<String, String> getMetadata() {
+ return mMutator.mMetadata.get();
+ }
+
+ /**
+ * Returns the current caret position in the document being edited and is
+ * always up-to-date.
+ *
+ * @return Caret position in the document.
+ */
+ public Supplier<Caret> getCaret() {
+ return mMutator.mCaret;
+ }
+
+ /**
+ * Returns the directory that contains the file being edited. When
+ * {@link Constants#DOCUMENT_DEFAULT} is created, the parent path is
+ * {@code null}. This will get absolute path to the file before trying to
+ * get te parent path, which should always be a valid path. In the unlikely
+ * event that the base path cannot be determined by the path alone, the
+ * default user directory is returned. This is necessary for the creation
+ * of new files.
+ *
+ * @return Path to the directory containing a file being edited, or the
+ * default user directory if the base path cannot be determined.
+ */
+ public Path getBaseDir() {
+ final var path = getSourcePath().toAbsolutePath().getParent();
+ return path == null ? DEFAULT_DIRECTORY : path;
+ }
+
+ FileType getSourceFileType() {
+ return lookup( getSourcePath() );
+ }
+
+ public Path getThemesPath() {
+ return mMutator.mThemesPath.get();
+ }
+
+ public Path getImagesPath() {
+ return mMutator.mImagesPath.get();
+ }
+
+ public Path getCachesPath() {
+ return mMutator.mCachesPath.get();
+ }
+
+ public Iterable<String> getImageOrder() {
+ assert mMutator.mImageOrder != null;
+
+ final var order = mMutator.mImageOrder.get();
+ final var token = order.contains( "," ) ? ',' : ' ';
+
+ return Splitter.on( token ).split( token + order );
+ }
+
+ public String getImageServer() {
+ return mMutator.mImageServer.get();
+ }
+
+ public Path getFontsPath() {
+ return mMutator.mFontsPath.get();
+ }
+
+ public boolean getAutoRemove() {
+ return mMutator.mAutoRemove.get();
+ }
+
+ public Path getRWorkingDir() {
+ return mMutator.mRWorkingDir.get();
+ }
+
+ public String getRScript() {
+ return mMutator.mRScript.get();
+ }
+
+ public boolean getCurlQuotes() {
+ return mMutator.mCurlQuotes.get();
+ }
+
+ /**
+ * Answers whether to process a single text file or all text files in
+ * the same directory as a single text file. See {@link #getSourcePath()}
+ * for the file to process (or all files in its directory).
+ *
+ * @return {@code true} means to process all text files, {@code false}
+ * means to process a single file.
+ */
+ public boolean getConcatenate() {
+ return mMutator.mConcatenate.get();
+ }
+
+ public String getChapters() {
+ return mMutator.mChapters.get();
}
src/main/java/com/keenwrite/ui/actions/GuiCommands.java
import com.keenwrite.MainPane;
import com.keenwrite.MainScene;
-import com.keenwrite.editors.TextDefinition;
-import com.keenwrite.editors.TextEditor;
-import com.keenwrite.editors.markdown.HyperlinkModel;
-import com.keenwrite.editors.markdown.LinkVisitor;
-import com.keenwrite.events.CaretMovedEvent;
-import com.keenwrite.events.ExportFailedEvent;
-import com.keenwrite.preferences.Key;
-import com.keenwrite.preferences.PreferencesController;
-import com.keenwrite.preferences.Workspace;
-import com.keenwrite.processors.markdown.MarkdownProcessor;
-import com.keenwrite.search.SearchModel;
-import com.keenwrite.typesetting.Typesetter;
-import com.keenwrite.ui.controls.SearchBar;
-import com.keenwrite.ui.dialogs.ExportDialog;
-import com.keenwrite.ui.dialogs.ExportSettings;
-import com.keenwrite.ui.dialogs.ImageDialog;
-import com.keenwrite.ui.dialogs.LinkDialog;
-import com.keenwrite.ui.explorer.FilePicker;
-import com.keenwrite.ui.explorer.FilePickerFactory;
-import com.keenwrite.ui.logging.LogView;
-import com.keenwrite.util.AlphanumComparator;
-import com.keenwrite.util.RangeValidator;
-import com.vladsch.flexmark.ast.Link;
-import javafx.concurrent.Service;
-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.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.atomic.AtomicInteger;
-
-import static com.keenwrite.Bootstrap.*;
-import static com.keenwrite.ExportFormat.*;
-import static com.keenwrite.Messages.get;
-import static com.keenwrite.constants.Constants.PDF_DEFAULT;
-import static com.keenwrite.constants.Constants.USER_DIRECTORY;
-import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
-import static com.keenwrite.events.StatusEvent.clue;
-import static com.keenwrite.preferences.AppKeys.*;
-import static com.keenwrite.processors.ProcessorFactory.createProcessors;
-import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
-import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
-import static com.keenwrite.util.FileWalker.walk;
-import static java.lang.System.lineSeparator;
-import static java.nio.file.Files.readString;
-import static java.nio.file.Files.writeString;
-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.
- */
-public final class GuiCommands {
- private static final String STYLE_SEARCH = "search";
-
- /**
- * Sci-fi genres, which are can be longer than other genres, typically fall
- * below 150,000 words at 6 chars per word. This reduces re-allocations of
- * memory when concatenating files together when exporting novels.
- */
- private static final int DOCUMENT_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;
-
- private boolean mCanTypeset;
-
- /**
- * A {@link Task} can only be run once, so wrap it in a {@link Service} to
- * allow re-launching the typesetting task repeatedly.
- */
- private Service<Path> mTypesetService;
-
- /**
- * Prevent a race-condition between checking to see if the typesetting task
- * is running and restarting the task itself.
- */
- private final Object mMutex = new Object();
-
- public GuiCommands( 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 ...
- mMainPane.textEditorProperty().addListener(
- ( c, o, n ) -> {
- // ... update the haystack.
- mSearchModel.search( getActiveTextEditor().getText() );
-
- // ... update the status bar with the current caret position.
- if( n != null ) {
- final var w = getWorkspace();
- final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
-
- // ... preserve the most recent document.
- recentDoc.setValue( n.getFile() );
- CaretMovedEvent.fire( n.getCaret() );
- }
- }
- );
- }
-
- 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 editor = getMainPane().getTextEditor();
- final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
- final var exportParent = exported.get().toPath().getParent();
- final var editorParent = editor.getPath().getParent();
- final var userHomeParent = USER_DIRECTORY.toPath();
- final var exportPath = exportParent != null
- ? exportParent
- : editorParent != null
- ? editorParent
- : userHomeParent;
-
- final var filename = format.toExportFilename( editor.getPath() );
- final var selected = PDF_DEFAULT
- .getName()
- .equals( exported.get().getName() );
- final var selection = pickFile(
- selected
- ? filename
- : exported.get(),
- exportPath,
- FILE_EXPORT
- );
-
- selection.ifPresent( files -> file_export( editor, format, files, dir ) );
- }
-
- private void file_export(
- final TextEditor editor,
- final ExportFormat format,
- final List<File> files,
- final boolean dir ) {
- editor.save();
- final var main = getMainPane();
- final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
-
- final var sourceFile = files.get( 0 );
- final var sourcePath = sourceFile.toPath();
- final var document = dir ? append( editor ) : editor.getText();
- final var context = main.createProcessorContext( sourcePath, format );
-
- final var service = new Service<Path>() {
- @Override
- protected Task<Path> createTask() {
- 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( sourcePath, export );
- }
- };
-
- task.setOnSucceeded(
- e -> {
- // Remember the exported file name for next time.
- exported.setValue( sourceFile );
-
- 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();
- }
- } );
-
- return task;
- }
- };
-
- mTypesetService = service;
- typeset( service );
- }
-
- /**
- * @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.getFile(
- KEY_TYPESET_CONTEXT_THEMES_PATH
- );
- final var theme = workspace.stringProperty(
- KEY_TYPESET_CONTEXT_THEME_SELECTION
- );
- final var chapters = workspace.stringProperty(
- KEY_TYPESET_CONTEXT_CHAPTERS
- );
- final var settings = ExportSettings
- .builder()
- .with( ExportSettings.Mutator::setTheme, theme )
- .with( ExportSettings.Mutator::setChapters, chapters )
- .build();
-
- // Don't re-validate the typesetter installation each time. If the
- // user mucks up the typesetter installation, it'll get caught the
- // next time the application is started. Don't use |= because it
- // won't short-circuit.
- mCanTypeset = mCanTypeset || Typesetter.canRun();
-
- if( mCanTypeset ) {
- // If the typesetter is installed, allow the user to select a theme. If
- // the themes aren't installed, a status message will appear.
- if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
- 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_dir() {
- file_export( XHTML_TEX, true );
- }
-
- public void file_export_repeat() {
- typeset( mTypesetService );
- }
-
- 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 );
- }
-
- private void fireExportFailedEvent() {
- runLater( ExportFailedEvent::fire );
- }
-
- 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_monospace() {
- getActiveTextEditor().monospace();
- }
-
- 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();
- }
-
- private <T> void typeset( final Service<T> service ) {
- synchronized( mMutex ) {
- if( service != null && !service.isRunning() ) {
- service.reset();
- service.start();
- }
- }
- }
-
- /**
- * 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.isBlank() ) {
- clue( "Main.status.export.concat.extension", filename );
- return editor.getText();
- }
-
- try {
- final var glob = "**/*." + extension;
- final var files = new ArrayList<Path>();
- final var text = new StringBuilder( DOCUMENT_LENGTH );
- final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS );
- final var validator = new RangeValidator( range );
- final var chapter = new AtomicInteger();
-
- walk( parent, glob, files::add );
- files.sort( new AlphanumComparator<>() );
- files.forEach( file -> {
- try {
- clue( "Main.status.export.concat", file );
-
- if( validator.test( chapter.incrementAndGet() ) ) {
- // Ensure multiple files are separated by an EOL.
- text.append( readString( file ) ).append( lineSeparator() );
- }
- } catch( final IOException ex ) {
- clue( "Main.status.export.concat.io", file );
- }
- } );
-
- return text.toString();
+import com.keenwrite.commands.ConcatenateCommand;
+import com.keenwrite.editors.TextDefinition;
+import com.keenwrite.editors.TextEditor;
+import com.keenwrite.editors.markdown.HyperlinkModel;
+import com.keenwrite.editors.markdown.LinkVisitor;
+import com.keenwrite.events.CaretMovedEvent;
+import com.keenwrite.events.ExportFailedEvent;
+import com.keenwrite.preferences.Key;
+import com.keenwrite.preferences.PreferencesController;
+import com.keenwrite.preferences.Workspace;
+import com.keenwrite.processors.markdown.MarkdownProcessor;
+import com.keenwrite.search.SearchModel;
+import com.keenwrite.typesetting.Typesetter;
+import com.keenwrite.ui.controls.SearchBar;
+import com.keenwrite.ui.dialogs.ExportDialog;
+import com.keenwrite.ui.dialogs.ExportSettings;
+import com.keenwrite.ui.dialogs.ImageDialog;
+import com.keenwrite.ui.dialogs.LinkDialog;
+import com.keenwrite.ui.explorer.FilePicker;
+import com.keenwrite.ui.explorer.FilePickerFactory;
+import com.keenwrite.ui.logging.LogView;
+import com.vladsch.flexmark.ast.Link;
+import javafx.concurrent.Service;
+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 static com.keenwrite.Bootstrap.*;
+import static com.keenwrite.ExportFormat.*;
+import static com.keenwrite.Messages.get;
+import static com.keenwrite.constants.Constants.PDF_DEFAULT;
+import static com.keenwrite.constants.Constants.USER_DIRECTORY;
+import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
+import static com.keenwrite.events.StatusEvent.clue;
+import static com.keenwrite.preferences.AppKeys.*;
+import static com.keenwrite.processors.ProcessorFactory.createProcessors;
+import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
+import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
+import static java.nio.file.Files.writeString;
+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.
+ */
+public final class GuiCommands {
+ 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;
+
+ private boolean mCanTypeset;
+
+ /**
+ * A {@link Task} can only be run once, so wrap it in a {@link Service} to
+ * allow re-launching the typesetting task repeatedly.
+ */
+ private Service<Path> mTypesetService;
+
+ /**
+ * Prevent a race-condition between checking to see if the typesetting task
+ * is running and restarting the task itself.
+ */
+ private final Object mMutex = new Object();
+
+ public GuiCommands( 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 ...
+ mMainPane.textEditorProperty().addListener(
+ ( c, o, n ) -> {
+ // ... update the haystack.
+ mSearchModel.search( getActiveTextEditor().getText() );
+
+ // ... update the status bar with the current caret position.
+ if( n != null ) {
+ final var w = getWorkspace();
+ final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
+
+ // ... preserve the most recent document.
+ recentDoc.setValue( n.getFile() );
+ CaretMovedEvent.fire( n.getCaret() );
+ }
+ }
+ );
+ }
+
+ 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 editor = getMainPane().getTextEditor();
+ final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
+ final var exportParent = exported.get().toPath().getParent();
+ final var editorParent = editor.getPath().getParent();
+ final var userHomeParent = USER_DIRECTORY.toPath();
+ final var exportPath = exportParent != null
+ ? exportParent
+ : editorParent != null
+ ? editorParent
+ : userHomeParent;
+
+ final var filename = format.toExportFilename( editor.getPath() );
+ final var selected = PDF_DEFAULT
+ .getName()
+ .equals( exported.get().getName() );
+ final var selection = pickFile(
+ selected
+ ? filename
+ : exported.get(),
+ exportPath,
+ FILE_EXPORT
+ );
+
+ selection.ifPresent( files -> file_export( editor, format, files, dir ) );
+ }
+
+ private void file_export(
+ final TextEditor editor,
+ final ExportFormat format,
+ final List<File> files,
+ final boolean dir ) {
+ editor.save();
+ final var main = getMainPane();
+ final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
+
+ final var sourceFile = files.get( 0 );
+ final var sourcePath = sourceFile.toPath();
+ final var document = dir ? append( editor ) : editor.getText();
+ final var context = main.createProcessorContext( sourcePath, format );
+
+ final var service = new Service<Path>() {
+ @Override
+ protected Task<Path> createTask() {
+ 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( sourcePath, export );
+ }
+ };
+
+ task.setOnSucceeded(
+ e -> {
+ // Remember the exported file name for next time.
+ exported.setValue( sourceFile );
+
+ 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();
+ }
+ } );
+
+ return task;
+ }
+ };
+
+ mTypesetService = service;
+ typeset( service );
+ }
+
+ /**
+ * @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.getFile(
+ KEY_TYPESET_CONTEXT_THEMES_PATH
+ );
+ final var theme = workspace.stringProperty(
+ KEY_TYPESET_CONTEXT_THEME_SELECTION
+ );
+ final var chapters = workspace.stringProperty(
+ KEY_TYPESET_CONTEXT_CHAPTERS
+ );
+ final var settings = ExportSettings
+ .builder()
+ .with( ExportSettings.Mutator::setTheme, theme )
+ .with( ExportSettings.Mutator::setChapters, chapters )
+ .build();
+
+ // Don't re-validate the typesetter installation each time. If the
+ // user mucks up the typesetter installation, it'll get caught the
+ // next time the application is started. Don't use |= because it
+ // won't short-circuit.
+ mCanTypeset = mCanTypeset || Typesetter.canRun();
+
+ if( mCanTypeset ) {
+ // If the typesetter is installed, allow the user to select a theme. If
+ // the themes aren't installed, a status message will appear.
+ if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
+ 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_dir() {
+ file_export( XHTML_TEX, true );
+ }
+
+ public void file_export_repeat() {
+ typeset( mTypesetService );
+ }
+
+ 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 );
+ }
+
+ private void fireExportFailedEvent() {
+ runLater( ExportFailedEvent::fire );
+ }
+
+ 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_monospace() {
+ getActiveTextEditor().monospace();
+ }
+
+ 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();
+ }
+
+ private <T> void typeset( final Service<T> service ) {
+ synchronized( mMutex ) {
+ if( service != null && !service.isRunning() ) {
+ service.reset();
+ service.start();
+ }
+ }
+ }
+
+ /**
+ * 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.isBlank() ) {
+ clue( "Main.status.export.concat.extension", filename );
+ return editor.getText();
+ }
+
+ try {
+ final var command = new ConcatenateCommand(
+ parent, extension, getString( KEY_TYPESET_CONTEXT_CHAPTERS ) );
+ return command.call();
} catch( final Throwable t ) {
clue( t );
Delta1067 lines added, 1030 lines removed, 37-line increase