diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fc9dfa8fa..134ec7736be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv - We added tooltip on main table cells that shows cell content or cell content and entry preview if set in preferences. [10925](https://github.com/JabRef/jabref/issues/10925) - We added the ability to add a keyword/crossref when typing the separator character (e.g., comma) in the keywords/crossref fields. [#11178](https://github.com/JabRef/jabref/issues/11178) - We added an exporter and improved the importer for Endnote XML format. [#11137](https://github.com/JabRef/jabref/issues/11137) +- We added support for automatically update LaTeX citations when a LaTeX file is created, removed, or modified. [#10585](https://github.com/JabRef/jabref/issues/10585) ### Changed diff --git a/build.gradle b/build.gradle index 0ebf42a254e..28db2f20df6 100644 --- a/build.gradle +++ b/build.gradle @@ -296,6 +296,8 @@ dependencies { // YAML formatting implementation 'org.yaml:snakeyaml:2.2' + implementation 'commons-io:commons-io:2.16.1' + testImplementation 'io.github.classgraph:classgraph:4.8.170' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2' diff --git a/docs/decisions/0028-http-return-bibtex-string.md b/docs/decisions/0028-http-return-bibtex-string.md index e60a931d54a..bc8ff9394eb 100644 --- a/docs/decisions/0028-http-return-bibtex-string.md +++ b/docs/decisions/0028-http-return-bibtex-string.md @@ -2,8 +2,6 @@ nav_order: 28 parent: Decision Records --- - - # Return BibTeX string and CSL Item JSON in the API ## Context and Problem Statement diff --git a/docs/decisions/0029-cff-export-multiple-entries.md b/docs/decisions/0029-cff-export-multiple-entries.md index 179ceab745d..ec1dab8cfc0 100644 --- a/docs/decisions/0029-cff-export-multiple-entries.md +++ b/docs/decisions/0029-cff-export-multiple-entries.md @@ -1,10 +1,7 @@ --- -nav_order: 28 +nav_order: 29 parent: Decision Records --- - - - # Exporting multiple entries to CFF ## Context and Problem Statement diff --git a/docs/decisions/0030-use-apache-commons-io-for-directory-monitoring.md b/docs/decisions/0030-use-apache-commons-io-for-directory-monitoring.md new file mode 100644 index 00000000000..42a762132c8 --- /dev/null +++ b/docs/decisions/0030-use-apache-commons-io-for-directory-monitoring.md @@ -0,0 +1,48 @@ +--- +nav_order: 30 +parent: Decision Records +--- +# Use Apache Commons IO for directory monitoring + +## Context and Problem Statement + +In JabRef, there is a need to add a directory monitor that will listen for changes in a specified directory. + +Currently, the monitor is used to automatically update the [LaTeX Citations](https://docs.jabref.org/advanced/entryeditor/latex-citations) when a LaTeX file in the LaTeX directory is created, removed, or modified ([#10585](https://github.com/JabRef/jabref/issues/10585)). +Additionally, this monitor will be used to create a dynamic group that mirrors the file system structure ([#10930](https://github.com/JabRef/jabref/issues/10930)). + +## Considered Options + +* Use [java.nio.file.WatchService](https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/nio/file/WatchService.html) +* Use [io.methvin.watcher.DirectoryWatcher](https://github.com/gmethvin/directory-watcher) +* Use [org.apache.commons.io.monitor](https://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/monitor/package-summary.html) + +## Decision Outcome + +Chosen option: "Use [org.apache.commons.io.monitor](https://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/monitor/package-summary.html)", because comes out best (see below). + +## Pros and Cons of the Options + +### java.nio.file.WatchService + +* Good, because it is a standard Java API for watching directories. +* Good, because it does not need polling, it is event-based for most operating systems. +* Bad, because: + 1. Does not detect files coming together with a new folder (JDK issue: [JDK-8162948](https://bugs.openjdk.org/browse/JDK-8162948)). + 2. Deleting a subdirectory does not detect deleted files in that directory. + 3. Access denied when trying to delete the recursively watched directory on Windows (JDK issue: [JDK-6972833](https://bugs.openjdk.org/browse/JDK-6972833)). + 4. Implemented on macOS by the generic `PollingWatchService`. (JDK issue: [JDK-8293067](https://bugs.openjdk.org/browse/JDK-8293067)) + +### io.methvin.watcher.DirectoryWatcher + +* Good, because it implemented on top of the `java.nio.file.WatchService`, which is a standard Java API for watching directories. +* Good, because it resolves some of the issues of the `java.nio.file.WatchService`. + * Uses`ExtendedWatchEventModifier.FILE_TREE` on Windows, which resolves issues (1, 3) of the `java.nio.file.WatchService`. + * On macOS have native implementation based on the Carbon File System Events API, this resolves issue (4) of the `java.nio.file.WatchService`. +* Bad, because issue (2) of the `java.nio.file.WatchService` is not resolved. + +### org.apache.commons.io.monitor + +* Good, because there are no observed issues. +* Good, because can handle huge amount of files without overflowing. +* Bad, because it uses a polling mechanism at fixed intervals, which can waste CPU cycles if no change occurs. diff --git a/external-libraries.md b/external-libraries.md index 96695fdc1b7..a4e483bb84d 100644 --- a/external-libraries.md +++ b/external-libraries.md @@ -294,6 +294,12 @@ Project: Apache Commons Digester URL: https://commons.apache.org/proper/commons-digester/ ``` +```yaml +Id: commons-io:commons-io +Project: Apache Commons IO +URL: https://commons.apache.org/proper/commons-io/ +``` + ```yaml Id: de.rototor.jeuclid:jeuclid-core Project: JEuclid @@ -537,7 +543,7 @@ Id: com.ibm.icu:* Project: International Components for Unicode URL: https://icu.unicode.org/ License: Unicode License (https://www.unicode.org/copyright.html) -Note: Our own fork https://github.com/JabRef/icu. Upstream PR: https://github.com/unicode-org/icu/pull/2127 +Note: Our own fork https://github.com/JabRef/icu. [Upstream PR](https://github.com/unicode-org/icu/pull/2127) Path: lib/icu4j.jar SourcePath: lib/ic4j-src.jar ``` @@ -579,49 +585,49 @@ License: LGPL-2.1-or-later ```yaml Id: org.openjfx:javafx-base -Project JavaFX +Project: JavaFX URL: https://openjfx.io/ License: GPL-2.0 WITH Classpath-exception-2.0 ``` ```yaml Id: org.openjfx:javafx-controls -Project JavaFX +Project: JavaFX URL: https://openjfx.io/ License: GPL-2.0 WITH Classpath-exception-2.0 ``` ```yaml Id: org.openjfx:javafx-fxml -Project JavaFX +Project: JavaFX URL: https://openjfx.io/ License: GPL-2.0 WITH Classpath-exception-2.0 ``` ```yaml Id: org.openjfx:javafx-graphics -Project JavaFX +Project: JavaFX URL: https://openjfx.io/ License: GPL-2.0 WITH Classpath-exception-2.0 ``` ```yaml Id: org.openjfx:javafx-media -Project JavaFX +Project: JavaFX URL: https://openjfx.io/ License: GPL-2.0 WITH Classpath-exception-2.0 ``` ```yaml Id: org.openjfx:javafx-swing -Project JavaFX +Project: JavaFX URL: https://openjfx.io/ License: GPL-2.0 WITH Classpath-exception-2.0 ``` ```yaml Id: org.openjfx:javafx-web -Project JavaFX +Project: JavaFX URL: https://openjfx.io/ License: GPL-2.0 WITH Classpath-exception-2.0 ``` @@ -765,6 +771,7 @@ commons-cli:commons-cli:1.6.0 commons-codec:commons-codec:1.16.0 commons-collections:commons-collections:3.2.2 commons-digester:commons-digester:2.1 +commons-io:commons-io:2.16.1 commons-logging:commons-logging:1.2 commons-validator:commons-validator:1.7 de.rototor.jeuclid:jeuclid-core:3.1.11 diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 597270bf308..352ef69c95b 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -54,6 +54,9 @@ requires java.prefs; requires com.fasterxml.aalto; + // YAML + requires org.yaml.snakeyaml; + // Annotations (@PostConstruct) requires jakarta.annotation; requires jakarta.inject; @@ -88,13 +91,14 @@ uses org.mariadb.jdbc.credential.CredentialPlugin; // Apache Commons and other (similar) helper libraries + requires com.google.common; + requires io.github.javadiffutils; + requires java.string.similarity; requires org.apache.commons.cli; requires org.apache.commons.csv; + requires org.apache.commons.io; requires org.apache.commons.lang3; requires org.apache.commons.text; - requires com.google.common; - requires io.github.javadiffutils; - requires java.string.similarity; requires com.github.tomtung.latex2unicode; requires fastparse; @@ -146,5 +150,4 @@ requires de.saxsys.mvvmfx.validation; requires dd.plist; requires mslinks; - requires org.yaml.snakeyaml; } diff --git a/src/main/java/org/jabref/gui/Globals.java b/src/main/java/org/jabref/gui/Globals.java index 4bbb5dbb2d8..bb8a45f8d8b 100644 --- a/src/main/java/org/jabref/gui/Globals.java +++ b/src/main/java/org/jabref/gui/Globals.java @@ -5,6 +5,7 @@ import org.jabref.gui.remote.CLIMessageHandler; import org.jabref.gui.telemetry.Telemetry; import org.jabref.gui.undo.CountingUndoManager; +import org.jabref.gui.util.DefaultDirectoryMonitor; import org.jabref.gui.util.DefaultFileUpdateMonitor; import org.jabref.gui.util.DefaultTaskExecutor; import org.jabref.gui.util.TaskExecutor; @@ -14,6 +15,7 @@ import org.jabref.logic.remote.server.RemoteListenerServerManager; import org.jabref.logic.util.BuildInfo; import org.jabref.model.entry.BibEntryTypesManager; +import org.jabref.model.util.DirectoryMonitor; import org.jabref.model.util.FileUpdateMonitor; import org.jabref.preferences.PreferencesService; @@ -65,6 +67,7 @@ public class Globals { private static KeyBindingRepository keyBindingRepository; private static DefaultFileUpdateMonitor fileUpdateMonitor; + private static DefaultDirectoryMonitor directoryMonitor; private Globals() { } @@ -92,6 +95,13 @@ public static synchronized FileUpdateMonitor getFileUpdateMonitor() { return fileUpdateMonitor; } + public static DirectoryMonitor getDirectoryMonitor() { + if (directoryMonitor == null) { + directoryMonitor = new DefaultDirectoryMonitor(); + } + return directoryMonitor; + } + // Background tasks public static void startBackgroundTasks() { // TODO Currently deactivated due to incompatibilities in XML @@ -109,6 +119,11 @@ public static void shutdownThreadPools() { if (fileUpdateMonitor != null) { fileUpdateMonitor.shutdown(); } + + if (directoryMonitor != null) { + directoryMonitor.shutdown(); + } + JabRefExecutorService.INSTANCE.shutdownEverything(); } diff --git a/src/main/java/org/jabref/gui/LibraryTab.java b/src/main/java/org/jabref/gui/LibraryTab.java index 48231b2f967..02ee691c747 100644 --- a/src/main/java/org/jabref/gui/LibraryTab.java +++ b/src/main/java/org/jabref/gui/LibraryTab.java @@ -84,6 +84,7 @@ import org.jabref.model.entry.field.Field; import org.jabref.model.entry.field.FieldFactory; import org.jabref.model.entry.field.StandardField; +import org.jabref.model.util.DirectoryMonitorManager; import org.jabref.model.util.FileUpdateMonitor; import org.jabref.preferences.PreferencesService; @@ -127,9 +128,9 @@ private enum PanelMode { MAIN_TABLE, MAIN_TABLE_AND_ENTRY_EDITOR } // Indicates whether the tab is loading data using a dataloading task // The constructors take care to the right true/false assignment during start. - private SimpleBooleanProperty loading = new SimpleBooleanProperty(false); + private final SimpleBooleanProperty loading = new SimpleBooleanProperty(false); - // initally, the dialog is loading, not saving + // initially, the dialog is loading, not saving private boolean saving = false; private PersonNameSuggestionProvider searchAutoCompleter; @@ -151,6 +152,7 @@ private enum PanelMode { MAIN_TABLE, MAIN_TABLE_AND_ENTRY_EDITOR } private final IndexingTaskManager indexingTaskManager; private final TaskExecutor taskExecutor; + private final DirectoryMonitorManager directoryMonitorManager; private LibraryTab(BibDatabaseContext bibDatabaseContext, LibraryTabContainer tabContainer, @@ -171,6 +173,7 @@ private LibraryTab(BibDatabaseContext bibDatabaseContext, this.entryTypesManager = entryTypesManager; this.indexingTaskManager = new IndexingTaskManager(taskExecutor); this.taskExecutor = taskExecutor; + this.directoryMonitorManager = new DirectoryMonitorManager(Globals.getDirectoryMonitor()); bibDatabaseContext.getDatabase().registerListener(this); bibDatabaseContext.getMetaData().registerListener(this); @@ -832,6 +835,11 @@ private void onClosed(Event event) { } catch (RuntimeException e) { LOGGER.error("Problem when closing change monitor", e); } + try { + directoryMonitorManager.unregister(); + } catch (RuntimeException e) { + LOGGER.error("Problem when closing directory monitor", e); + } try { PdfIndexerManager.shutdownIndexer(bibDatabaseContext); } catch (RuntimeException e) { @@ -864,6 +872,10 @@ public BibDatabaseContext getBibDatabaseContext() { return this.bibDatabaseContext; } + public DirectoryMonitorManager getDirectoryMonitorManager() { + return directoryMonitorManager; + } + public boolean isSaving() { return saving; } diff --git a/src/main/java/org/jabref/gui/StateManager.java b/src/main/java/org/jabref/gui/StateManager.java index 2c842cd213d..010375fc1c5 100644 --- a/src/main/java/org/jabref/gui/StateManager.java +++ b/src/main/java/org/jabref/gui/StateManager.java @@ -190,7 +190,7 @@ public ObservableList> getBackgroundTasks() { } public void addBackgroundTask(BackgroundTask backgroundTask, Task task) { - this.backgroundTasks.add(0, new Pair<>(backgroundTask, task)); + this.backgroundTasks.addFirst(new Pair<>(backgroundTask, task)); } public EasyBinding getAnyTaskRunning() { diff --git a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java index e2621d4cec0..220d11e2299 100644 --- a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java +++ b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java @@ -55,6 +55,7 @@ import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.BibEntryTypesManager; import org.jabref.model.entry.field.Field; +import org.jabref.model.util.DirectoryMonitorManager; import org.jabref.model.util.FileUpdateMonitor; import org.jabref.preferences.PreferencesService; @@ -82,6 +83,7 @@ public class EntryEditor extends BorderPane { private final BibDatabaseContext databaseContext; private final EntryEditorPreferences entryEditorPreferences; private final ExternalFilesEntryLinker fileLinker; + private final DirectoryMonitorManager directoryMonitorManager; private Subscription typeSubscription; @@ -121,6 +123,7 @@ public class EntryEditor extends BorderPane { public EntryEditor(LibraryTab libraryTab) { this.libraryTab = libraryTab; this.databaseContext = libraryTab.getBibDatabaseContext(); + this.directoryMonitorManager = libraryTab.getDirectoryMonitorManager(); ViewLoader.view(this) .root(this) @@ -307,7 +310,7 @@ private List createTabs() { bibEntryTypesManager, keyBindingRepository); entryEditorTabs.add(sourceTab); - entryEditorTabs.add(new LatexCitationsTab(databaseContext, preferencesService, taskExecutor, dialogService)); + entryEditorTabs.add(new LatexCitationsTab(databaseContext, preferencesService, dialogService, directoryMonitorManager)); entryEditorTabs.add(new FulltextSearchResultsTab(stateManager, preferencesService, dialogService, taskExecutor)); return entryEditorTabs; diff --git a/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTab.java b/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTab.java index 2272316bb8f..0be86194c87 100644 --- a/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTab.java @@ -17,10 +17,10 @@ import org.jabref.gui.DialogService; import org.jabref.gui.icon.IconTheme; import org.jabref.gui.texparser.CitationsDisplay; -import org.jabref.gui.util.TaskExecutor; import org.jabref.logic.l10n.Localization; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; +import org.jabref.model.util.DirectoryMonitorManager; import org.jabref.preferences.PreferencesService; import com.tobiasdiez.easybind.EasyBind; @@ -33,9 +33,17 @@ public class LatexCitationsTab extends EntryEditorTab { private final ProgressIndicator progressIndicator; private final CitationsDisplay citationsDisplay; - public LatexCitationsTab(BibDatabaseContext databaseContext, PreferencesService preferencesService, - TaskExecutor taskExecutor, DialogService dialogService) { - this.viewModel = new LatexCitationsTabViewModel(databaseContext, preferencesService, taskExecutor, dialogService); + public LatexCitationsTab(BibDatabaseContext databaseContext, + PreferencesService preferencesService, + DialogService dialogService, + DirectoryMonitorManager directoryMonitorManager) { + + this.viewModel = new LatexCitationsTabViewModel( + databaseContext, + preferencesService, + dialogService, + directoryMonitorManager); + this.searchPane = new GridPane(); this.progressIndicator = new ProgressIndicator(); this.citationsDisplay = new CitationsDisplay(); @@ -66,6 +74,11 @@ private void setSearchPane() { searchPane.setId("citationsPane"); setContent(searchPane); + HBox latexDirectoryBox = getLatexDirectoryBox(); + VBox citationsPane = getCitationsPane(); + VBox notFoundPane = getNotFoundPane(); + VBox errorPane = getErrorPane(); + EasyBind.subscribe(viewModel.statusProperty(), status -> { searchPane.getChildren().clear(); switch (status) { @@ -73,36 +86,35 @@ private void setSearchPane() { searchPane.add(progressIndicator, 0, 0); break; case CITATIONS_FOUND: - searchPane.add(getCitationsPane(), 0, 0); + searchPane.add(citationsPane, 0, 0); break; case NO_RESULTS: - searchPane.add(getNotFoundPane(), 0, 0); + searchPane.add(notFoundPane, 0, 0); break; case ERROR: - searchPane.add(getErrorPane(), 0, 0); + searchPane.add(errorPane, 0, 0); break; } - searchPane.add(getLatexDirectoryBox(), 0, 1); + searchPane.add(latexDirectoryBox, 0, 1); }); } private HBox getLatexDirectoryBox() { Text latexDirectoryText = new Text(Localization.lang("Current search directory:")); - Text latexDirectoryPath = new Text(viewModel.directoryProperty().get().toString()); + Text latexDirectoryPath = new Text(); + latexDirectoryPath.textProperty().bind(viewModel.directoryProperty().asString()); latexDirectoryPath.setStyle("-fx-font-family:monospace;-fx-font-weight: bold;"); Button latexDirectoryButton = new Button(Localization.lang("Set LaTeX file directory")); latexDirectoryButton.setGraphic(IconTheme.JabRefIcons.LATEX_FILE_DIRECTORY.getGraphicNode()); latexDirectoryButton.setOnAction(event -> viewModel.setLatexDirectory()); - Button latexDirectoryRefreshButton = new Button(Localization.lang("Refresh")); - latexDirectoryRefreshButton.setGraphic(IconTheme.JabRefIcons.REFRESH.getGraphicNode()); - latexDirectoryRefreshButton.setOnAction(event -> viewModel.refreshLatexDirectory()); - HBox latexDirectoryBox = new HBox(10, latexDirectoryText, latexDirectoryPath, latexDirectoryButton, latexDirectoryRefreshButton); + HBox latexDirectoryBox = new HBox(10, latexDirectoryText, latexDirectoryPath, latexDirectoryButton); latexDirectoryBox.setAlignment(Pos.CENTER); return latexDirectoryBox; } private VBox getCitationsPane() { VBox citationsBox = new VBox(30, citationsDisplay); + VBox.setVgrow(citationsDisplay, Priority.ALWAYS); citationsBox.setStyle("-fx-padding: 0;"); return citationsBox; } @@ -122,7 +134,8 @@ private VBox getNotFoundPane() { private VBox getErrorPane() { Label titleLabel = new Label(Localization.lang("Error")); titleLabel.setStyle("-fx-font-size: 1.5em;-fx-font-weight: bold;-fx-text-fill: -fx-accent;"); - Text errorMessageText = new Text(viewModel.searchErrorProperty().get()); + Text errorMessageText = new Text(); + errorMessageText.textProperty().bind(viewModel.searchErrorProperty()); VBox errorMessageBox = new VBox(30, titleLabel, errorMessageText); errorMessageBox.setStyle("-fx-padding: 30 0 0 30;"); return errorMessageBox; @@ -130,7 +143,7 @@ private VBox getErrorPane() { @Override protected void bindToEntry(BibEntry entry) { - viewModel.init(entry); + viewModel.bindToEntry(entry); } @Override diff --git a/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTabViewModel.java b/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTabViewModel.java index 833ce40ef63..2549e9ec211 100644 --- a/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTabViewModel.java +++ b/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTabViewModel.java @@ -1,11 +1,15 @@ package org.jabref.gui.entryeditor; +import java.io.File; +import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collection; import java.util.Optional; -import java.util.concurrent.Future; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyListWrapper; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; @@ -14,23 +18,29 @@ import org.jabref.gui.AbstractViewModel; import org.jabref.gui.DialogService; -import org.jabref.gui.util.BackgroundTask; +import org.jabref.gui.util.DefaultTaskExecutor; import org.jabref.gui.util.DirectoryDialogConfiguration; -import org.jabref.gui.util.TaskExecutor; import org.jabref.logic.l10n.Localization; -import org.jabref.logic.texparser.CitationFinder; +import org.jabref.logic.texparser.DefaultLatexParser; import org.jabref.logic.util.io.FileUtil; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.texparser.Citation; +import org.jabref.model.texparser.LatexParserResult; +import org.jabref.model.texparser.LatexParserResults; +import org.jabref.model.util.DirectoryMonitorManager; import org.jabref.preferences.PreferencesService; +import org.apache.commons.io.filefilter.FileFilterUtils; +import org.apache.commons.io.filefilter.IOFileFilter; +import org.apache.commons.io.monitor.FileAlterationListener; +import org.apache.commons.io.monitor.FileAlterationObserver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class LatexCitationsTabViewModel extends AbstractViewModel { - enum Status { + public enum Status { IN_PROGRESS, CITATIONS_FOUND, NO_RESULTS, @@ -39,114 +49,187 @@ enum Status { private static final Logger LOGGER = LoggerFactory.getLogger(LatexCitationsTabViewModel.class); private static final String TEX_EXT = ".tex"; + private static final IOFileFilter FILE_FILTER = FileFilterUtils.or(FileFilterUtils.suffixFileFilter(TEX_EXT), FileFilterUtils.directoryFileFilter()); private final BibDatabaseContext databaseContext; private final PreferencesService preferencesService; - private final TaskExecutor taskExecutor; private final DialogService dialogService; private final ObjectProperty directory; - private CitationFinder citationFinder; private final ObservableList citationList; private final ObjectProperty status; private final StringProperty searchError; - private Future searchTask; + private final BooleanProperty updateStatusOnCreate; + private final DefaultLatexParser latexParser; + private final LatexParserResults latexFiles; + private final DirectoryMonitorManager directoryMonitorManager; + private final FileAlterationListener listener; + private FileAlterationObserver observer; private BibEntry currentEntry; public LatexCitationsTabViewModel(BibDatabaseContext databaseContext, PreferencesService preferencesService, - TaskExecutor taskExecutor, - DialogService dialogService) { + DialogService dialogService, + DirectoryMonitorManager directoryMonitorManager) { + this.databaseContext = databaseContext; this.preferencesService = preferencesService; - this.taskExecutor = taskExecutor; this.dialogService = dialogService; this.directory = new SimpleObjectProperty<>(databaseContext.getMetaData().getLatexFileDirectory(preferencesService.getFilePreferences().getUserAndHost()) .orElse(FileUtil.getInitialDirectory(databaseContext, preferencesService.getFilePreferences().getWorkingDirectory()))); - this.citationFinder = new CitationFinder(directory.get()); this.citationList = FXCollections.observableArrayList(); this.status = new SimpleObjectProperty<>(Status.IN_PROGRESS); this.searchError = new SimpleStringProperty(""); + this.directoryMonitorManager = directoryMonitorManager; + this.updateStatusOnCreate = new SimpleBooleanProperty(false); + this.listener = getListener(); + + this.latexParser = new DefaultLatexParser(); + this.latexFiles = new LatexParserResults(); } - public void init(BibEntry entry) { - cancelSearch(); + private FileAlterationListener getListener() { + return new FileAlterationListener() { + @Override + public void onStart(FileAlterationObserver observer) { + if (!updateStatusOnCreate.get()) { + status.set(Status.IN_PROGRESS); + } + } + + @Override + public void onStop(FileAlterationObserver observer) { + if (!updateStatusOnCreate.get()) { + updateStatusOnCreate.set(true); + updateStatus(); + } + } + + @Override + public void onFileCreate(File file) { + Path path = file.toPath(); + LatexParserResult result = latexParser.parse(path).get(); + latexFiles.add(path, result); + + Optional citationKey = currentEntry.getCitationKey(); + if (citationKey.isPresent()) { + Collection citations = result.getCitationsByKey(citationKey.get()); + DefaultTaskExecutor.runInJavaFXThread(() -> citationList.addAll(citations)); + } + + if (updateStatusOnCreate.get()) { + updateStatus(); + } + } + + @Override + public void onFileDelete(File file) { + LatexParserResult result = latexFiles.remove(file.toPath()); + + Optional citationKey = currentEntry.getCitationKey(); + if (citationKey.isPresent()) { + Collection citations = result.getCitationsByKey(citationKey.get()); + DefaultTaskExecutor.runInJavaFXThread(() -> citationList.removeAll(citations)); + updateStatus(); + } + } + + @Override + public void onFileChange(File file) { + onFileDelete(file); + onFileCreate(file); + updateStatus(); + } + + @Override + public void onDirectoryChange(File directory) { + } + + @Override + public void onDirectoryCreate(File directory) { + } + + @Override + public void onDirectoryDelete(File directory) { + } + }; + } + + public void bindToEntry(BibEntry entry) { + checkAndUpdateDirectory(); currentEntry = entry; - Optional citeKey = entry.getCitationKey(); + Optional citationKey = entry.getCitationKey(); + + if (observer == null) { + observer = new FileAlterationObserver(directory.get().toFile(), FILE_FILTER); + directoryMonitorManager.addObserver(observer, listener); + } - if (citeKey.isPresent()) { - startSearch(citeKey.get()); + if (citationKey.isPresent()) { + citationList.setAll(latexFiles.getCitationsByKey(citationKey.get())); + if (!status.get().equals(Status.IN_PROGRESS)) { + updateStatus(); + } } else { searchError.set(Localization.lang("Selected entry does not have an associated citation key.")); status.set(Status.ERROR); } } - public ObjectProperty directoryProperty() { - return directory; - } - - public ObservableList getCitationList() { - return new ReadOnlyListWrapper<>(citationList); - } - - public ObjectProperty statusProperty() { - return status; - } + public void setLatexDirectory() { + DirectoryDialogConfiguration directoryDialogConfiguration = new DirectoryDialogConfiguration.Builder() + .withInitialDirectory(directory.get()).build(); - public StringProperty searchErrorProperty() { - return searchError; - } + dialogService.showDirectorySelectionDialog(directoryDialogConfiguration).ifPresent(selectedDirectory -> + databaseContext.getMetaData().setLatexFileDirectory(preferencesService.getFilePreferences().getUserAndHost(), selectedDirectory.toAbsolutePath())); - private void startSearch(String citeKey) { - // we need to check whether the user meanwhile set the LaTeX file directory or the database changed locations checkAndUpdateDirectory(); - - searchTask = BackgroundTask.wrap(() -> citationFinder.searchAndParse(citeKey)) - .onRunning(() -> status.set(Status.IN_PROGRESS)) - .onSuccess(result -> { - citationList.setAll(result); - status.set(citationList.isEmpty() ? Status.NO_RESULTS : Status.CITATIONS_FOUND); - }) - .onFailure(error -> { - searchError.set(error.getMessage()); - status.set(Status.ERROR); - }) - .executeWith(taskExecutor); - } - - private void cancelSearch() { - if (searchTask == null || searchTask.isCancelled() || searchTask.isDone()) { - return; - } - - status.set(Status.IN_PROGRESS); - searchTask.cancel(true); } - public void checkAndUpdateDirectory() { + private void checkAndUpdateDirectory() { Path newDirectory = databaseContext.getMetaData().getLatexFileDirectory(preferencesService.getFilePreferences().getUserAndHost()) .orElse(FileUtil.getInitialDirectory(databaseContext, preferencesService.getFilePreferences().getWorkingDirectory())); if (!newDirectory.equals(directory.get())) { + status.set(Status.IN_PROGRESS); + updateStatusOnCreate.set(false); + citationList.clear(); + latexFiles.clear(); + + directoryMonitorManager.removeObserver(observer); directory.set(newDirectory); - citationFinder = new CitationFinder(newDirectory); + observer = new FileAlterationObserver(directory.get().toFile(), FILE_FILTER); + directoryMonitorManager.addObserver(observer, listener); } } - public void setLatexDirectory() { - DirectoryDialogConfiguration directoryDialogConfiguration = new DirectoryDialogConfiguration.Builder() - .withInitialDirectory(directory.get()).build(); + private void updateStatus() { + DefaultTaskExecutor.runInJavaFXThread(() -> { + if (!Files.exists(directory.get())) { + searchError.set(Localization.lang("Current search directory does not exist: %0", directory.get())); + status.set(Status.ERROR); + } else if (citationList.isEmpty()) { + status.set(Status.NO_RESULTS); + } else { + status.set(Status.CITATIONS_FOUND); + } + }); + } - dialogService.showDirectorySelectionDialog(directoryDialogConfiguration).ifPresent(selectedDirectory -> - databaseContext.getMetaData().setLatexFileDirectory(preferencesService.getFilePreferences().getUserAndHost(), selectedDirectory.toAbsolutePath())); + public ObjectProperty directoryProperty() { + return directory; + } + + public ObservableList getCitationList() { + return new ReadOnlyListWrapper<>(citationList); + } - init(currentEntry); + public ObjectProperty statusProperty() { + return status; } - public void refreshLatexDirectory() { - citationFinder = new CitationFinder(directory.get()); - init(currentEntry); + public StringProperty searchErrorProperty() { + return searchError; } public boolean shouldShow() { diff --git a/src/main/java/org/jabref/gui/util/DefaultDirectoryMonitor.java b/src/main/java/org/jabref/gui/util/DefaultDirectoryMonitor.java new file mode 100644 index 00000000000..522a63632b0 --- /dev/null +++ b/src/main/java/org/jabref/gui/util/DefaultDirectoryMonitor.java @@ -0,0 +1,54 @@ +package org.jabref.gui.util; + +import org.jabref.model.util.DirectoryMonitor; + +import org.apache.commons.io.monitor.FileAlterationListener; +import org.apache.commons.io.monitor.FileAlterationMonitor; +import org.apache.commons.io.monitor.FileAlterationObserver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DefaultDirectoryMonitor implements DirectoryMonitor { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultDirectoryMonitor.class); + private static final int POLL_INTERVAL = 1000; + + private final FileAlterationMonitor monitor; + + public DefaultDirectoryMonitor() { + monitor = new FileAlterationMonitor(POLL_INTERVAL); + start(); + } + + @Override + public void addObserver(FileAlterationObserver observer, FileAlterationListener listener) { + if (observer != null) { + observer.addListener(listener); + monitor.addObserver(observer); + } + } + + @Override + public void removeObserver(FileAlterationObserver observer) { + if (observer != null) { + monitor.removeObserver(observer); + } + } + + public void start() { + try { + monitor.start(); + } catch (Exception e) { + LOGGER.error("Error starting directory monitor", e); + } + } + + @Override + public void shutdown() { + try { + monitor.stop(); + } catch (Exception e) { + LOGGER.error("Error stopping directory monitor", e); + } + } +} diff --git a/src/main/java/org/jabref/logic/texparser/CitationFinder.java b/src/main/java/org/jabref/logic/texparser/CitationFinder.java deleted file mode 100644 index fac31569876..00000000000 --- a/src/main/java/org/jabref/logic/texparser/CitationFinder.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.jabref.logic.texparser; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Collection; -import java.util.List; -import java.util.stream.Stream; - -import org.jabref.model.texparser.Citation; -import org.jabref.model.texparser.LatexParserResults; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class CitationFinder { - - private static final Logger LOGGER = LoggerFactory.getLogger(CitationFinder.class); - private static final String TEX_EXT = ".tex"; - private final Path directory; - - private LatexParserResults latexParserResults; - - public CitationFinder(Path directory) { - this.directory = directory; - } - - public Collection searchAndParse(String citeKey) throws IOException { - if (latexParserResults == null) { - if (!Files.exists(directory)) { - throw new IOException("Current search directory does not exist: %s".formatted(directory)); - } - - List texFiles = searchDirectory(directory); - LOGGER.debug("Found tex files: {}", texFiles); - latexParserResults = new DefaultLatexParser().parse(texFiles); - } - - return latexParserResults.getCitationsByKey(citeKey); - } - - /** - * @param directory the directory to search for. It is recursively searched. - */ - private List searchDirectory(Path directory) { - LOGGER.debug("Searching directory {}", directory); - try (Stream paths = Files.walk(directory)) { - return paths.filter(Files::isRegularFile) - .filter(path -> path.toString().endsWith(TEX_EXT)) - .toList(); - } catch (IOException e) { - LOGGER.error("Error while searching files", e); - return List.of(); - } - } -} diff --git a/src/main/java/org/jabref/logic/texparser/DefaultLatexParser.java b/src/main/java/org/jabref/logic/texparser/DefaultLatexParser.java index 385c3b8b94e..1e24671c21e 100644 --- a/src/main/java/org/jabref/logic/texparser/DefaultLatexParser.java +++ b/src/main/java/org/jabref/logic/texparser/DefaultLatexParser.java @@ -12,7 +12,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,12 +49,6 @@ public class DefaultLatexParser implements LatexParser { private static final Pattern INCLUDE_PATTERN = Pattern.compile( "\\\\(?:include|input)\\{(?<%s>[^\\}]*)\\}".formatted(INCLUDE_GROUP)); - private final LatexParserResults latexParserResults; - - public DefaultLatexParser() { - this.latexParserResults = new LatexParserResults(); - } - @Override public LatexParserResult parse(String citeString) { Path path = Path.of(""); @@ -70,6 +63,7 @@ public Optional parse(Path latexFile) { LOGGER.error("File does not exist: {}", latexFile); return Optional.empty(); } + LatexParserResult latexParserResult = new LatexParserResult(latexFile); try (InputStream inputStream = Files.newInputStream(latexFile); @@ -99,20 +93,9 @@ public Optional parse(Path latexFile) { @Override public LatexParserResults parse(List latexFiles) { - for (Path latexFile : latexFiles) { - if (!latexParserResults.isParsed(latexFile)) { - parse(latexFile).ifPresent(parsedTex -> latexParserResults.add(latexFile, parsedTex)); - } - } - - Set nonParsedNestedFiles = latexParserResults.getNonParsedNestedFiles(); - // Parse all "non-parsed" files referenced by TEX files, recursively. - if (!nonParsedNestedFiles.isEmpty()) { - // modifies class variable latexParserResults - parse(nonParsedNestedFiles.stream().toList()); - } - - return latexParserResults; + LatexParserResults results = new LatexParserResults(); + latexFiles.forEach(file -> parse(file).ifPresent(result -> results.add(file, result))); + return results; } /** diff --git a/src/main/java/org/jabref/model/texparser/LatexParserResults.java b/src/main/java/org/jabref/model/texparser/LatexParserResults.java index 63245fea4ea..d09df07408a 100644 --- a/src/main/java/org/jabref/model/texparser/LatexParserResults.java +++ b/src/main/java/org/jabref/model/texparser/LatexParserResults.java @@ -28,31 +28,20 @@ public LatexParserResults(LatexParserResult... parsedFiles) { } } - public boolean isParsed(Path texFile) { - return parsedTexFiles.containsKey(texFile); - } - public void add(Path texFile, LatexParserResult parsedFile) { parsedTexFiles.put(texFile, parsedFile); } + public LatexParserResult remove(Path texFile) { + return parsedTexFiles.remove(texFile); + } + public Set getBibFiles() { Set bibFiles = new HashSet<>(); parsedTexFiles.values().forEach(result -> bibFiles.addAll(result.getBibFiles())); return bibFiles; } - public Set getNonParsedNestedFiles() { - Set nonParsedNestedFiles = new HashSet<>(); - for (LatexParserResult result : parsedTexFiles.values()) { - nonParsedNestedFiles.addAll(result.getNestedFiles() - .stream() - .filter(nestedFile -> !parsedTexFiles.containsKey(nestedFile)) - .toList()); - } - return nonParsedNestedFiles; - } - public Multimap getCitations() { Multimap citations = HashMultimap.create(); parsedTexFiles.forEach((path, result) -> citations.putAll(result.getCitations())); @@ -65,6 +54,10 @@ public Collection getCitationsByKey(String key) { return citations; } + public void clear() { + parsedTexFiles.clear(); + } + @Override public boolean equals(Object obj) { if (this == obj) { diff --git a/src/main/java/org/jabref/model/util/DirectoryMonitor.java b/src/main/java/org/jabref/model/util/DirectoryMonitor.java new file mode 100644 index 00000000000..0678704c8a1 --- /dev/null +++ b/src/main/java/org/jabref/model/util/DirectoryMonitor.java @@ -0,0 +1,25 @@ +package org.jabref.model.util; + +import org.apache.commons.io.monitor.FileAlterationListener; +import org.apache.commons.io.monitor.FileAlterationObserver; + +public interface DirectoryMonitor { + /** + * Add an observer to the monitor. + * + * @param observer The directory to observe. + * @param listener The listener to invoke when the directory changes. + */ + void addObserver(FileAlterationObserver observer, FileAlterationListener listener); + + /** + * Remove an observer from the monitor. + * + * @param observer The directory to stop monitoring. + */ + void removeObserver(FileAlterationObserver observer); + + void start(); + + void shutdown(); +} diff --git a/src/main/java/org/jabref/model/util/DirectoryMonitorManager.java b/src/main/java/org/jabref/model/util/DirectoryMonitorManager.java new file mode 100644 index 00000000000..ba3c6546a8f --- /dev/null +++ b/src/main/java/org/jabref/model/util/DirectoryMonitorManager.java @@ -0,0 +1,36 @@ +package org.jabref.model.util; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.io.monitor.FileAlterationListener; +import org.apache.commons.io.monitor.FileAlterationObserver; + +public class DirectoryMonitorManager { + + private final DirectoryMonitor directoryMonitor; + private final List observers = new ArrayList<>(); + + public DirectoryMonitorManager(DirectoryMonitor directoryMonitor) { + this.directoryMonitor = directoryMonitor; + } + + public void addObserver(FileAlterationObserver observer, FileAlterationListener listener) { + directoryMonitor.addObserver(observer, listener); + observers.add(observer); + } + + public void removeObserver(FileAlterationObserver observer) { + directoryMonitor.removeObserver(observer); + observers.remove(observer); + } + + /** + * Unregister all observers associated with this manager from the directory monitor. + * This method should be called when the library is closed to stop watching observers. + */ + public void unregister() { + observers.forEach(directoryMonitor::removeObserver); + observers.clear(); + } +} diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index 21233b43e2f..8e7e5c82d98 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -2043,10 +2043,10 @@ LaTeX\ Citations=LaTeX Citations Search\ citations\ for\ this\ entry\ in\ LaTeX\ files=Search citations for this entry in LaTeX files No\ citations\ found=No citations found No\ LaTeX\ files\ containing\ this\ entry\ were\ found.=No LaTeX files containing this entry were found. +Current\ search\ directory\ does\ not\ exist\:\ %0= Current search directory does not exist: %0 Selected\ entry\ does\ not\ have\ an\ associated\ citation\ key.=Selected entry does not have an associated citation key. Current\ search\ directory\:=Current search directory: Set\ LaTeX\ file\ directory=Set LaTeX file directory -Refresh=Refresh Import\ entries\ from\ LaTeX\ files=Import entries from LaTeX files Import\ new\ entries=Import new entries Group\ color=Group color