Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M BUILD.md
77
Download and install the following software packages:
88
9
* [JDK 19](https://bell-sw.com/pages/downloads) (Full JDK + JavaFX)
10
* [Gradle 7.6-rc-1](https://services.gradle.org/distributions/gradle-7.6-rc-1-bin.zip)
11
* [Git 2.38.1](https://git-scm.com/downloads)
9
* [JDK 20](https://bell-sw.com/pages/downloads) (Full JDK + JavaFX)
10
* [Gradle 8.1.1](https://gradle.org/releases)
11
* [Git 2.40.1](https://git-scm.com/downloads)
1212
* [warp v0.4.0-alpha](https://github.com/Reisz/warp/releases/tag/v0.4.0)
1313
M README.md
3434
On other platforms, such as MacOS, start the application as follows:
3535
36
1. Download the *Full version* of the Java Runtime Environment, [JRE 19](https://bell-sw.com/pages/downloads).
36
1. Download the *Full version* of the Java Runtime Environment, [JRE 20](https://bell-sw.com/pages/downloads).
3737
1. Install the JRE (include JRE's `bin` directory in the `PATH` environment variable).
3838
1. Open a new terminal.
M README.zh-CN.md
3434
### Other
3535
36
Download and install a full version of [OpenJDK 19](https://bell-sw.com/pages/downloads) that includes JavaFX module support, then run:
36
Download and install a full version of [OpenJDK 20](https://bell-sw.com/pages/downloads) that includes JavaFX module support, then run:
3737
3838
``` bash
A bug-filter.xml
1
<?xml version="1.0" encoding="UTF-8"?>
2
<FindBugsFilter>
3
  <Match>
4
    <Or>
5
      <Bug code="EI, EI2" />
6
    </Or>
7
  </Match>
8
9
  <Match class="com.keenwrite.preview.HighQualityRenderingHints">
10
    <Method name="initializeRenderingHints" />
11
    <Bug code="WMI" />
12
  </Match>
13
14
  <Match class="com.keenwrite.processors.HtmlPreviewProcessor">
15
    <Method name="&lt;init&gt;" />
16
    <Bug code="ST" />
17
  </Match>
18
</FindBugsFilter>
119
M build.gradle
99
  }
1010
  dependencies {
11
    classpath 'org.owasp:dependency-check-gradle:7.4.3'
12
    classpath "com.github.spotbugs.snom:spotbugs-gradle-plugin:5.0.13"
11
    classpath 'org.owasp:dependency-check-gradle:8.2.1'
12
    classpath "com.github.spotbugs.snom:spotbugs-gradle-plugin:5.0.14"
1313
  }
1414
}
1515
1616
plugins {
1717
  id 'application'
18
  id 'org.openjfx.javafxplugin' version '0.0.13'
19
  id 'com.palantir.git-version' version '0.15.0'
20
  //id "com.github.spotbugs" version "5.0.13"
18
  id 'org.openjfx.javafxplugin' version '0.0.14'
19
  id 'com.palantir.git-version' version '3.0.0'
20
  id "com.github.spotbugs" version "5.0.14"
21
}
22
23
spotbugs {
24
  excludeFilter.set(
25
      file("${projectDir}/bug-filter.xml")
26
  )
2127
}
2228
...
7076
7177
java {
72
  sourceCompatibility = 19
73
  targetCompatibility = 19
78
  sourceCompatibility = 20
79
  targetCompatibility = 20
7480
}
7581
7682
javafx {
77
  version = '19'
83
  version = '20'
7884
  modules = ['javafx.base', 'javafx.controls', 'javafx.graphics', 'javafx.swing']
7985
  configuration = 'compileOnly'
8086
}
8187
8288
dependencies {
83
  def v_junit = '5.9.1'
84
  def v_flexmark = '0.64.0'
85
  def v_jackson = '2.14.2'
86
  def v_echosvg = '0.2.2'
87
  def v_picocli = '4.7.0'
89
  def v_junit = '5.9.3'
90
  def v_flexmark = '0.64.6'
91
  def v_jackson = '2.15.1'
92
  def v_echosvg = '0.3'
93
  def v_picocli = '4.7.3'
8894
8995
  // JavaFX
9096
  implementation 'org.controlsfx:controlsfx:11.1.2'
9197
  implementation 'org.fxmisc.richtext:richtextfx:0.11.0'
9298
  implementation 'org.fxmisc.flowless:flowless:0.7.0'
9399
  implementation 'org.fxmisc.wellbehaved:wellbehavedfx:0.3.3'
94100
  implementation 'com.miglayout:miglayout-javafx:11.0'
95
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.11.0'
101
  implementation 'com.dlsc.preferencesfx:preferencesfx-core:11.16.0'
96102
  implementation 'com.panemu:tiwulfx-dock:0.2'
97103
...
112118
113119
  // HTML parsing and rendering
114
  implementation 'org.jsoup:jsoup:1.15.3'
120
  implementation 'org.jsoup:jsoup:1.16.1'
115121
  // TODO: https://github.com/flyingsaucerproject/flyingsaucer/pull/170
116122
  //implementation 'org.xhtmlrenderer:flying-saucer-core:9.1.22'
...
140146
  implementation 'org.ahocorasick:ahocorasick:0.6.3'
141147
  implementation 'org.apache.commons:commons-configuration2:2.9.0'
142
  implementation 'com.googlecode.juniversalchardet:juniversalchardet:1.0.3'
148
  implementation 'com.github.albfernandez:juniversalchardet:2.4.0'
143149
  implementation 'javax.validation:validation-api:2.0.1.Final'
144150
  implementation 'org.greenrobot:eventbus-java:3.3.1'
145
  //implementation 'commons-beanutils:commons-beanutils:1.9.4'
146151
147152
  // Command-line parsing
...
164169
  testImplementation "org.junit.jupiter:junit-jupiter-params:${v_junit}"
165170
  testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
171
}
172
173
sourceSets {
174
  main {
175
    java {
176
      srcDirs 'src/main'
177
    }
178
  }
179
180
  test {
181
    java {
182
      srcDirs 'src/test'
183
    }
184
  }
166185
}
167186
M src/main/java/com/keenwrite/AppCommands.java
77
import com.keenwrite.cmdline.Arguments;
88
import com.keenwrite.commands.ConcatenateCommand;
9
import com.keenwrite.io.SysFile;
910
import com.keenwrite.processors.Processor;
1011
import com.keenwrite.processors.ProcessorContext;
1112
import com.keenwrite.processors.RBootstrapProcessor;
1213
1314
import java.io.IOException;
1415
import java.nio.file.Path;
1516
import java.util.concurrent.Callable;
1617
import java.util.concurrent.CompletableFuture;
1718
import java.util.concurrent.ExecutorService;
19
import java.util.concurrent.Future;
1820
import java.util.concurrent.atomic.AtomicInteger;
1921
...
6769
   *
6870
   * @param future Indicates whether the export succeeded or failed.
71
   * @return The path to the exported file as a {@link Future}.
6972
   */
70
  private static void file_export(
73
  @SuppressWarnings( "UnusedReturnValue" )
74
  private static Future<Path> file_export(
7175
    final Arguments args, final CompletableFuture<Path> future ) {
7276
    assert args != null;
...
96100
97101
    // Prevent the application from blocking while the processor executes.
98
    sExecutor.submit( callableTask );
102
    return sExecutor.submit( callableTask );
99103
  }
100104
...
131135
    final var inputPath = context.getSourcePath();
132136
    final var parent = inputPath.getParent();
133
    final var filename = inputPath.getFileName().toString();
137
    final var filename = SysFile.getFileName( inputPath );
134138
    final var extension = getExtension( filename );
135139
M src/main/java/com/keenwrite/Bootstrap.java
3131
  private static final Properties sP = new Properties();
3232
33
  public static String APP_TITLE;
34
  public static String APP_VERSION;
35
  public static String CONTAINER_VERSION;
33
  public static final String APP_TITLE;
34
  public static final String APP_VERSION;
35
  public static final String CONTAINER_VERSION;
3636
3737
  public static final String APP_TITLE_ABBR = "kwr";
...
4444
4545
  static {
46
    // There's no way to know what container version is compatible. This
47
    // value will cause a failure when downloading the container,
48
    var containerVersion = "1.0.0";
49
    var appVersion = "0.0.0";
50
    var appTitle = "KeenWrite";
51
4652
    try( final var in = openResource( PATH_BOOTSTRAP ) ) {
4753
      sP.load( in );
4854
49
      APP_TITLE = sP.getProperty( "application.title" );
50
      CONTAINER_VERSION = sP.getProperty( "container.version" );
55
      appTitle = sP.getProperty( "application.title" );
56
      containerVersion = sP.getProperty( "container.version" );
5157
    } catch( final Exception ex ) {
52
      APP_TITLE = "KeenWrite";
53
54
      // Bootstrap properties cannot be found, use a default value.
5558
      final var fmt = "Unable to load %s resource, applying defaults.%n";
5659
      clue( ex, fmt, PATH_BOOTSTRAP );
57
58
      // There's no way to know what container version is compatible. This
59
      // value will cause a failure when downloading the container,
60
      CONTAINER_VERSION = "1.0.0";
6160
    }
6261
62
    CONTAINER_VERSION = containerVersion;
63
    APP_TITLE = appTitle;
6364
    APP_TITLE_LOWERCASE = APP_TITLE.toLowerCase();
6465
6566
    try {
66
      APP_VERSION = Launcher.getVersion();
67
      appVersion = Launcher.getVersion();
6768
    } catch( final Exception ex ) {
68
      APP_VERSION = "0.0.0";
69
70
      // Application version cannot be found, use a default value.
7169
      final var fmt = "Unable to determine application version.";
7270
      clue( ex, fmt );
7371
    }
72
73
    APP_VERSION = appVersion;
7474
7575
    // The plug-in that requests the version from the repository tag will
...
8585
    USER_CACHE_DIR = USER_DATA_DIR.resolve( "cache" ).toFile();
8686
87
    if( !USER_CACHE_DIR.exists() ) {
88
      final var ignored = USER_CACHE_DIR.mkdirs();
87
    if( !USER_CACHE_DIR.exists() && !USER_CACHE_DIR.mkdirs() ) {
88
      clue( "Main.status.error.bootstrap.cache", USER_CACHE_DIR );
8989
    }
9090
  }
M src/main/java/com/keenwrite/collections/CircularQueue.java
192192
      @Override
193193
      public E next() {
194
        final var element = mElements[ mIndex++ ];
195
        mIndex %= mCapacity;
196
        mFirst = false;
194
        try {
195
          final var element = mElements[ mIndex++ ];
196
          mIndex %= mCapacity;
197
          mFirst = false;
197198
198
        return (E) element;
199
          return (E) element;
200
        } catch( final IndexOutOfBoundsException ex ) {
201
          throw new NoSuchElementException( "No such element at: " + mIndex );
202
        }
199203
      }
200204
    };
M src/main/java/com/keenwrite/collections/InterpolatingMap.java
44
import com.keenwrite.sigils.SigilKeyOperator;
55
6
import java.io.Serial;
67
import java.util.HashMap;
78
import java.util.Map;
9
import java.util.Objects;
810
import java.util.concurrent.ConcurrentHashMap;
911
1012
/**
1113
 * Responsible for interpolating key-value pairs in a map. That is, this will
1214
 * iterate over all key-value pairs and replace keys wrapped in sigils
1315
 * with corresponding definition value from the same map.
1416
 */
1517
public class InterpolatingMap extends ConcurrentHashMap<String, String> {
18
  @Serial
19
  private static final long serialVersionUID = -8705400301476113530L;
20
1621
  private static final int GROUP_DELIMITED = 1;
1722
1823
  /**
1924
   * Used to override the default initial capacity in {@link HashMap}.
2025
   */
2126
  private static final int INITIAL_CAPACITY = 1 << 8;
2227
23
  private final SigilKeyOperator mOperator;
28
  private transient final SigilKeyOperator mOperator;
2429
2530
  /**
...
8489
8590
    return value;
91
  }
92
93
  @Override
94
  public boolean equals( final Object o ) {
95
    if( this == o ) { return true; }
96
    if( o == null || getClass() != o.getClass() ) { return false; }
97
    if( !super.equals( o ) ) { return false; }
98
    final InterpolatingMap that = (InterpolatingMap) o;
99
    return Objects.equals( mOperator, that.mOperator );
100
  }
101
102
  @Override
103
  public int hashCode() {
104
    return Objects.hash( super.hashCode(), mOperator );
86105
  }
87106
}
M src/main/java/com/keenwrite/dom/DocumentParser.java
4141
    new ByteArrayOutputStream( 65536 );
4242
  private static final OutputStreamWriter sOutput =
43
    new OutputStreamWriter( sWriter );
43
    new OutputStreamWriter( sWriter, UTF_8 );
4444
4545
  /**
...
5353
  private static final XPath sXpath = XPathFactory.newInstance().newXPath();
5454
55
  public static DOMImplementation sDomImplementation;
55
  public static final DOMImplementation sDomImplementation;
5656
5757
  static {
5858
    sDocumentFactory = DocumentBuilderFactory.newInstance();
5959
6060
    sDocumentFactory.setValidating( false );
6161
    sDocumentFactory.setAttribute( LOAD_EXTERNAL_DTD, false );
6262
    sDocumentFactory.setNamespaceAware( true );
6363
    sDocumentFactory.setIgnoringComments( true );
6464
    sDocumentFactory.setIgnoringElementContentWhitespace( true );
65
66
    DOMImplementation domImplementation;
6567
6668
    try {
6769
      sDocumentBuilder = sDocumentFactory.newDocumentBuilder();
68
      sDomImplementation = sDocumentBuilder.getDOMImplementation();
70
      domImplementation = sDocumentBuilder.getDOMImplementation();
6971
      sTransformer = TransformerFactory.newInstance().newTransformer();
7072
...
7779
    } catch( final Exception ex ) {
7880
      clue( ex );
81
      domImplementation = sDocumentBuilder.getDOMImplementation();
7982
    }
83
84
    sDomImplementation = domImplementation;
8085
  }
8186
M src/main/java/com/keenwrite/editors/common/VariableNameInjector.java
143143
    assert word != null;
144144
145
    DefinitionTreeItem<String> leaf = null;
145
    DefinitionTreeItem<String> leaf;
146146
147
    leaf = leaf == null ? definition.findLeafExact( word ) : leaf;
147
    leaf = definition.findLeafExact( word );
148148
    leaf = leaf == null ? definition.findLeafStartsWith( word ) : leaf;
149149
    leaf = leaf == null ? definition.findLeafContains( word ) : leaf;
M src/main/java/com/keenwrite/editors/definition/DefinitionEditor.java
457457
          }
458458
        }
459
460
        default -> { }
459461
      }
460462
M src/main/java/com/keenwrite/events/workspace/WorkspaceLoadedEvent.java
2828
   * @return The {@link Workspace} that has loaded user preferences.
2929
   */
30
  public Workspace getWorkspace() {
30
  @SuppressWarnings( "unused" )
31
  private Workspace getWorkspace() {
3132
    return mWorkspace;
3233
  }
M src/main/java/com/keenwrite/io/FileWatchService.java
126126
   */
127127
  public void unregister( final File file ) {
128
    mWatched.remove( cancel( file ) );
128
    cancel( file );
129
    mWatched.remove( file );
129130
  }
130131
131132
  /**
132133
   * Cancels watching the given file for file system changes.
133134
   *
134135
   * @param file The {@link File} to watch for file events.
135
   * @return The given file, always.
136136
   */
137
  private File cancel( final File file ) {
137
  private void cancel( final File file ) {
138138
    final var watchKey = mWatched.get( file );
139139
140140
    if( watchKey != null ) {
141141
      watchKey.cancel();
142142
    }
143
144
    return file;
145143
  }
146144
...
216214
    } catch( final Exception ex ) {
217215
      // Create a fallback that allows the class to be instantiated and used
218
      // without without preventing the application from launching.
216
      // without preventing the application from launching.
219217
      return new PollingWatchService();
220218
    }
M src/main/java/com/keenwrite/io/SysFile.java
128128
129129
  /**
130
   * Provides {@code null}-safe machinery to get a file name.
131
   *
132
   * @param p The path to the file name to retrieve (may be {@code null}).
133
   * @return The file name or the empty string if the path is not found.
134
   */
135
  public static String getFileName( final Path p ) {
136
    return p == null ? "" : getPathFileName( p );
137
  }
138
139
  private static String getPathFileName( final Path p ) {
140
    assert p != null;
141
142
    final var f = p.getFileName();
143
144
    return f == null ? "" : f.toString();
145
  }
146
147
  /**
130148
   * Changes to the PATH environment variable aren't reflected for the
131149
   * currently running task. The registry, however, contains the updated
...
143161
  }
144162
163
  @SuppressWarnings( "SpellCheckingInspection" )
145164
  private String pathsWindows( final Function<String, String> map ) {
146165
    try {
...
219238
      }
220239
221
      final var subexpr = compile( quote( match ) );
222
      expanded = subexpr.matcher( expanded ).replaceAll( value );
240
      final var subexpression = compile( quote( match ) );
241
      expanded = subexpression.matcher( expanded ).replaceAll( value );
223242
    }
224243
M src/main/java/com/keenwrite/io/Zip.java
3232
   */
3333
  public static void extract( final Path zipPath ) throws IOException {
34
    final var path = zipPath.getParent().normalize();
34
    final var parent = zipPath.getParent();
35
36
    if( parent == null ) {
37
      throw new IOException( "Path to zip file has no parent." );
38
    }
39
40
    final var path = parent.normalize();
3541
3642
    iterate( zipPath, ( zipFile, zipEntry ) -> {
...
125131
    final Path zipEntryPath ) throws IOException {
126132
    // Only extract files, skip empty directories.
127
    if( !zipEntry.isDirectory() ) {
128
      createDirectories( zipEntryPath.getParent() );
133
    if( !zipEntry.isDirectory() && zipEntryPath != null ) {
134
      final var parent = zipEntryPath.getParent();
129135
130
      try( final var in = zipFile.getInputStream( zipEntry ) ) {
131
        Files.copy( in, zipEntryPath, REPLACE_EXISTING );
136
      if( parent != null ) {
137
        createDirectories( parent );
138
139
        try( final var in = zipFile.getInputStream( zipEntry ) ) {
140
          Files.copy( in, zipEntryPath, REPLACE_EXISTING );
141
        }
132142
      }
133143
    }
M src/main/java/com/keenwrite/io/downloads/DownloadManager.java
8989
     * {@link OutputStream} will be closed after downloading is complete.
9090
     *
91
     * @param output   Where to write the file contents.
91
     * @param file   Where to write the file contents.
9292
     * @param listener Receives download progress status updates.
9393
     * @return A {@link Runnable} task that can be executed in the background
9494
     * to download the resource for this {@link DownloadToken}.
9595
     */
9696
    public Runnable download(
97
      final OutputStream output,
97
      final File file,
9898
      final ProgressListener listener ) {
9999
      return () -> {
100100
        final var buffer = new byte[ BUFFER_SIZE ];
101101
        final var stream = getInputStream();
102102
        final var bytesTotal = mBytesTotal;
103103
104104
        long bytesTally = 0;
105105
        int bytesRead;
106106
107
        try( output ) {
107
        try( final var output = new FileOutputStream( file ) ) {
108108
          while( (bytesRead = stream.read( buffer )) != -1 ) {
109109
            if( Thread.currentThread().isInterrupted() ) {
...
191191
   * responsible for closing the {@link DownloadManager} to close the
192192
   * underlying stream and the HTTP connection. Connections must be closed by
193
   * callers if {@link DownloadToken#download(OutputStream, ProgressListener)}
193
   * callers if {@link DownloadToken#download(File, ProgressListener)}
194194
   * isn't called (i.e., {@link DownloadToken#getMediaType()} is called
195195
   * after the transport layer's Content-Type is requested but not contents
M src/main/java/com/keenwrite/preferences/PreferencesController.java
342342
        case ENTER -> ((Button) pane.lookupButton( OK )).fire();
343343
        case ESCAPE -> ((Button) pane.lookupButton( CANCEL )).fire();
344
        default -> { }
344345
      }
345346
    } );
M src/main/java/com/keenwrite/preferences/SimpleFontControl.java
118118
        case ENTER -> buttonOk.fire();
119119
        case ESCAPE -> buttonCancel.fire();
120
        default -> { }
120121
      }
121122
    } );
M src/main/java/com/keenwrite/preferences/SimpleTableControl.java
3232
  private static long sCounter;
3333
34
  public SimpleTableControl() {}
34
  public SimpleTableControl() { }
3535
3636
  @Override
3737
  public void initializeParts() {
3838
    super.initializeParts();
3939
40
    final var model = field.viewProperty();
41
    final var table = new TableView<>( model );
40
    final var field = getField();
41
    final var table = field.createTableView();
4242
4343
    table.setColumnResizePolicy( CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN );
...
5858
          sCounter++;
5959
60
          model.add( createEntry( "key" + sCounter, "value" + sCounter ) );
60
          field.add( createEntry( "key" + sCounter, "value" + sCounter ) );
6161
        }
6262
      ),
...
191191
   */
192192
  @Override
193
  public void layoutParts() {}
193
  public void layoutParts() { }
194194
}
195195
M src/main/java/com/keenwrite/preferences/SkeletonStorageHandler.java
100100
  }
101101
102
  @Override
103102
  public Preferences getPreferences() {
104103
    return null;
M src/main/java/com/keenwrite/preferences/TableField.java
77
import javafx.beans.property.Property;
88
import javafx.beans.property.SimpleListProperty;
9
import javafx.scene.control.TableView;
910
1011
import java.util.ArrayList;
...
4950
  }
5051
51
  /**
52
   * Returns the data model that seeds the user interface. At any point the
53
   * user may cancel editing, which will revert to the previously persisted
54
   * set.
55
   *
56
   * @return The source for values displayed in the UI.
57
   */
58
  public ListProperty<P> viewProperty() {
59
    return mViewProperty;
52
  public TableView<P> createTableView() {
53
    return new TableView<>( mViewProperty );
54
  }
55
56
  public void add( final P entry ) {
57
    mViewProperty.add( entry );
6058
  }
6159
M src/main/java/com/keenwrite/preferences/XmlStore.java
1818
import java.util.function.Consumer;
1919
20
import static com.keenwrite.events.StatusEvent.clue;
21
import static java.nio.charset.StandardCharsets.UTF_8;
2022
import static javax.xml.xpath.XPathConstants.NODE;
2123
...
8587
        return node.getTextContent();
8688
      }
87
    } catch( final XPathExpressionException ignored ) {}
89
    } catch( final XPathExpressionException ignored ) { }
8890
8991
    throw new NoSuchElementException( key.toString() );
...
159161
    assert config != null;
160162
161
    try( final var writer = new FileWriter( config ) ) {
163
    try( final var writer = new FileWriter( config, UTF_8 ) ) {
162164
      writer.write( DocumentParser.toString( mDocument ) );
163165
    }
...
172174
173175
      node.setTextContent( value );
174
    } catch( final XPathExpressionException ignored ) {}
176
    } catch( final XPathExpressionException ex ) {
177
      clue( ex );
178
    }
175179
  }
176180
...
201205
        node.setTextContent( item.toString() );
202206
      }
203
    } catch( final XPathExpressionException ignored ) {}
207
    } catch( final XPathExpressionException ignored ) { }
204208
  }
205209
...
219223
220224
          node.setTextContent( entry.getValue().toString() );
221
        } catch( final XPathExpressionException ignored ) {}
225
        } catch( final XPathExpressionException ignored ) { }
222226
      }
223227
    }
M src/main/java/com/keenwrite/preview/DiagramUrlGenerator.java
22
package com.keenwrite.preview;
33
4
import java.nio.charset.StandardCharsets;
45
import java.util.zip.Deflater;
56
...
3940
   */
4041
  private static String encode( final String text ) {
41
    return getUrlEncoder().encodeToString( compress( text.getBytes() ) );
42
    return getUrlEncoder().encodeToString(
43
      compress( text.getBytes( StandardCharsets.UTF_8 ) )
44
    );
4245
  }
4346
M src/main/java/com/keenwrite/preview/FlyingSaucerPanel.java
7777
          case HTTP -> HyperlinkOpenEvent.fire( uri );
7878
          case FILE -> FileOpenEvent.fire( uri );
79
          default -> { }
7980
        }
8081
      } catch( final Exception ex ) {
M src/main/java/com/keenwrite/preview/HighQualityRenderingHints.java
3838
3939
  static {
40
    initializeRenderingHints();
41
  }
42
43
  private static void initializeRenderingHints() {
4044
    final var toolkit = getDefaultToolkit();
4145
    final var hints = toolkit.getDesktopProperty( "awt.font.desktophints" );
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
207207
      final var dimensions = getAttributes( node.getInfo() );
208208
      final var r = format( R_SVG_EXPORT, svg, dimensions, text );
209
      final var result = mRChunkEvaluator.apply( r );
209
210
      mRChunkEvaluator.apply( r );
210211
211212
      return new Tuple<>( svg, link );
M src/main/java/com/keenwrite/search/SearchModel.java
7676
    final var emits = trie.parseText( haystack );
7777
78
    mMatches = new CyclicIterator<>( new ArrayList<>( emits ) );
78
    mMatches = new CyclicIterator<>( emits );
7979
    mMatchCount.set( emits.size() );
8080
    mNeedle = needle;
M src/main/java/com/keenwrite/security/PermissiveCertificate.java
33
44
import javax.net.ssl.*;
5
import java.security.KeyManagementException;
6
import java.security.NoSuchAlgorithmException;
57
import java.security.SecureRandom;
68
import java.security.cert.X509Certificate;
...
6163
      setDefaultHostnameVerifier( new PermissiveHostNameVerifier() );
6264
      return true;
63
    } catch( final Exception ex ) {
65
    } catch( NoSuchAlgorithmException | KeyManagementException e ) {
6466
      return false;
6567
    }
M src/main/java/com/keenwrite/typesetting/GuestTypesetter.java
44
import com.keenwrite.io.CommandNotFoundException;
55
import com.keenwrite.io.StreamGobbler;
6
import com.keenwrite.io.SysFile;
67
import com.keenwrite.typesetting.containerization.Podman;
78
import org.apache.commons.io.FilenameUtils;
...
100101
101102
  static String removeExtension( final Path path ) {
102
    return FilenameUtils.removeExtension( path.toString() );
103
    return FilenameUtils.removeExtension( SysFile.getFileName( path ) );
103104
  }
104105
M src/main/java/com/keenwrite/typesetting/HostTypesetter.java
136136
      // error files.
137137
      if( exit > 0 ) {
138
        final var xmlName = getSourcePath().getFileName().toString();
139
        final var srcName = getTargetPath().getFileName().toString();
138
        final var xmlName = SysFile.getFileName( getSourcePath() );
139
        final var srcName = SysFile.getFileName( getTargetPath() );
140140
        final var logName = newExtension( xmlName, ".log" );
141141
        final var errName = newExtension( xmlName, "-error.log" );
M src/main/java/com/keenwrite/typesetting/containerization/Podman.java
66
77
import java.io.File;
8
import java.io.IOException;
89
import java.nio.file.Path;
910
import java.util.LinkedList;
1011
import java.util.List;
12
import java.util.NoSuchElementException;
1113
1214
import static com.keenwrite.Bootstrap.CONTAINER_VERSION;
...
134136
135137
      return wait( process );
136
    } catch( final Exception ex ) {
138
    } catch( final NoSuchElementException |
139
                   IOException |
140
                   InterruptedException ex ) {
137141
      throw new CommandNotFoundException( MANAGER.toString() );
138142
    }
M src/main/java/com/keenwrite/typesetting/installer/panes/AbstractDownloadPane.java
1515
import static com.keenwrite.Messages.get;
1616
import static com.keenwrite.Messages.getUri;
17
import static com.keenwrite.events.StatusEvent.clue;
1718
1819
/**
...
118119
119120
  protected void deleteTarget() {
120
    final var ignored = getTarget().delete();
121
    if( !getTarget().delete() ) {
122
      clue( "Main.status.error.file.delete", getTarget() );
123
    }
121124
  }
122125
}
M src/main/java/com/keenwrite/typesetting/installer/panes/InstallerPane.java
2121
2222
import java.io.File;
23
import java.io.FileOutputStream;
2423
import java.net.URI;
2524
import java.nio.file.Paths;
...
297296
    final Task<Void> task = createTask( () -> {
298297
      try( final var token = DownloadManager.open( uri ) ) {
299
        final var output = new FileOutputStream( file );
300
        final var downloader = token.download( output, listener );
301
302
        downloader.run();
298
        token.download( file, listener ).run();
303299
      }
304300
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
1212
import com.keenwrite.events.CaretMovedEvent;
1313
import com.keenwrite.events.ExportFailedEvent;
14
import com.keenwrite.preferences.Key;
15
import com.keenwrite.preferences.PreferencesController;
16
import com.keenwrite.preferences.Workspace;
17
import com.keenwrite.processors.markdown.MarkdownProcessor;
18
import com.keenwrite.search.SearchModel;
19
import com.keenwrite.typesetting.Typesetter;
20
import com.keenwrite.ui.controls.SearchBar;
21
import com.keenwrite.ui.dialogs.ExportDialog;
22
import com.keenwrite.ui.dialogs.ExportSettings;
23
import com.keenwrite.ui.dialogs.ImageDialog;
24
import com.keenwrite.ui.dialogs.LinkDialog;
25
import com.keenwrite.ui.explorer.FilePicker;
26
import com.keenwrite.ui.explorer.FilePickerFactory;
27
import com.keenwrite.ui.logging.LogView;
28
import com.vladsch.flexmark.ast.Link;
29
import javafx.concurrent.Service;
30
import javafx.concurrent.Task;
31
import javafx.scene.control.Alert;
32
import javafx.scene.control.Dialog;
33
import javafx.stage.Window;
34
import javafx.stage.WindowEvent;
35
36
import java.io.File;
37
import java.nio.file.Path;
38
import java.util.List;
39
import java.util.Optional;
40
41
import static com.keenwrite.Bootstrap.*;
42
import static com.keenwrite.ExportFormat.*;
43
import static com.keenwrite.Messages.get;
44
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
45
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
46
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
47
import static com.keenwrite.events.StatusEvent.clue;
48
import static com.keenwrite.preferences.AppKeys.*;
49
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
50
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
51
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
52
import static java.nio.file.Files.writeString;
53
import static javafx.application.Platform.runLater;
54
import static javafx.event.Event.fireEvent;
55
import static javafx.scene.control.Alert.AlertType.INFORMATION;
56
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
57
import static org.apache.commons.io.FilenameUtils.getExtension;
58
59
/**
60
 * Responsible for abstracting how functionality is mapped to the application.
61
 * This allows users to customize accelerator keys and will provide pluggable
62
 * functionality so that different text markup languages can change documents
63
 * using their respective syntax.
64
 */
65
public final class GuiCommands {
66
  private static final String STYLE_SEARCH = "search";
67
68
  /**
69
   * When an action is executed, this is one of the recipients.
70
   */
71
  private final MainPane mMainPane;
72
73
  private final MainScene mMainScene;
74
75
  private final LogView mLogView;
76
77
  /**
78
   * Tracks finding text in the active document.
79
   */
80
  private final SearchModel mSearchModel;
81
82
  private boolean mCanTypeset;
83
84
  /**
85
   * A {@link Task} can only be run once, so wrap it in a {@link Service} to
86
   * allow re-launching the typesetting task repeatedly.
87
   */
88
  private Service<Path> mTypesetService;
89
90
  /**
91
   * Prevent a race-condition between checking to see if the typesetting task
92
   * is running and restarting the task itself.
93
   */
94
  private final Object mMutex = new Object();
95
96
  public GuiCommands( final MainScene scene, final MainPane pane ) {
97
    mMainScene = scene;
98
    mMainPane = pane;
99
    mLogView = new LogView();
100
    mSearchModel = new SearchModel();
101
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
102
      final var editor = getActiveTextEditor();
103
104
      // Clear highlighted areas before highlighting a new region.
105
      if( o != null ) {
106
        editor.unstylize( STYLE_SEARCH );
107
      }
108
109
      if( n != null ) {
110
        editor.moveTo( n.getStart() );
111
        editor.stylize( n, STYLE_SEARCH );
112
      }
113
    } );
114
115
    // When the active text editor changes ...
116
    mMainPane.textEditorProperty().addListener(
117
      ( c, o, n ) -> {
118
        // ... update the haystack.
119
        mSearchModel.search( getActiveTextEditor().getText() );
120
121
        // ... update the status bar with the current caret position.
122
        if( n != null ) {
123
          final var w = getWorkspace();
124
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
125
126
          // ... preserve the most recent document.
127
          recentDoc.setValue( n.getFile() );
128
          CaretMovedEvent.fire( n.getCaret() );
129
        }
130
      }
131
    );
132
  }
133
134
  public void file_new() {
135
    getMainPane().newTextEditor();
136
  }
137
138
  public void file_open() {
139
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
140
  }
141
142
  public void file_close() {
143
    getMainPane().close();
144
  }
145
146
  public void file_close_all() {
147
    getMainPane().closeAll();
148
  }
149
150
  public void file_save() {
151
    getMainPane().save();
152
  }
153
154
  public void file_save_as() {
155
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
156
  }
157
158
  public void file_save_all() {
159
    getMainPane().saveAll();
160
  }
161
162
  /**
163
   * Converts the actively edited file in the given file format.
164
   *
165
   * @param format The destination file format.
166
   */
167
  private void file_export( final ExportFormat format ) {
168
    file_export( format, false );
169
  }
170
171
  /**
172
   * Converts one or more files into the given file format. If {@code dir}
173
   * is set to true, this will first append all files in the same directory
174
   * as the actively edited file.
175
   *
176
   * @param format The destination file format.
177
   * @param dir    Export all files in the actively edited file's directory.
178
   */
179
  private void file_export( final ExportFormat format, final boolean dir ) {
180
    final var editor = getMainPane().getTextEditor();
181
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
182
    final var exportParent = exported.get().toPath().getParent();
183
    final var editorParent = editor.getPath().getParent();
184
    final var userHomeParent = USER_DIRECTORY.toPath();
185
    final var exportPath = exportParent != null
186
      ? exportParent
187
      : editorParent != null
188
      ? editorParent
189
      : userHomeParent;
190
191
    final var filename = format.toExportFilename( editor.getPath() );
192
    final var selected = PDF_DEFAULT
193
      .getName()
194
      .equals( exported.get().getName() );
195
    final var selection = pickFile(
196
      selected
197
        ? filename
198
        : exported.get(),
199
      exportPath,
200
      FILE_EXPORT
201
    );
202
203
    selection.ifPresent( files -> file_export( editor, format, files, dir ) );
204
  }
205
206
  private void file_export(
207
    final TextEditor editor,
208
    final ExportFormat format,
209
    final List<File> files,
210
    final boolean dir ) {
211
    editor.save();
212
    final var main = getMainPane();
213
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
214
215
    final var sourceFile = files.get( 0 );
216
    final var sourcePath = sourceFile.toPath();
217
    final var document = dir ? append( editor ) : editor.getText();
218
    final var context = main.createProcessorContext( sourcePath, format );
219
220
    final var service = new Service<Path>() {
221
      @Override
222
      protected Task<Path> createTask() {
223
        final var task = new Task<Path>() {
224
          @Override
225
          protected Path call() throws Exception {
226
            final var chain = createProcessors( context );
227
            final var export = chain.apply( document );
228
229
            // Processors can export binary files. In such cases, processors
230
            // return null to prevent further processing.
231
            return export == null ? null : writeString( sourcePath, export );
232
          }
233
        };
234
235
        task.setOnSucceeded(
236
          e -> {
237
            // Remember the exported file name for next time.
238
            exported.setValue( sourceFile );
239
240
            final var result = task.getValue();
241
242
            // Binary formats must notify users of success independently.
243
            if( result != null ) {
244
              clue( "Main.status.export.success", result );
245
            }
246
          }
247
        );
248
249
        task.setOnFailed( e -> {
250
          final var ex = task.getException();
251
          clue( ex );
252
253
          if( ex instanceof TypeNotPresentException ) {
254
            fireExportFailedEvent();
255
          }
256
        } );
257
258
        return task;
259
      }
260
    };
261
262
    mTypesetService = service;
263
    typeset( service );
264
  }
265
266
  /**
267
   * @param dir {@code true} means to export all files in the active file
268
   *            editor's directory; {@code false} means to export only the
269
   *            actively edited file.
270
   */
271
  private void file_export_pdf( final boolean dir ) {
272
    final var workspace = getWorkspace();
273
    final var themes = workspace.getFile(
274
      KEY_TYPESET_CONTEXT_THEMES_PATH
275
    );
276
    final var theme = workspace.stringProperty(
277
      KEY_TYPESET_CONTEXT_THEME_SELECTION
278
    );
279
    final var chapters = workspace.stringProperty(
280
      KEY_TYPESET_CONTEXT_CHAPTERS
281
    );
282
    final var settings = ExportSettings
283
      .builder()
284
      .with( ExportSettings.Mutator::setTheme, theme )
285
      .with( ExportSettings.Mutator::setChapters, chapters )
286
      .build();
287
288
    // Don't re-validate the typesetter installation each time. If the
289
    // user mucks up the typesetter installation, it'll get caught the
290
    // next time the application is started. Don't use |= because it
291
    // won't short-circuit.
292
    mCanTypeset = mCanTypeset || Typesetter.canRun();
293
294
    if( mCanTypeset ) {
295
      // If the typesetter is installed, allow the user to select a theme. If
296
      // the themes aren't installed, a status message will appear.
297
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
298
        file_export( APPLICATION_PDF, dir );
299
      }
300
    }
301
    else {
302
      fireExportFailedEvent();
303
    }
304
  }
305
306
  public void file_export_pdf() {
307
    file_export_pdf( false );
308
  }
309
310
  public void file_export_pdf_dir() {
311
    file_export_pdf( true );
312
  }
313
314
  public void file_export_html_dir() {
315
    file_export( XHTML_TEX, true );
316
  }
317
318
  public void file_export_repeat() {
319
    typeset( mTypesetService );
320
  }
321
322
  public void file_export_html_svg() {
323
    file_export( HTML_TEX_SVG );
324
  }
325
326
  public void file_export_html_tex() {
327
    file_export( HTML_TEX_DELIMITED );
328
  }
329
330
  public void file_export_xhtml_tex() {
331
    file_export( XHTML_TEX );
332
  }
333
334
  private void fireExportFailedEvent() {
335
    runLater( ExportFailedEvent::fire );
336
  }
337
338
  public void file_exit() {
339
    final var window = getWindow();
340
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
341
  }
342
343
  public void edit_undo() {
344
    getActiveTextEditor().undo();
345
  }
346
347
  public void edit_redo() {
348
    getActiveTextEditor().redo();
349
  }
350
351
  public void edit_cut() {
352
    getActiveTextEditor().cut();
353
  }
354
355
  public void edit_copy() {
356
    getActiveTextEditor().copy();
357
  }
358
359
  public void edit_paste() {
360
    getActiveTextEditor().paste();
361
  }
362
363
  public void edit_select_all() {
364
    getActiveTextEditor().selectAll();
365
  }
366
367
  public void edit_find() {
368
    final var nodes = getMainScene().getStatusBar().getLeftItems();
369
370
    if( nodes.isEmpty() ) {
371
      final var searchBar = new SearchBar();
372
373
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
374
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
375
376
      searchBar.setOnCancelAction( event -> {
377
        final var editor = getActiveTextEditor();
378
        nodes.remove( searchBar );
379
        editor.unstylize( STYLE_SEARCH );
380
        editor.getNode().requestFocus();
381
      } );
382
383
      searchBar.addInputListener( ( c, o, n ) -> {
384
        if( n != null && !n.isEmpty() ) {
385
          mSearchModel.search( n, getActiveTextEditor().getText() );
386
        }
387
      } );
388
389
      searchBar.setOnNextAction( event -> edit_find_next() );
390
      searchBar.setOnPrevAction( event -> edit_find_prev() );
391
392
      nodes.add( searchBar );
393
      searchBar.requestFocus();
394
    }
395
  }
396
397
  public void edit_find_next() {
398
    mSearchModel.advance();
399
  }
400
401
  public void edit_find_prev() {
402
    mSearchModel.retreat();
403
  }
404
405
  public void edit_preferences() {
406
    try {
407
      new PreferencesController( getWorkspace() ).show();
408
    } catch( final Exception ex ) {
409
      clue( ex );
410
    }
411
  }
412
413
  public void format_bold() {
414
    getActiveTextEditor().bold();
415
  }
416
417
  public void format_italic() {
418
    getActiveTextEditor().italic();
419
  }
420
421
  public void format_monospace() {
422
    getActiveTextEditor().monospace();
423
  }
424
425
  public void format_superscript() {
426
    getActiveTextEditor().superscript();
427
  }
428
429
  public void format_subscript() {
430
    getActiveTextEditor().subscript();
431
  }
432
433
  public void format_strikethrough() {
434
    getActiveTextEditor().strikethrough();
435
  }
436
437
  public void insert_blockquote() {
438
    getActiveTextEditor().blockquote();
439
  }
440
441
  public void insert_code() {
442
    getActiveTextEditor().code();
443
  }
444
445
  public void insert_fenced_code_block() {
446
    getActiveTextEditor().fencedCodeBlock();
447
  }
448
449
  public void insert_link() {
450
    insertObject( createLinkDialog() );
451
  }
452
453
  public void insert_image() {
454
    insertObject( createImageDialog() );
455
  }
456
457
  private void insertObject( final Dialog<String> dialog ) {
458
    final var textArea = getActiveTextEditor().getTextArea();
459
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
460
  }
461
462
  private Dialog<String> createLinkDialog() {
463
    return new LinkDialog( getWindow(), createHyperlinkModel() );
464
  }
465
466
  private Dialog<String> createImageDialog() {
467
    final var path = getActiveTextEditor().getPath();
468
    final var parentDir = path.getParent();
469
    return new ImageDialog( getWindow(), parentDir );
470
  }
471
472
  /**
473
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
474
   * the Markdown AST.
475
   *
476
   * @return An instance containing the link URL and display text.
477
   */
478
  private HyperlinkModel createHyperlinkModel() {
479
    final var context = getMainPane().createProcessorContext();
480
    final var editor = getActiveTextEditor();
481
    final var textArea = editor.getTextArea();
482
    final var selectedText = textArea.getSelectedText();
483
484
    // Convert current paragraph to Markdown nodes.
485
    final var mp = MarkdownProcessor.create( context );
486
    final var p = textArea.getCurrentParagraph();
487
    final var paragraph = textArea.getText( p );
488
    final var node = mp.toNode( paragraph );
489
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
490
    final var link = visitor.process( node );
491
492
    if( link != null ) {
493
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
494
    }
495
496
    return createHyperlinkModel( link, selectedText );
497
  }
498
499
  private HyperlinkModel createHyperlinkModel(
500
    final Link link, final String selection ) {
501
502
    return link == null
503
      ? new HyperlinkModel( selection, "https://localhost" )
504
      : new HyperlinkModel( link );
505
  }
506
507
  public void insert_heading_1() {
508
    insert_heading( 1 );
509
  }
510
511
  public void insert_heading_2() {
512
    insert_heading( 2 );
513
  }
514
515
  public void insert_heading_3() {
516
    insert_heading( 3 );
517
  }
518
519
  private void insert_heading( final int level ) {
520
    getActiveTextEditor().heading( level );
521
  }
522
523
  public void insert_unordered_list() {
524
    getActiveTextEditor().unorderedList();
525
  }
526
527
  public void insert_ordered_list() {
528
    getActiveTextEditor().orderedList();
529
  }
530
531
  public void insert_horizontal_rule() {
532
    getActiveTextEditor().horizontalRule();
533
  }
534
535
  public void definition_create() {
536
    getActiveTextDefinition().createDefinition();
537
  }
538
539
  public void definition_rename() {
540
    getActiveTextDefinition().renameDefinition();
541
  }
542
543
  public void definition_delete() {
544
    getActiveTextDefinition().deleteDefinitions();
545
  }
546
547
  public void definition_autoinsert() {
548
    getMainPane().autoinsert();
549
  }
550
551
  public void view_refresh() {
552
    getMainPane().viewRefresh();
553
  }
554
555
  public void view_preview() {
556
    getMainPane().viewPreview();
557
  }
558
559
  public void view_outline() {
560
    getMainPane().viewOutline();
561
  }
562
563
  public void view_files() { getMainPane().viewFiles(); }
564
565
  public void view_statistics() {
566
    getMainPane().viewStatistics();
567
  }
568
569
  public void view_menubar() {
570
    getMainScene().toggleMenuBar();
571
  }
572
573
  public void view_toolbar() {
574
    getMainScene().toggleToolBar();
575
  }
576
577
  public void view_statusbar() {
578
    getMainScene().toggleStatusBar();
579
  }
580
581
  public void view_log() {
582
    mLogView.view();
583
  }
584
585
  public void help_about() {
586
    final var alert = new Alert( INFORMATION );
587
    final var prefix = "Dialog.about.";
588
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
589
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
590
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
591
    alert.setGraphic( ICON_DIALOG_NODE );
592
    alert.initOwner( getWindow() );
593
    alert.showAndWait();
594
  }
595
596
  private <T> void typeset( final Service<T> service ) {
597
    synchronized( mMutex ) {
598
      if( service != null && !service.isRunning() ) {
599
        service.reset();
600
        service.start();
601
      }
602
    }
603
  }
604
605
  /**
606
   * Concatenates all the files in the same directory as the given file into
607
   * a string. The extension is determined by the given file name pattern; the
608
   * order files are concatenated is based on their numeric sort order (this
609
   * avoids lexicographic sorting).
610
   * <p>
611
   * If the parent path to the file being edited in the text editor cannot
612
   * be found then this will return the editor's text, without iterating through
613
   * the parent directory. (Should never happen, but who knows?)
614
   * </p>
615
   * <p>
616
   * New lines are automatically appended to separate each file.
617
   * </p>
618
   *
619
   * @param editor The text editor containing
620
   * @return All files in the same directory as the file being edited
621
   * concatenated into a single string.
622
   */
623
  private String append( final TextEditor editor ) {
624
    final var pattern = editor.getPath();
625
    final var parent = pattern.getParent();
626
627
    // Short-circuit because nothing else can be done.
628
    if( parent == null ) {
629
      clue( "Main.status.export.concat.parent", pattern );
630
      return editor.getText();
631
    }
632
633
    final var filename = pattern.getFileName().toString();
14
import com.keenwrite.io.SysFile;
15
import com.keenwrite.preferences.Key;
16
import com.keenwrite.preferences.PreferencesController;
17
import com.keenwrite.preferences.Workspace;
18
import com.keenwrite.processors.markdown.MarkdownProcessor;
19
import com.keenwrite.search.SearchModel;
20
import com.keenwrite.typesetting.Typesetter;
21
import com.keenwrite.ui.controls.SearchBar;
22
import com.keenwrite.ui.dialogs.ExportDialog;
23
import com.keenwrite.ui.dialogs.ExportSettings;
24
import com.keenwrite.ui.dialogs.ImageDialog;
25
import com.keenwrite.ui.dialogs.LinkDialog;
26
import com.keenwrite.ui.explorer.FilePicker;
27
import com.keenwrite.ui.explorer.FilePickerFactory;
28
import com.keenwrite.ui.logging.LogView;
29
import com.vladsch.flexmark.ast.Link;
30
import javafx.concurrent.Service;
31
import javafx.concurrent.Task;
32
import javafx.scene.control.Alert;
33
import javafx.scene.control.Dialog;
34
import javafx.stage.Window;
35
import javafx.stage.WindowEvent;
36
37
import java.io.File;
38
import java.nio.file.Path;
39
import java.util.List;
40
import java.util.Optional;
41
42
import static com.keenwrite.Bootstrap.*;
43
import static com.keenwrite.ExportFormat.*;
44
import static com.keenwrite.Messages.get;
45
import static com.keenwrite.constants.Constants.PDF_DEFAULT;
46
import static com.keenwrite.constants.Constants.USER_DIRECTORY;
47
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
48
import static com.keenwrite.events.StatusEvent.clue;
49
import static com.keenwrite.preferences.AppKeys.*;
50
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
51
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType;
52
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
53
import static java.nio.file.Files.writeString;
54
import static javafx.application.Platform.runLater;
55
import static javafx.event.Event.fireEvent;
56
import static javafx.scene.control.Alert.AlertType.INFORMATION;
57
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
58
import static org.apache.commons.io.FilenameUtils.getExtension;
59
60
/**
61
 * Responsible for abstracting how functionality is mapped to the application.
62
 * This allows users to customize accelerator keys and will provide pluggable
63
 * functionality so that different text markup languages can change documents
64
 * using their respective syntax.
65
 */
66
public final class GuiCommands {
67
  private static final String STYLE_SEARCH = "search";
68
69
  /**
70
   * When an action is executed, this is one of the recipients.
71
   */
72
  private final MainPane mMainPane;
73
74
  private final MainScene mMainScene;
75
76
  private final LogView mLogView;
77
78
  /**
79
   * Tracks finding text in the active document.
80
   */
81
  private final SearchModel mSearchModel;
82
83
  private boolean mCanTypeset;
84
85
  /**
86
   * A {@link Task} can only be run once, so wrap it in a {@link Service} to
87
   * allow re-launching the typesetting task repeatedly.
88
   */
89
  private Service<Path> mTypesetService;
90
91
  /**
92
   * Prevent a race-condition between checking to see if the typesetting task
93
   * is running and restarting the task itself.
94
   */
95
  private final Object mMutex = new Object();
96
97
  public GuiCommands( final MainScene scene, final MainPane pane ) {
98
    mMainScene = scene;
99
    mMainPane = pane;
100
    mLogView = new LogView();
101
    mSearchModel = new SearchModel();
102
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
103
      final var editor = getActiveTextEditor();
104
105
      // Clear highlighted areas before highlighting a new region.
106
      if( o != null ) {
107
        editor.unstylize( STYLE_SEARCH );
108
      }
109
110
      if( n != null ) {
111
        editor.moveTo( n.getStart() );
112
        editor.stylize( n, STYLE_SEARCH );
113
      }
114
    } );
115
116
    // When the active text editor changes ...
117
    mMainPane.textEditorProperty().addListener(
118
      ( c, o, n ) -> {
119
        // ... update the haystack.
120
        mSearchModel.search( getActiveTextEditor().getText() );
121
122
        // ... update the status bar with the current caret position.
123
        if( n != null ) {
124
          final var w = getWorkspace();
125
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
126
127
          // ... preserve the most recent document.
128
          recentDoc.setValue( n.getFile() );
129
          CaretMovedEvent.fire( n.getCaret() );
130
        }
131
      }
132
    );
133
  }
134
135
  public void file_new() {
136
    getMainPane().newTextEditor();
137
  }
138
139
  public void file_open() {
140
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
141
  }
142
143
  public void file_close() {
144
    getMainPane().close();
145
  }
146
147
  public void file_close_all() {
148
    getMainPane().closeAll();
149
  }
150
151
  public void file_save() {
152
    getMainPane().save();
153
  }
154
155
  public void file_save_as() {
156
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
157
  }
158
159
  public void file_save_all() {
160
    getMainPane().saveAll();
161
  }
162
163
  /**
164
   * Converts the actively edited file in the given file format.
165
   *
166
   * @param format The destination file format.
167
   */
168
  private void file_export( final ExportFormat format ) {
169
    file_export( format, false );
170
  }
171
172
  /**
173
   * Converts one or more files into the given file format. If {@code dir}
174
   * is set to true, this will first append all files in the same directory
175
   * as the actively edited file.
176
   *
177
   * @param format The destination file format.
178
   * @param dir    Export all files in the actively edited file's directory.
179
   */
180
  private void file_export( final ExportFormat format, final boolean dir ) {
181
    final var editor = getMainPane().getTextEditor();
182
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
183
    final var exportParent = exported.get().toPath().getParent();
184
    final var editorParent = editor.getPath().getParent();
185
    final var userHomeParent = USER_DIRECTORY.toPath();
186
    final var exportPath = exportParent != null
187
      ? exportParent
188
      : editorParent != null
189
      ? editorParent
190
      : userHomeParent;
191
192
    final var filename = format.toExportFilename( editor.getPath() );
193
    final var selected = PDF_DEFAULT
194
      .getName()
195
      .equals( exported.get().getName() );
196
    final var selection = pickFile(
197
      selected
198
        ? filename
199
        : exported.get(),
200
      exportPath,
201
      FILE_EXPORT
202
    );
203
204
    selection.ifPresent( files -> file_export( editor, format, files, dir ) );
205
  }
206
207
  private void file_export(
208
    final TextEditor editor,
209
    final ExportFormat format,
210
    final List<File> files,
211
    final boolean dir ) {
212
    editor.save();
213
    final var main = getMainPane();
214
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
215
216
    final var sourceFile = files.get( 0 );
217
    final var sourcePath = sourceFile.toPath();
218
    final var document = dir ? append( editor ) : editor.getText();
219
    final var context = main.createProcessorContext( sourcePath, format );
220
221
    final var service = new Service<Path>() {
222
      @Override
223
      protected Task<Path> createTask() {
224
        final var task = new Task<Path>() {
225
          @Override
226
          protected Path call() throws Exception {
227
            final var chain = createProcessors( context );
228
            final var export = chain.apply( document );
229
230
            // Processors can export binary files. In such cases, processors
231
            // return null to prevent further processing.
232
            return export == null ? null : writeString( sourcePath, export );
233
          }
234
        };
235
236
        task.setOnSucceeded(
237
          e -> {
238
            // Remember the exported file name for next time.
239
            exported.setValue( sourceFile );
240
241
            final var result = task.getValue();
242
243
            // Binary formats must notify users of success independently.
244
            if( result != null ) {
245
              clue( "Main.status.export.success", result );
246
            }
247
          }
248
        );
249
250
        task.setOnFailed( e -> {
251
          final var ex = task.getException();
252
          clue( ex );
253
254
          if( ex instanceof TypeNotPresentException ) {
255
            fireExportFailedEvent();
256
          }
257
        } );
258
259
        return task;
260
      }
261
    };
262
263
    mTypesetService = service;
264
    typeset( service );
265
  }
266
267
  /**
268
   * @param dir {@code true} means to export all files in the active file
269
   *            editor's directory; {@code false} means to export only the
270
   *            actively edited file.
271
   */
272
  private void file_export_pdf( final boolean dir ) {
273
    final var workspace = getWorkspace();
274
    final var themes = workspace.getFile(
275
      KEY_TYPESET_CONTEXT_THEMES_PATH
276
    );
277
    final var theme = workspace.stringProperty(
278
      KEY_TYPESET_CONTEXT_THEME_SELECTION
279
    );
280
    final var chapters = workspace.stringProperty(
281
      KEY_TYPESET_CONTEXT_CHAPTERS
282
    );
283
    final var settings = ExportSettings
284
      .builder()
285
      .with( ExportSettings.Mutator::setTheme, theme )
286
      .with( ExportSettings.Mutator::setChapters, chapters )
287
      .build();
288
289
    // Don't re-validate the typesetter installation each time. If the
290
    // user mucks up the typesetter installation, it'll get caught the
291
    // next time the application is started. Don't use |= because it
292
    // won't short-circuit.
293
    mCanTypeset = mCanTypeset || Typesetter.canRun();
294
295
    if( mCanTypeset ) {
296
      // If the typesetter is installed, allow the user to select a theme. If
297
      // the themes aren't installed, a status message will appear.
298
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
299
        file_export( APPLICATION_PDF, dir );
300
      }
301
    }
302
    else {
303
      fireExportFailedEvent();
304
    }
305
  }
306
307
  public void file_export_pdf() {
308
    file_export_pdf( false );
309
  }
310
311
  public void file_export_pdf_dir() {
312
    file_export_pdf( true );
313
  }
314
315
  public void file_export_html_dir() {
316
    file_export( XHTML_TEX, true );
317
  }
318
319
  public void file_export_repeat() {
320
    typeset( mTypesetService );
321
  }
322
323
  public void file_export_html_svg() {
324
    file_export( HTML_TEX_SVG );
325
  }
326
327
  public void file_export_html_tex() {
328
    file_export( HTML_TEX_DELIMITED );
329
  }
330
331
  public void file_export_xhtml_tex() {
332
    file_export( XHTML_TEX );
333
  }
334
335
  private void fireExportFailedEvent() {
336
    runLater( ExportFailedEvent::fire );
337
  }
338
339
  public void file_exit() {
340
    final var window = getWindow();
341
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
342
  }
343
344
  public void edit_undo() {
345
    getActiveTextEditor().undo();
346
  }
347
348
  public void edit_redo() {
349
    getActiveTextEditor().redo();
350
  }
351
352
  public void edit_cut() {
353
    getActiveTextEditor().cut();
354
  }
355
356
  public void edit_copy() {
357
    getActiveTextEditor().copy();
358
  }
359
360
  public void edit_paste() {
361
    getActiveTextEditor().paste();
362
  }
363
364
  public void edit_select_all() {
365
    getActiveTextEditor().selectAll();
366
  }
367
368
  public void edit_find() {
369
    final var nodes = getMainScene().getStatusBar().getLeftItems();
370
371
    if( nodes.isEmpty() ) {
372
      final var searchBar = new SearchBar();
373
374
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
375
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
376
377
      searchBar.setOnCancelAction( event -> {
378
        final var editor = getActiveTextEditor();
379
        nodes.remove( searchBar );
380
        editor.unstylize( STYLE_SEARCH );
381
        editor.getNode().requestFocus();
382
      } );
383
384
      searchBar.addInputListener( ( c, o, n ) -> {
385
        if( n != null && !n.isEmpty() ) {
386
          mSearchModel.search( n, getActiveTextEditor().getText() );
387
        }
388
      } );
389
390
      searchBar.setOnNextAction( event -> edit_find_next() );
391
      searchBar.setOnPrevAction( event -> edit_find_prev() );
392
393
      nodes.add( searchBar );
394
      searchBar.requestFocus();
395
    }
396
  }
397
398
  public void edit_find_next() {
399
    mSearchModel.advance();
400
  }
401
402
  public void edit_find_prev() {
403
    mSearchModel.retreat();
404
  }
405
406
  public void edit_preferences() {
407
    try {
408
      new PreferencesController( getWorkspace() ).show();
409
    } catch( final Exception ex ) {
410
      clue( ex );
411
    }
412
  }
413
414
  public void format_bold() {
415
    getActiveTextEditor().bold();
416
  }
417
418
  public void format_italic() {
419
    getActiveTextEditor().italic();
420
  }
421
422
  public void format_monospace() {
423
    getActiveTextEditor().monospace();
424
  }
425
426
  public void format_superscript() {
427
    getActiveTextEditor().superscript();
428
  }
429
430
  public void format_subscript() {
431
    getActiveTextEditor().subscript();
432
  }
433
434
  public void format_strikethrough() {
435
    getActiveTextEditor().strikethrough();
436
  }
437
438
  public void insert_blockquote() {
439
    getActiveTextEditor().blockquote();
440
  }
441
442
  public void insert_code() {
443
    getActiveTextEditor().code();
444
  }
445
446
  public void insert_fenced_code_block() {
447
    getActiveTextEditor().fencedCodeBlock();
448
  }
449
450
  public void insert_link() {
451
    insertObject( createLinkDialog() );
452
  }
453
454
  public void insert_image() {
455
    insertObject( createImageDialog() );
456
  }
457
458
  private void insertObject( final Dialog<String> dialog ) {
459
    final var textArea = getActiveTextEditor().getTextArea();
460
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
461
  }
462
463
  private Dialog<String> createLinkDialog() {
464
    return new LinkDialog( getWindow(), createHyperlinkModel() );
465
  }
466
467
  private Dialog<String> createImageDialog() {
468
    final var path = getActiveTextEditor().getPath();
469
    final var parentDir = path.getParent();
470
    return new ImageDialog( getWindow(), parentDir );
471
  }
472
473
  /**
474
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
475
   * the Markdown AST.
476
   *
477
   * @return An instance containing the link URL and display text.
478
   */
479
  private HyperlinkModel createHyperlinkModel() {
480
    final var context = getMainPane().createProcessorContext();
481
    final var editor = getActiveTextEditor();
482
    final var textArea = editor.getTextArea();
483
    final var selectedText = textArea.getSelectedText();
484
485
    // Convert current paragraph to Markdown nodes.
486
    final var mp = MarkdownProcessor.create( context );
487
    final var p = textArea.getCurrentParagraph();
488
    final var paragraph = textArea.getText( p );
489
    final var node = mp.toNode( paragraph );
490
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
491
    final var link = visitor.process( node );
492
493
    if( link != null ) {
494
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
495
    }
496
497
    return createHyperlinkModel( link, selectedText );
498
  }
499
500
  private HyperlinkModel createHyperlinkModel(
501
    final Link link, final String selection ) {
502
503
    return link == null
504
      ? new HyperlinkModel( selection, "https://localhost" )
505
      : new HyperlinkModel( link );
506
  }
507
508
  public void insert_heading_1() {
509
    insert_heading( 1 );
510
  }
511
512
  public void insert_heading_2() {
513
    insert_heading( 2 );
514
  }
515
516
  public void insert_heading_3() {
517
    insert_heading( 3 );
518
  }
519
520
  private void insert_heading( final int level ) {
521
    getActiveTextEditor().heading( level );
522
  }
523
524
  public void insert_unordered_list() {
525
    getActiveTextEditor().unorderedList();
526
  }
527
528
  public void insert_ordered_list() {
529
    getActiveTextEditor().orderedList();
530
  }
531
532
  public void insert_horizontal_rule() {
533
    getActiveTextEditor().horizontalRule();
534
  }
535
536
  public void definition_create() {
537
    getActiveTextDefinition().createDefinition();
538
  }
539
540
  public void definition_rename() {
541
    getActiveTextDefinition().renameDefinition();
542
  }
543
544
  public void definition_delete() {
545
    getActiveTextDefinition().deleteDefinitions();
546
  }
547
548
  public void definition_autoinsert() {
549
    getMainPane().autoinsert();
550
  }
551
552
  public void view_refresh() {
553
    getMainPane().viewRefresh();
554
  }
555
556
  public void view_preview() {
557
    getMainPane().viewPreview();
558
  }
559
560
  public void view_outline() {
561
    getMainPane().viewOutline();
562
  }
563
564
  public void view_files() { getMainPane().viewFiles(); }
565
566
  public void view_statistics() {
567
    getMainPane().viewStatistics();
568
  }
569
570
  public void view_menubar() {
571
    getMainScene().toggleMenuBar();
572
  }
573
574
  public void view_toolbar() {
575
    getMainScene().toggleToolBar();
576
  }
577
578
  public void view_statusbar() {
579
    getMainScene().toggleStatusBar();
580
  }
581
582
  public void view_log() {
583
    mLogView.view();
584
  }
585
586
  public void help_about() {
587
    final var alert = new Alert( INFORMATION );
588
    final var prefix = "Dialog.about.";
589
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
590
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
591
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
592
    alert.setGraphic( ICON_DIALOG_NODE );
593
    alert.initOwner( getWindow() );
594
    alert.showAndWait();
595
  }
596
597
  private <T> void typeset( final Service<T> service ) {
598
    synchronized( mMutex ) {
599
      if( service != null && !service.isRunning() ) {
600
        service.reset();
601
        service.start();
602
      }
603
    }
604
  }
605
606
  /**
607
   * Concatenates all the files in the same directory as the given file into
608
   * a string. The extension is determined by the given file name pattern; the
609
   * order files are concatenated is based on their numeric sort order (this
610
   * avoids lexicographic sorting).
611
   * <p>
612
   * If the parent path to the file being edited in the text editor cannot
613
   * be found then this will return the editor's text, without iterating through
614
   * the parent directory. (Should never happen, but who knows?)
615
   * </p>
616
   * <p>
617
   * New lines are automatically appended to separate each file.
618
   * </p>
619
   *
620
   * @param editor The text editor containing
621
   * @return All files in the same directory as the file being edited
622
   * concatenated into a single string.
623
   */
624
  private String append( final TextEditor editor ) {
625
    final var pattern = editor.getPath();
626
    final var parent = pattern.getParent();
627
628
    // Short-circuit because nothing else can be done.
629
    if( parent == null ) {
630
      clue( "Main.status.export.concat.parent", pattern );
631
      return editor.getText();
632
    }
633
634
    final var filename = SysFile.getFileName( pattern );
634635
    final var extension = getExtension( filename );
635636
M src/main/java/com/keenwrite/ui/dialogs/AbstractDialog.java
3535
    initDialogButtons();
3636
    initComponents();
37
    initIcon( (Stage) owner );
37
38
    if( owner instanceof Stage stage ) {
39
      initIcon( stage );
40
    }
3841
  }
3942
M src/main/java/com/keenwrite/ui/dialogs/ExportDialog.java
33
44
import com.keenwrite.events.ExportFailedEvent;
5
import com.keenwrite.io.SysFile;
56
import com.keenwrite.util.Diacritics;
67
import com.keenwrite.util.FileWalker;
...
6162
     */
6263
    public boolean matches( final String themeDir ) {
63
      final var path = path().getFileName().toString();
64
      final var f = SysFile.getFileName( path() );
6465
65
      return path.equalsIgnoreCase( Diacritics.remove( themeDir ) );
66
      return f.equalsIgnoreCase( Diacritics.remove( themeDir ) );
6667
    }
6768
...
7879
    @Override
7980
    public int compareTo( final Theme o ) {
81
      assert o != null;
82
8083
      return name().compareTo( o.name() );
8184
    }
...
191194
          final var theme = mComboBox.getSelectionModel().getSelectedItem();
192195
          final var path = theme.path();
193
          final var filename = path.getFileName().toString();
196
          final var filename = SysFile.getFileName( path.getFileName() );
197
194198
          mSettings.themeProperty().setValue( filename );
195199
M src/main/java/com/keenwrite/ui/explorer/FilesView.java
33
44
import com.keenwrite.events.FileOpenEvent;
5
import com.keenwrite.io.SysFile;
56
import com.keenwrite.ui.controls.BrowseButton;
67
import javafx.beans.property.*;
...
2122
import java.util.List;
2223
import java.util.Locale;
23
import java.util.Objects;
2424
import java.util.Optional;
2525
...
9191
9292
  @Override
93
  public void setInitialFilename( final File file ) {
94
  }
93
  public void setInitialFilename( final File file ) { }
9594
9695
  @Override
...
109108
        }
110109
111
        for( final var f : Objects.requireNonNull( directory.list() ) ) {
112
          if( !f.startsWith( "." ) ) {
113
            mItems.add( pathEntry( Paths.get( directory.toString(), f ) ) );
110
        final var list = directory.list();
111
112
        if( list != null ) {
113
          for( final var f : list ) {
114
            if( !f.startsWith( "." ) ) {
115
              mItems.add( pathEntry( Paths.get( directory.toString(), f ) ) );
116
            }
114117
          }
115118
        }
...
266269
      this(
267270
        path,
268
        path.getFileName().toString(),
271
        SysFile.getFileName( path ),
269272
        size( path ),
270273
        ofEpochMilli( path.toFile().lastModified() )
M src/main/java/com/keenwrite/ui/fonts/IconFactory.java
44
import com.keenwrite.io.MediaType;
55
import com.keenwrite.io.MediaTypeExtension;
6
import com.keenwrite.io.SysFile;
67
import javafx.scene.Node;
78
import javafx.scene.image.Image;
...
8384
  public static ImageView createFileIcon( final Path path ) throws IOException {
8485
    final var attrs = readAttributes( path, BasicFileAttributes.class );
85
    final var filename = path.getFileName().toString();
86
    final var filename = SysFile.getFileName( path );
8687
    String extension;
8788
...
189190
   * create an icon for display.
190191
   */
191
  private IconFactory() {}
192
  private IconFactory() { }
192193
}
193194
M src/main/java/com/keenwrite/ui/logging/LogView.java
133133
      switch( t.getCode() ) {
134134
        case ENTER, ESCAPE -> buttonOk.fire();
135
        default -> { }
135136
      }
136137
    } );
M src/main/java/com/keenwrite/util/AlphanumComparator.java
3030
package com.keenwrite.util;
3131
32
import java.io.Serializable;
3233
import java.util.Comparator;
3334
...
4344
 * </p>
4445
 */
45
public final class AlphanumComparator<T> implements Comparator<T> {
46
public final class AlphanumComparator<T> implements
47
  Comparator<T>, Serializable {
48
4649
  /**
4750
   * Returns a chunk of text that is continuous with respect to digits or
M src/main/java/com/keenwrite/util/CyclicIterator.java
22
package com.keenwrite.util;
33
4
import java.util.List;
5
import java.util.ListIterator;
6
import java.util.NoSuchElementException;
4
import java.util.*;
75
86
/**
...
3432
   * @param list The list to cycle through indefinitely.
3533
   */
36
  public CyclicIterator( final List<T> list ) {
37
    mList = list;
34
  public CyclicIterator( final Collection<T> list ) {
35
    mList = new ArrayList<>( list );
3836
  }
3937
M src/main/java/com/keenwrite/util/DataTypeConverter.java
4444
  public static byte[] hash( final String s ) throws NoSuchAlgorithmException {
4545
    final var digest = MessageDigest.getInstance( "SHA-1" );
46
    return digest.digest( s.getBytes() );
46
    return digest.digest( s.getBytes( UTF_8 ) );
4747
  }
4848
}
M src/main/resources/com/keenwrite/messages.properties
155155
156156
Main.status.error.bootstrap.eval=Note: Bootstrap variable of ''{0}'' not found
157
Main.status.error.bootstrap.cache=Could not create cache directory ''{0}''
157158
158159
Main.status.error.parse=Evaluation error: {0}
159160
Main.status.error.def.blank=Move the caret to a word before inserting a variable
160161
Main.status.error.def.empty=Create a variable before inserting one
161162
Main.status.error.def.missing=No variable value found for ''{0}''
162163
Main.status.error.r=Error with [{0}...]: {1}
163164
Main.status.error.file.missing=Not found: ''{0}''
164165
Main.status.error.file.missing.near=Not found: ''{0}'' near line {1}
166
Main.status.error.file.delete=Failed to delete ''{0}''
165167
166168
Main.status.error.messages.recursion=Lookup depth exceeded, check for loops in ''{0}''
M src/test/java/com/keenwrite/io/MediaTypeTest.java
6363
        assertEquals( v, response.getMediaType() );
6464
      } catch( final Exception e ) {
65
        fail();
65
        throw new RuntimeException( e );
6666
      }
6767
    } );
M src/test/java/com/keenwrite/io/downloads/DownloadManagerTest.java
44
import org.junit.jupiter.api.Test;
55
6
import java.io.File;
67
import java.io.IOException;
7
import java.io.OutputStream;
8
import java.net.URISyntaxException;
89
import java.util.concurrent.ExecutionException;
910
import java.util.concurrent.Executors;
1011
import java.util.concurrent.atomic.AtomicInteger;
1112
import java.util.concurrent.atomic.AtomicLong;
1213
1314
import static com.keenwrite.io.downloads.DownloadManager.ProgressListener;
1415
import static com.keenwrite.io.downloads.DownloadManager.open;
15
import static java.io.OutputStream.nullOutputStream;
1616
import static java.lang.System.setProperty;
1717
import static org.junit.jupiter.api.Assertions.*;
...
3030
  @Test
3131
  void test_Async_DownloadRequested_DownloadCompletes()
32
    throws IOException, InterruptedException, ExecutionException {
32
    throws IOException, InterruptedException,
33
    ExecutionException, URISyntaxException {
3334
    final var complete = new AtomicInteger();
3435
    final var transferred = new AtomicLong();
3536
36
    final OutputStream output = nullOutputStream();
3737
    final ProgressListener listener = ( percentage, bytes ) -> {
3838
      complete.set( percentage );
3939
      transferred.set( bytes );
4040
    };
41
42
    final var file = File.createTempFile("kw-", "test");
43
    file.deleteOnExit();
4144
4245
    final var token = open( URL );
4346
    final var executor = Executors.newFixedThreadPool( 1 );
44
    final var result = token.download( output, listener );
47
    final var result = token.download( file, listener );
4548
    final var future = executor.submit( result );
4649
4750
    assertFalse( future.isDone() );
4851
    assertTrue( complete.get() < 100 );
49
    assertTrue( transferred.get() > 100_000 );
50
51
    future.get();
52
52
    assertNull( future.get() );
53
    assertTrue( future.isDone() );
5354
    assertEquals( 100, complete.get() );
55
    assertTrue( transferred.get() > 100_000 );
5456
5557
    token.close();
M src/test/java/com/keenwrite/processors/html/XhtmlProcessorTest.java
7272
        XHTML_TEX,
7373
        """
74
          <html><head></head><body><p>the 👍 emoji</p>
74
          <html><head><title/><meta content="2" name="count"/></head><body><p>the 👍 emoji</p>
7575
          </body></html>"""
7676
      )
M src/test/java/com/keenwrite/processors/markdown/ImageLinkExtensionTest.java
7373
    final var subpath = resource.subpath( 0, subpaths );
7474
75
    final var root = resource.getRoot();
76
    assertNotNull( root );
77
78
    final var resolved = root.resolve( subpath );
79
    final var doc = resolved.toString();
80
7581
    // The root component isn't considered part of the path, so add it back.
76
    final var documentPath = Path.of(
77
      resource.getRoot().resolve( subpath ).toString(),
78
      DOCUMENT_DEFAULT.getName() );
82
    final var documentPath = Path.of( doc, DOCUMENT_DEFAULT.getName() );
7983
    final var imagesDir = Path.of( "images" );
8084
    final var context = createProcessorContext( documentPath, imagesDir );