Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M src/main/java/com/keenwrite/dom/DocumentParser.java
2525
import static java.nio.charset.StandardCharsets.UTF_16;
2626
import static java.nio.charset.StandardCharsets.UTF_8;
27
import static java.nio.file.Files.write;
2728
import static javax.xml.transform.OutputKeys.*;
2829
import static javax.xml.xpath.XPathConstants.NODESET;
...
214215
    assert path != null;
215216
216
    final var file = path.toFile();
217
    final var writer = new ByteArrayOutputStream( 65536 );
218
    final var output = new OutputStreamWriter( writer );
219
    final var target = new StreamResult( output );
220
    final var source = sDocumentBuilder.parse( path.toFile() );
217221
218
    transform( sDocumentBuilder.parse( file ), new StreamResult( file ) );
222
    transform( source, target );
223
    output.close();
224
    write( path, writer.toByteArray() );
225
    writer.close();
219226
  }
220227
M src/main/java/com/keenwrite/io/HttpFacade.java
8181
        conn.setRequestProperty( "User-Agent", getProperty( "http.agent" ) );
8282
        conn.setRequestMethod( "GET" );
83
        conn.setConnectTimeout( 15000 );
83
        conn.setConnectTimeout( 30000 );
8484
        conn.setRequestProperty( "connection", "close" );
8585
        conn.connect();
M src/main/java/com/keenwrite/processors/XhtmlProcessor.java
188188
      }
189189
190
      // Strip comments, superfluous whitespace, DOCTYPE, and XML
191
      // declarations.
190
      // Strip comments, superfluous whitespace, DOCTYPE, and XML declarations.
192191
      if( mediaType.isSvg() ) {
193192
        DocumentParser.sanitize( imageFile );
M src/main/java/com/keenwrite/processors/markdown/extensions/fences/FencedBlockExtension.java
140140
        FencedCodeBlock.class, ( node, context, html ) -> {
141141
        final var style = sanitize( node.getInfo() );
142
143
        Tuple<String, ResolvedLink> imagePair;
142
        final Tuple<String, ResolvedLink> imagePair;
144143
145144
        if( style.startsWith( STYLE_DIAGRAM ) ) {
M src/main/java/com/keenwrite/ui/actions/GuiCommands.java
5454
import static com.keenwrite.ui.explorer.FilePickerFactory.SelectionType.*;
5555
import static com.keenwrite.util.FileWalker.walk;
56
import static java.nio.file.Files.readString;
57
import static java.nio.file.Files.writeString;
58
import static java.util.concurrent.Executors.newFixedThreadPool;
59
import static javafx.application.Platform.runLater;
60
import static javafx.event.Event.fireEvent;
61
import static javafx.scene.control.Alert.AlertType.INFORMATION;
62
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
63
import static org.apache.commons.io.FilenameUtils.getExtension;
64
65
/**
66
 * Responsible for abstracting how functionality is mapped to the application.
67
 * This allows users to customize accelerator keys and will provide pluggable
68
 * functionality so that different text markup languages can change documents
69
 * using their respective syntax.
70
 */
71
public final class GuiCommands {
72
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
73
74
  private static final String STYLE_SEARCH = "search";
75
76
  /**
77
   * Sci-fi genres, which are can be longer than other genres, typically fall
78
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
79
   * memory when concatenating files together when exporting novels.
80
   */
81
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
82
83
  /**
84
   * When an action is executed, this is one of the recipients.
85
   */
86
  private final MainPane mMainPane;
87
88
  private final MainScene mMainScene;
89
90
  private final LogView mLogView;
91
92
  /**
93
   * Tracks finding text in the active document.
94
   */
95
  private final SearchModel mSearchModel;
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 main = getMainPane();
182
    final var editor = main.getTextEditor();
183
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
184
    final var filename = format.toExportFilename( editor.getPath() );
185
    final var selected = PDF_DEFAULT.getName().equals( exported.get().getName() );
186
    final var selection = pickFile(
187
      selected ? filename : exported.get(),
188
      exported.get().toPath().getParent(),
189
      FILE_EXPORT
190
    );
191
192
    selection.ifPresent( files -> {
193
      editor.save();
194
195
      final var file = files.get( 0 );
196
      final var path = file.toPath();
197
      final var document = dir ? append( editor ) : editor.getText();
198
      final var context = main.createProcessorContext( path, format );
199
200
      final var task = new Task<Path>() {
201
        @Override
202
        protected Path call() throws Exception {
203
          final var chain = createProcessors( context );
204
          final var export = chain.apply( document );
205
206
          // Processors can export binary files. In such cases, processors
207
          // return null to prevent further processing.
208
          return export == null ? null : writeString( path, export );
209
        }
210
      };
211
212
      task.setOnSucceeded(
213
        e -> {
214
          // Remember the exported file name for next time.
215
          exported.setValue( file );
216
217
          final var result = task.getValue();
218
219
          // Binary formats must notify users of success independently.
220
          if( result != null ) {
221
            clue( "Main.status.export.success", result );
222
          }
223
        }
224
      );
225
226
      task.setOnFailed( e -> {
227
        final var ex = task.getException();
228
        clue( ex );
229
230
        if( ex instanceof TypeNotPresentException ) {
231
          fireExportFailedEvent();
232
        }
233
      } );
234
235
      sExecutor.execute( task );
236
    } );
237
  }
238
239
  /**
240
   * @param dir {@code true} means to export all files in the active file
241
   *            editor's directory; {@code false} means to export only the
242
   *            actively edited file.
243
   */
244
  private void file_export_pdf( final boolean dir ) {
245
    final var workspace = getWorkspace();
246
    final var themes = workspace.getFile(
247
      KEY_TYPESET_CONTEXT_THEMES_PATH
248
    );
249
    final var theme = workspace.stringProperty(
250
      KEY_TYPESET_CONTEXT_THEME_SELECTION
251
    );
252
    final var chapters = workspace.stringProperty(
253
      KEY_TYPESET_CONTEXT_CHAPTERS
254
    );
255
    final var settings = ExportSettings
256
      .builder()
257
      .with( ExportSettings.Mutator::setTheme, theme )
258
      .with( ExportSettings.Mutator::setChapters, chapters )
259
      .build();
260
261
    if( Typesetter.canRun() ) {
262
      // If the typesetter is installed, allow the user to select a theme. If
263
      // the themes aren't installed, a status message will appear.
264
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
265
        file_export( APPLICATION_PDF, dir );
266
      }
267
    }
268
    else {
269
      fireExportFailedEvent();
270
    }
271
  }
272
273
  public void file_export_pdf() {
274
    file_export_pdf( false );
275
  }
276
277
  public void file_export_pdf_dir() {
278
    file_export_pdf( true );
279
  }
280
281
  public void file_export_html_svg() {
282
    file_export( HTML_TEX_SVG );
283
  }
284
285
  public void file_export_html_tex() {
286
    file_export( HTML_TEX_DELIMITED );
287
  }
288
289
  public void file_export_xhtml_tex() {
290
    file_export( XHTML_TEX );
291
  }
292
293
  private void fireExportFailedEvent() {
294
    runLater( ExportFailedEvent::fire );
295
  }
296
297
  public void file_exit() {
298
    final var window = getWindow();
299
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
300
  }
301
302
  public void edit_undo() {
303
    getActiveTextEditor().undo();
304
  }
305
306
  public void edit_redo() {
307
    getActiveTextEditor().redo();
308
  }
309
310
  public void edit_cut() {
311
    getActiveTextEditor().cut();
312
  }
313
314
  public void edit_copy() {
315
    getActiveTextEditor().copy();
316
  }
317
318
  public void edit_paste() {
319
    getActiveTextEditor().paste();
320
  }
321
322
  public void edit_select_all() {
323
    getActiveTextEditor().selectAll();
324
  }
325
326
  public void edit_find() {
327
    final var nodes = getMainScene().getStatusBar().getLeftItems();
328
329
    if( nodes.isEmpty() ) {
330
      final var searchBar = new SearchBar();
331
332
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
333
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
334
335
      searchBar.setOnCancelAction( event -> {
336
        final var editor = getActiveTextEditor();
337
        nodes.remove( searchBar );
338
        editor.unstylize( STYLE_SEARCH );
339
        editor.getNode().requestFocus();
340
      } );
341
342
      searchBar.addInputListener( ( c, o, n ) -> {
343
        if( n != null && !n.isEmpty() ) {
344
          mSearchModel.search( n, getActiveTextEditor().getText() );
345
        }
346
      } );
347
348
      searchBar.setOnNextAction( event -> edit_find_next() );
349
      searchBar.setOnPrevAction( event -> edit_find_prev() );
350
351
      nodes.add( searchBar );
352
      searchBar.requestFocus();
353
    }
354
    else {
355
      nodes.clear();
356
    }
357
  }
358
359
  public void edit_find_next() {
360
    mSearchModel.advance();
361
  }
362
363
  public void edit_find_prev() {
364
    mSearchModel.retreat();
365
  }
366
367
  public void edit_preferences() {
368
    try {
369
      new PreferencesController( getWorkspace() ).show();
370
    } catch( final Exception ex ) {
371
      clue( ex );
372
    }
373
  }
374
375
  public void format_bold() {
376
    getActiveTextEditor().bold();
377
  }
378
379
  public void format_italic() {
380
    getActiveTextEditor().italic();
381
  }
382
383
  public void format_monospace() {
384
    getActiveTextEditor().monospace();
385
  }
386
387
  public void format_superscript() {
388
    getActiveTextEditor().superscript();
389
  }
390
391
  public void format_subscript() {
392
    getActiveTextEditor().subscript();
393
  }
394
395
  public void format_strikethrough() {
396
    getActiveTextEditor().strikethrough();
397
  }
398
399
  public void insert_blockquote() {
400
    getActiveTextEditor().blockquote();
401
  }
402
403
  public void insert_code() {
404
    getActiveTextEditor().code();
405
  }
406
407
  public void insert_fenced_code_block() {
408
    getActiveTextEditor().fencedCodeBlock();
409
  }
410
411
  public void insert_link() {
412
    insertObject( createLinkDialog() );
413
  }
414
415
  public void insert_image() {
416
    insertObject( createImageDialog() );
417
  }
418
419
  private void insertObject( final Dialog<String> dialog ) {
420
    final var textArea = getActiveTextEditor().getTextArea();
421
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
422
  }
423
424
  private Dialog<String> createLinkDialog() {
425
    return new LinkDialog( getWindow(), createHyperlinkModel() );
426
  }
427
428
  private Dialog<String> createImageDialog() {
429
    final var path = getActiveTextEditor().getPath();
430
    final var parentDir = path.getParent();
431
    return new ImageDialog( getWindow(), parentDir );
432
  }
433
434
  /**
435
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
436
   * the Markdown AST.
437
   *
438
   * @return An instance containing the link URL and display text.
439
   */
440
  private HyperlinkModel createHyperlinkModel() {
441
    final var context = getMainPane().createProcessorContext();
442
    final var editor = getActiveTextEditor();
443
    final var textArea = editor.getTextArea();
444
    final var selectedText = textArea.getSelectedText();
445
446
    // Convert current paragraph to Markdown nodes.
447
    final var mp = MarkdownProcessor.create( context );
448
    final var p = textArea.getCurrentParagraph();
449
    final var paragraph = textArea.getText( p );
450
    final var node = mp.toNode( paragraph );
451
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
452
    final var link = visitor.process( node );
453
454
    if( link != null ) {
455
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
456
    }
457
458
    return createHyperlinkModel( link, selectedText );
459
  }
460
461
  private HyperlinkModel createHyperlinkModel(
462
    final Link link, final String selection ) {
463
464
    return link == null
465
      ? new HyperlinkModel( selection, "https://localhost" )
466
      : new HyperlinkModel( link );
467
  }
468
469
  public void insert_heading_1() {
470
    insert_heading( 1 );
471
  }
472
473
  public void insert_heading_2() {
474
    insert_heading( 2 );
475
  }
476
477
  public void insert_heading_3() {
478
    insert_heading( 3 );
479
  }
480
481
  private void insert_heading( final int level ) {
482
    getActiveTextEditor().heading( level );
483
  }
484
485
  public void insert_unordered_list() {
486
    getActiveTextEditor().unorderedList();
487
  }
488
489
  public void insert_ordered_list() {
490
    getActiveTextEditor().orderedList();
491
  }
492
493
  public void insert_horizontal_rule() {
494
    getActiveTextEditor().horizontalRule();
495
  }
496
497
  public void definition_create() {
498
    getActiveTextDefinition().createDefinition();
499
  }
500
501
  public void definition_rename() {
502
    getActiveTextDefinition().renameDefinition();
503
  }
504
505
  public void definition_delete() {
506
    getActiveTextDefinition().deleteDefinitions();
507
  }
508
509
  public void definition_autoinsert() {
510
    getMainPane().autoinsert();
511
  }
512
513
  public void view_refresh() {
514
    getMainPane().viewRefresh();
515
  }
516
517
  public void view_preview() {
518
    getMainPane().viewPreview();
519
  }
520
521
  public void view_outline() {
522
    getMainPane().viewOutline();
523
  }
524
525
  public void view_files() {getMainPane().viewFiles();}
526
527
  public void view_statistics() {
528
    getMainPane().viewStatistics();
529
  }
530
531
  public void view_menubar() {
532
    getMainScene().toggleMenuBar();
533
  }
534
535
  public void view_toolbar() {
536
    getMainScene().toggleToolBar();
537
  }
538
539
  public void view_statusbar() {
540
    getMainScene().toggleStatusBar();
541
  }
542
543
  public void view_log() {
544
    mLogView.view();
545
  }
546
547
  public void help_about() {
548
    final var alert = new Alert( INFORMATION );
549
    final var prefix = "Dialog.about.";
550
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
551
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
552
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
553
    alert.setGraphic( ICON_DIALOG_NODE );
554
    alert.initOwner( getWindow() );
555
    alert.showAndWait();
556
  }
557
558
  /**
559
   * Concatenates all the files in the same directory as the given file into
560
   * a string. The extension is determined by the given file name pattern; the
561
   * order files are concatenated is based on their numeric sort order (this
562
   * avoids lexicographic sorting).
563
   * <p>
564
   * If the parent path to the file being edited in the text editor cannot
565
   * be found then this will return the editor's text, without iterating through
566
   * the parent directory. (Should never happen, but who knows?)
567
   * </p>
568
   * <p>
569
   * New lines are automatically appended to separate each file.
570
   * </p>
571
   *
572
   * @param editor The text editor containing
573
   * @return All files in the same directory as the file being edited
574
   * concatenated into a single string.
575
   */
576
  private String append( final TextEditor editor ) {
577
    final var pattern = editor.getPath();
578
    final var parent = pattern.getParent();
579
580
    // Short-circuit because nothing else can be done.
581
    if( parent == null ) {
582
      clue( "Main.status.export.concat.parent", pattern );
583
      return editor.getText();
584
    }
585
586
    final var filename = pattern.getFileName().toString();
587
    final var extension = getExtension( filename );
588
589
    if( extension.isBlank() ) {
590
      clue( "Main.status.export.concat.extension", filename );
591
      return editor.getText();
592
    }
593
594
    try {
595
      final var glob = "**/*." + extension;
596
      final var files = new ArrayList<Path>();
597
      final var text = new StringBuilder( DOCUMENT_LENGTH );
598
      final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS );
599
      final var validator = new RangeValidator( range );
600
      final var chapter = new AtomicInteger();
601
602
      walk( parent, glob, files::add );
603
      files.sort( new AlphanumComparator<>() );
604
      files.forEach( file -> {
605
        try {
606
          clue( "Main.status.export.concat", file );
607
608
          if( validator.test( chapter.incrementAndGet() ) ) {
609
            text.append( readString( file ) );
56
import static java.lang.System.lineSeparator;
57
import static java.nio.file.Files.readString;
58
import static java.nio.file.Files.writeString;
59
import static java.util.concurrent.Executors.newFixedThreadPool;
60
import static javafx.application.Platform.runLater;
61
import static javafx.event.Event.fireEvent;
62
import static javafx.scene.control.Alert.AlertType.INFORMATION;
63
import static javafx.stage.WindowEvent.WINDOW_CLOSE_REQUEST;
64
import static org.apache.commons.io.FilenameUtils.getExtension;
65
66
/**
67
 * Responsible for abstracting how functionality is mapped to the application.
68
 * This allows users to customize accelerator keys and will provide pluggable
69
 * functionality so that different text markup languages can change documents
70
 * using their respective syntax.
71
 */
72
public final class GuiCommands {
73
  private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
74
75
  private static final String STYLE_SEARCH = "search";
76
77
  /**
78
   * Sci-fi genres, which are can be longer than other genres, typically fall
79
   * below 150,000 words at 6 chars per word. This reduces re-allocations of
80
   * memory when concatenating files together when exporting novels.
81
   */
82
  private static final int DOCUMENT_LENGTH = 150_000 * 6;
83
84
  /**
85
   * When an action is executed, this is one of the recipients.
86
   */
87
  private final MainPane mMainPane;
88
89
  private final MainScene mMainScene;
90
91
  private final LogView mLogView;
92
93
  /**
94
   * Tracks finding text in the active document.
95
   */
96
  private final SearchModel mSearchModel;
97
98
  public GuiCommands( final MainScene scene, final MainPane pane ) {
99
    mMainScene = scene;
100
    mMainPane = pane;
101
    mLogView = new LogView();
102
    mSearchModel = new SearchModel();
103
    mSearchModel.matchOffsetProperty().addListener( ( c, o, n ) -> {
104
      final var editor = getActiveTextEditor();
105
106
      // Clear highlighted areas before highlighting a new region.
107
      if( o != null ) {
108
        editor.unstylize( STYLE_SEARCH );
109
      }
110
111
      if( n != null ) {
112
        editor.moveTo( n.getStart() );
113
        editor.stylize( n, STYLE_SEARCH );
114
      }
115
    } );
116
117
    // When the active text editor changes ...
118
    mMainPane.textEditorProperty().addListener(
119
      ( c, o, n ) -> {
120
        // ... update the haystack.
121
        mSearchModel.search( getActiveTextEditor().getText() );
122
123
        // ... update the status bar with the current caret position.
124
        if( n != null ) {
125
          final var w = getWorkspace();
126
          final var recentDoc = w.fileProperty( KEY_UI_RECENT_DOCUMENT );
127
128
          // ... preserve the most recent document.
129
          recentDoc.setValue( n.getFile() );
130
          CaretMovedEvent.fire( n.getCaret() );
131
        }
132
      }
133
    );
134
  }
135
136
  public void file_new() {
137
    getMainPane().newTextEditor();
138
  }
139
140
  public void file_open() {
141
    pickFiles( FILE_OPEN_MULTIPLE ).ifPresent( l -> getMainPane().open( l ) );
142
  }
143
144
  public void file_close() {
145
    getMainPane().close();
146
  }
147
148
  public void file_close_all() {
149
    getMainPane().closeAll();
150
  }
151
152
  public void file_save() {
153
    getMainPane().save();
154
  }
155
156
  public void file_save_as() {
157
    pickFiles( FILE_SAVE_AS ).ifPresent( l -> getMainPane().saveAs( l ) );
158
  }
159
160
  public void file_save_all() {
161
    getMainPane().saveAll();
162
  }
163
164
  /**
165
   * Converts the actively edited file in the given file format.
166
   *
167
   * @param format The destination file format.
168
   */
169
  private void file_export( final ExportFormat format ) {
170
    file_export( format, false );
171
  }
172
173
  /**
174
   * Converts one or more files into the given file format. If {@code dir}
175
   * is set to true, this will first append all files in the same directory
176
   * as the actively edited file.
177
   *
178
   * @param format The destination file format.
179
   * @param dir    Export all files in the actively edited file's directory.
180
   */
181
  private void file_export( final ExportFormat format, final boolean dir ) {
182
    final var main = getMainPane();
183
    final var editor = main.getTextEditor();
184
    final var exported = getWorkspace().fileProperty( KEY_UI_RECENT_EXPORT );
185
    final var filename = format.toExportFilename( editor.getPath() );
186
    final var selected = PDF_DEFAULT.getName()
187
                                    .equals( exported.get().getName() );
188
    final var selection = pickFile(
189
      selected ? filename : exported.get(),
190
      exported.get().toPath().getParent(),
191
      FILE_EXPORT
192
    );
193
194
    selection.ifPresent( files -> {
195
      editor.save();
196
197
      final var file = files.get( 0 );
198
      final var path = file.toPath();
199
      final var document = dir ? append( editor ) : editor.getText();
200
      final var context = main.createProcessorContext( path, format );
201
202
      final var task = new Task<Path>() {
203
        @Override
204
        protected Path call() throws Exception {
205
          final var chain = createProcessors( context );
206
          final var export = chain.apply( document );
207
208
          // Processors can export binary files. In such cases, processors
209
          // return null to prevent further processing.
210
          return export == null ? null : writeString( path, export );
211
        }
212
      };
213
214
      task.setOnSucceeded(
215
        e -> {
216
          // Remember the exported file name for next time.
217
          exported.setValue( file );
218
219
          final var result = task.getValue();
220
221
          // Binary formats must notify users of success independently.
222
          if( result != null ) {
223
            clue( "Main.status.export.success", result );
224
          }
225
        }
226
      );
227
228
      task.setOnFailed( e -> {
229
        final var ex = task.getException();
230
        clue( ex );
231
232
        if( ex instanceof TypeNotPresentException ) {
233
          fireExportFailedEvent();
234
        }
235
      } );
236
237
      sExecutor.execute( task );
238
    } );
239
  }
240
241
  /**
242
   * @param dir {@code true} means to export all files in the active file
243
   *            editor's directory; {@code false} means to export only the
244
   *            actively edited file.
245
   */
246
  private void file_export_pdf( final boolean dir ) {
247
    final var workspace = getWorkspace();
248
    final var themes = workspace.getFile(
249
      KEY_TYPESET_CONTEXT_THEMES_PATH
250
    );
251
    final var theme = workspace.stringProperty(
252
      KEY_TYPESET_CONTEXT_THEME_SELECTION
253
    );
254
    final var chapters = workspace.stringProperty(
255
      KEY_TYPESET_CONTEXT_CHAPTERS
256
    );
257
    final var settings = ExportSettings
258
      .builder()
259
      .with( ExportSettings.Mutator::setTheme, theme )
260
      .with( ExportSettings.Mutator::setChapters, chapters )
261
      .build();
262
263
    if( Typesetter.canRun() ) {
264
      // If the typesetter is installed, allow the user to select a theme. If
265
      // the themes aren't installed, a status message will appear.
266
      if( ExportDialog.choose( getWindow(), themes, settings, dir ) ) {
267
        file_export( APPLICATION_PDF, dir );
268
      }
269
    }
270
    else {
271
      fireExportFailedEvent();
272
    }
273
  }
274
275
  public void file_export_pdf() {
276
    file_export_pdf( false );
277
  }
278
279
  public void file_export_pdf_dir() {
280
    file_export_pdf( true );
281
  }
282
283
  public void file_export_html_svg() {
284
    file_export( HTML_TEX_SVG );
285
  }
286
287
  public void file_export_html_tex() {
288
    file_export( HTML_TEX_DELIMITED );
289
  }
290
291
  public void file_export_xhtml_tex() {
292
    file_export( XHTML_TEX );
293
  }
294
295
  private void fireExportFailedEvent() {
296
    runLater( ExportFailedEvent::fire );
297
  }
298
299
  public void file_exit() {
300
    final var window = getWindow();
301
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
302
  }
303
304
  public void edit_undo() {
305
    getActiveTextEditor().undo();
306
  }
307
308
  public void edit_redo() {
309
    getActiveTextEditor().redo();
310
  }
311
312
  public void edit_cut() {
313
    getActiveTextEditor().cut();
314
  }
315
316
  public void edit_copy() {
317
    getActiveTextEditor().copy();
318
  }
319
320
  public void edit_paste() {
321
    getActiveTextEditor().paste();
322
  }
323
324
  public void edit_select_all() {
325
    getActiveTextEditor().selectAll();
326
  }
327
328
  public void edit_find() {
329
    final var nodes = getMainScene().getStatusBar().getLeftItems();
330
331
    if( nodes.isEmpty() ) {
332
      final var searchBar = new SearchBar();
333
334
      searchBar.matchIndexProperty().bind( mSearchModel.matchIndexProperty() );
335
      searchBar.matchCountProperty().bind( mSearchModel.matchCountProperty() );
336
337
      searchBar.setOnCancelAction( event -> {
338
        final var editor = getActiveTextEditor();
339
        nodes.remove( searchBar );
340
        editor.unstylize( STYLE_SEARCH );
341
        editor.getNode().requestFocus();
342
      } );
343
344
      searchBar.addInputListener( ( c, o, n ) -> {
345
        if( n != null && !n.isEmpty() ) {
346
          mSearchModel.search( n, getActiveTextEditor().getText() );
347
        }
348
      } );
349
350
      searchBar.setOnNextAction( event -> edit_find_next() );
351
      searchBar.setOnPrevAction( event -> edit_find_prev() );
352
353
      nodes.add( searchBar );
354
      searchBar.requestFocus();
355
    }
356
    else {
357
      nodes.clear();
358
    }
359
  }
360
361
  public void edit_find_next() {
362
    mSearchModel.advance();
363
  }
364
365
  public void edit_find_prev() {
366
    mSearchModel.retreat();
367
  }
368
369
  public void edit_preferences() {
370
    try {
371
      new PreferencesController( getWorkspace() ).show();
372
    } catch( final Exception ex ) {
373
      clue( ex );
374
    }
375
  }
376
377
  public void format_bold() {
378
    getActiveTextEditor().bold();
379
  }
380
381
  public void format_italic() {
382
    getActiveTextEditor().italic();
383
  }
384
385
  public void format_monospace() {
386
    getActiveTextEditor().monospace();
387
  }
388
389
  public void format_superscript() {
390
    getActiveTextEditor().superscript();
391
  }
392
393
  public void format_subscript() {
394
    getActiveTextEditor().subscript();
395
  }
396
397
  public void format_strikethrough() {
398
    getActiveTextEditor().strikethrough();
399
  }
400
401
  public void insert_blockquote() {
402
    getActiveTextEditor().blockquote();
403
  }
404
405
  public void insert_code() {
406
    getActiveTextEditor().code();
407
  }
408
409
  public void insert_fenced_code_block() {
410
    getActiveTextEditor().fencedCodeBlock();
411
  }
412
413
  public void insert_link() {
414
    insertObject( createLinkDialog() );
415
  }
416
417
  public void insert_image() {
418
    insertObject( createImageDialog() );
419
  }
420
421
  private void insertObject( final Dialog<String> dialog ) {
422
    final var textArea = getActiveTextEditor().getTextArea();
423
    dialog.showAndWait().ifPresent( textArea::replaceSelection );
424
  }
425
426
  private Dialog<String> createLinkDialog() {
427
    return new LinkDialog( getWindow(), createHyperlinkModel() );
428
  }
429
430
  private Dialog<String> createImageDialog() {
431
    final var path = getActiveTextEditor().getPath();
432
    final var parentDir = path.getParent();
433
    return new ImageDialog( getWindow(), parentDir );
434
  }
435
436
  /**
437
   * Returns one of: selected text, word under cursor, or parsed hyperlink from
438
   * the Markdown AST.
439
   *
440
   * @return An instance containing the link URL and display text.
441
   */
442
  private HyperlinkModel createHyperlinkModel() {
443
    final var context = getMainPane().createProcessorContext();
444
    final var editor = getActiveTextEditor();
445
    final var textArea = editor.getTextArea();
446
    final var selectedText = textArea.getSelectedText();
447
448
    // Convert current paragraph to Markdown nodes.
449
    final var mp = MarkdownProcessor.create( context );
450
    final var p = textArea.getCurrentParagraph();
451
    final var paragraph = textArea.getText( p );
452
    final var node = mp.toNode( paragraph );
453
    final var visitor = new LinkVisitor( textArea.getCaretColumn() );
454
    final var link = visitor.process( node );
455
456
    if( link != null ) {
457
      textArea.selectRange( p, link.getStartOffset(), p, link.getEndOffset() );
458
    }
459
460
    return createHyperlinkModel( link, selectedText );
461
  }
462
463
  private HyperlinkModel createHyperlinkModel(
464
    final Link link, final String selection ) {
465
466
    return link == null
467
      ? new HyperlinkModel( selection, "https://localhost" )
468
      : new HyperlinkModel( link );
469
  }
470
471
  public void insert_heading_1() {
472
    insert_heading( 1 );
473
  }
474
475
  public void insert_heading_2() {
476
    insert_heading( 2 );
477
  }
478
479
  public void insert_heading_3() {
480
    insert_heading( 3 );
481
  }
482
483
  private void insert_heading( final int level ) {
484
    getActiveTextEditor().heading( level );
485
  }
486
487
  public void insert_unordered_list() {
488
    getActiveTextEditor().unorderedList();
489
  }
490
491
  public void insert_ordered_list() {
492
    getActiveTextEditor().orderedList();
493
  }
494
495
  public void insert_horizontal_rule() {
496
    getActiveTextEditor().horizontalRule();
497
  }
498
499
  public void definition_create() {
500
    getActiveTextDefinition().createDefinition();
501
  }
502
503
  public void definition_rename() {
504
    getActiveTextDefinition().renameDefinition();
505
  }
506
507
  public void definition_delete() {
508
    getActiveTextDefinition().deleteDefinitions();
509
  }
510
511
  public void definition_autoinsert() {
512
    getMainPane().autoinsert();
513
  }
514
515
  public void view_refresh() {
516
    getMainPane().viewRefresh();
517
  }
518
519
  public void view_preview() {
520
    getMainPane().viewPreview();
521
  }
522
523
  public void view_outline() {
524
    getMainPane().viewOutline();
525
  }
526
527
  public void view_files() {getMainPane().viewFiles();}
528
529
  public void view_statistics() {
530
    getMainPane().viewStatistics();
531
  }
532
533
  public void view_menubar() {
534
    getMainScene().toggleMenuBar();
535
  }
536
537
  public void view_toolbar() {
538
    getMainScene().toggleToolBar();
539
  }
540
541
  public void view_statusbar() {
542
    getMainScene().toggleStatusBar();
543
  }
544
545
  public void view_log() {
546
    mLogView.view();
547
  }
548
549
  public void help_about() {
550
    final var alert = new Alert( INFORMATION );
551
    final var prefix = "Dialog.about.";
552
    alert.setTitle( get( prefix + "title", APP_TITLE ) );
553
    alert.setHeaderText( get( prefix + "header", APP_TITLE ) );
554
    alert.setContentText( get( prefix + "content", APP_YEAR, APP_VERSION ) );
555
    alert.setGraphic( ICON_DIALOG_NODE );
556
    alert.initOwner( getWindow() );
557
    alert.showAndWait();
558
  }
559
560
  /**
561
   * Concatenates all the files in the same directory as the given file into
562
   * a string. The extension is determined by the given file name pattern; the
563
   * order files are concatenated is based on their numeric sort order (this
564
   * avoids lexicographic sorting).
565
   * <p>
566
   * If the parent path to the file being edited in the text editor cannot
567
   * be found then this will return the editor's text, without iterating through
568
   * the parent directory. (Should never happen, but who knows?)
569
   * </p>
570
   * <p>
571
   * New lines are automatically appended to separate each file.
572
   * </p>
573
   *
574
   * @param editor The text editor containing
575
   * @return All files in the same directory as the file being edited
576
   * concatenated into a single string.
577
   */
578
  private String append( final TextEditor editor ) {
579
    final var pattern = editor.getPath();
580
    final var parent = pattern.getParent();
581
582
    // Short-circuit because nothing else can be done.
583
    if( parent == null ) {
584
      clue( "Main.status.export.concat.parent", pattern );
585
      return editor.getText();
586
    }
587
588
    final var filename = pattern.getFileName().toString();
589
    final var extension = getExtension( filename );
590
591
    if( extension.isBlank() ) {
592
      clue( "Main.status.export.concat.extension", filename );
593
      return editor.getText();
594
    }
595
596
    try {
597
      final var glob = "**/*." + extension;
598
      final var files = new ArrayList<Path>();
599
      final var text = new StringBuilder( DOCUMENT_LENGTH );
600
      final var range = getString( KEY_TYPESET_CONTEXT_CHAPTERS );
601
      final var validator = new RangeValidator( range );
602
      final var chapter = new AtomicInteger();
603
604
      walk( parent, glob, files::add );
605
      files.sort( new AlphanumComparator<>() );
606
      files.forEach( file -> {
607
        try {
608
          clue( "Main.status.export.concat", file );
609
610
          if( validator.test( chapter.incrementAndGet() ) ) {
611
            // Ensure multiple files are separated by an EOL.
612
            text.append( readString( file ) ).append( lineSeparator() );
610613
          }
611614
        } catch( final IOException ex ) {