diff --git a/CHANGELOG.md b/CHANGELOG.md index aadd804727a5..8638ed1a1d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added +- Directory libraries now stay in sync with external file changes: creating, editing, deleting, or renaming `.yml`/`.md`/`.pdf` files in the opened folder updates the open library live, and renames keep the affected entries (selection and undo history survive). [#738](https://github.com/JabRef/jabref-koppor/pull/738) - We added "Open folder as library" (File menu): a folder of PDFs and Hayagriva sidecar files (`.yml`, or `.md` notes with a Hayagriva frontmatter) opens as a library, and it is reopened on the next start. PDFs without a sidecar appear right away and get their metadata extracted in the background. Edits are not yet written back to the files. [#737](https://github.com/JabRef/jabref-koppor/pull/737) - We added `jabkit git merge-driver`, a Git merge driver that merges `.bib` files semantically. [#16838](https://github.com/JabRef/jabref/pull/16838) diff --git a/docs/requirements/directory-library.md b/docs/requirements/directory-library.md index 811a7cf9e03e..bb76c51e14e1 100644 --- a/docs/requirements/directory-library.md +++ b/docs/requirements/directory-library.md @@ -34,4 +34,17 @@ the last-opened list and routed back through the directory-library opener. Needs: impl +## External file changes appear live in an open directory library +`req~directory-library.inbound-sync~2` + +While a directory library is open, external creation, modification, deletion, and renaming of +`.yml`/`.yaml`/`.md`/`.pdf` files under its root must be reflected in the open library. Changed +entries are updated in place (the entry identity is preserved), renames are detected via a +grace window over the monitor's delete + create events and keep the affected entries, and files +written by JabRef itself are recognized by fingerprint and not re-imported. All resulting +database mutations carry a non-local event source so the future write-back direction can ignore +them. + +Needs: impl + diff --git a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java index 934ed603e93e..198e61eee537 100644 --- a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java +++ b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java @@ -34,7 +34,6 @@ import org.jabref.gui.remote.CLIMessageHandler; import org.jabref.gui.theme.ThemeManager; import org.jabref.gui.util.DefaultFileUpdateMonitor; -import org.jabref.gui.util.DirectoryMonitor; import org.jabref.gui.util.UiTaskExecutor; import org.jabref.gui.walkthrough.WalkthroughPane; import org.jabref.http.manager.HttpServerManager; @@ -55,6 +54,7 @@ import org.jabref.logic.search.sqlbased.IndexManager; import org.jabref.logic.search.sqlbased.PostgresServer; import org.jabref.logic.util.BuildInfo; +import org.jabref.logic.util.DirectoryMonitor; import org.jabref.logic.util.FallbackExceptionHandler; import org.jabref.logic.util.HeadlessExecutorService; import org.jabref.logic.util.TaskExecutor; diff --git a/jabgui/src/main/java/org/jabref/gui/LibraryTab.java b/jabgui/src/main/java/org/jabref/gui/LibraryTab.java index 680e6b4604af..580f2d81f2c8 100644 --- a/jabgui/src/main/java/org/jabref/gui/LibraryTab.java +++ b/jabgui/src/main/java/org/jabref/gui/LibraryTab.java @@ -940,6 +940,10 @@ private void onClosed(Event event) { if (dataLoadingTask != null) { dataLoadingTask.cancel(); } + if (bibDatabaseContext.getLocation() == DatabaseLocation.DIRECTORY) { + // Stops the directory watcher and shuts the synchronizer down + bibDatabaseContext.convertToLocalDatabase(); + } if (bibDatabaseContext.getLocation() == DatabaseLocation.SHARED) { closeSharedDatabase(bibDatabaseContext); } diff --git a/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java index 1e32cc711a1b..a6290462ddfe 100644 --- a/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java +++ b/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java @@ -36,7 +36,6 @@ import org.jabref.gui.undo.RedoAction; import org.jabref.gui.undo.UndoAction; import org.jabref.gui.util.BaseDialog; -import org.jabref.gui.util.DirectoryMonitor; import org.jabref.gui.util.DragDrop; import org.jabref.logic.ai.AiService; import org.jabref.logic.citation.SearchCitationsRelationsService; @@ -44,6 +43,7 @@ import org.jabref.logic.importer.EntryBasedFetcher; import org.jabref.logic.journals.JournalAbbreviationRepository; import org.jabref.logic.util.BuildInfo; +import org.jabref.logic.util.DirectoryMonitor; import org.jabref.logic.util.TaskExecutor; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.BibEntryTypesManager; diff --git a/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditorTabFactory.java b/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditorTabFactory.java index f81620b63878..eb69cb62819e 100644 --- a/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditorTabFactory.java +++ b/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditorTabFactory.java @@ -13,10 +13,10 @@ import org.jabref.gui.preview.PreviewPanel; import org.jabref.gui.undo.RedoAction; import org.jabref.gui.undo.UndoAction; -import org.jabref.gui.util.DirectoryMonitor; import org.jabref.logic.citation.SearchCitationsRelationsService; import org.jabref.logic.journals.JournalAbbreviationRepository; import org.jabref.logic.util.BuildInfo; +import org.jabref.logic.util.DirectoryMonitor; import org.jabref.logic.util.TaskExecutor; import org.jabref.model.entry.BibEntryTypesManager; import org.jabref.model.util.FileUpdateMonitor; diff --git a/jabgui/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTab.java b/jabgui/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTab.java index 3c859ca7d0c1..8b053d73d1dd 100644 --- a/jabgui/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTab.java +++ b/jabgui/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTab.java @@ -20,8 +20,8 @@ import org.jabref.gui.icon.IconTheme; import org.jabref.gui.preferences.GuiPreferences; import org.jabref.gui.texparser.CitationsDisplay; -import org.jabref.gui.util.DirectoryMonitor; import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.DirectoryMonitor; import org.jabref.model.entry.BibEntry; import com.tobiasdiez.easybind.EasyBind; diff --git a/jabgui/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTabViewModel.java b/jabgui/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTabViewModel.java index dd925cbc6f64..96eaa6fa1334 100644 --- a/jabgui/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTabViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/entryeditor/LatexCitationsTabViewModel.java @@ -27,10 +27,10 @@ import org.jabref.gui.push.GuiPushToTeXstudio; import org.jabref.gui.texparser.CitationsDisplay; import org.jabref.gui.util.DirectoryDialogConfiguration; -import org.jabref.gui.util.DirectoryMonitor; import org.jabref.gui.util.UiTaskExecutor; import org.jabref.logic.l10n.Localization; import org.jabref.logic.texparser.DefaultLatexParser; +import org.jabref.logic.util.DirectoryMonitor; import org.jabref.logic.util.io.FileUtil; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; diff --git a/jabgui/src/main/java/org/jabref/gui/importer/actions/OpenDirectoryLibraryAction.java b/jabgui/src/main/java/org/jabref/gui/importer/actions/OpenDirectoryLibraryAction.java index d0fc7e2fc1f0..b2c9468ceca5 100644 --- a/jabgui/src/main/java/org/jabref/gui/importer/actions/OpenDirectoryLibraryAction.java +++ b/jabgui/src/main/java/org/jabref/gui/importer/actions/OpenDirectoryLibraryAction.java @@ -16,15 +16,19 @@ import org.jabref.gui.util.UiTaskExecutor; import org.jabref.logic.ai.AiService; import org.jabref.logic.directorylibrary.DirectoryLibraryScanner; +import org.jabref.logic.directorylibrary.DirectoryLibrarySynchronizer; import org.jabref.logic.directorylibrary.PdfEnrichmentTask; import org.jabref.logic.directorylibrary.PdfEntryFactory; import org.jabref.logic.git.util.GitHandlerRegistry; import org.jabref.logic.l10n.Localization; import org.jabref.logic.util.BackgroundTask; +import org.jabref.logic.util.DirectoryMonitor; import org.jabref.logic.util.TaskExecutor; +import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntryTypesManager; import org.jabref.model.util.FileUpdateMonitor; +import com.airhacks.afterburner.injection.Injector; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -121,9 +125,18 @@ private void showLibraryTab(DirectoryLibraryScanner.ScanResult scanResult, PdfEn taskExecutor, gitHandlerRegistry); tabContainer.addTab(libraryTab, true); + // No change event follows the synchronous tab creation, so set the initial title here + libraryTab.updateTabTitle(false); + + BibDatabaseContext databaseContext = scanResult.databaseContext(); + DirectoryLibrarySynchronizer synchronizer = new DirectoryLibrarySynchronizer( + databaseContext, scanResult.catalog(), pdfEntryFactory, UiTaskExecutor::runInJavaFXThread); + databaseContext.attachDirectorySynchronizer(synchronizer); + synchronizer.startWatching(Injector.instantiateModelOrService(DirectoryMonitor.class)); + if (!scanResult.pendingPdfImports().isEmpty()) { PdfEnrichmentTask enrichment = new PdfEnrichmentTask(scanResult.pendingPdfImports(), pdfEntryFactory, - scanResult.databaseContext(), UiTaskExecutor::runInJavaFXThread); + databaseContext, UiTaskExecutor::runInJavaFXThread); enrichment.onFailure(exception -> LOGGER.error("Extracting PDF metadata failed", exception)); cancelOnClose(libraryTab, enrichment); enrichment.executeWith(taskExecutor); diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index db59b3beb941..073698f93c79 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -244,7 +244,7 @@ requires transitive com.google.common; requires java.string.similarity; requires transitive org.apache.commons.csv; - requires org.apache.commons.io; + requires transitive org.apache.commons.io; requires org.apache.commons.lang3; requires org.apache.commons.text; // endregion diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryCatalog.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryCatalog.java index 76b38d469522..53a0b6bf4ab5 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryCatalog.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryCatalog.java @@ -40,4 +40,18 @@ public Optional sourceOf(BibEntry entry) { public List entryIdsIn(Path yamlFile) { return List.copyOf(entryIdsByFile.getOrDefault(yamlFile, List.of())); } + + /// Re-homes all entries of `oldFile` to `newFile` (a rename/move on disk). + public void relocateFile(Path oldFile, Path newFile) { + Optional.ofNullable(entryIdsByFile.remove(oldFile)).ifPresent(entryIds -> { + entryIdsByFile.put(newFile, entryIds); + entryIds.forEach(entryId -> sourceByEntryId.computeIfPresent(entryId, + (_, source) -> new EntrySource(newFile, source.hayagrivaKey()))); + }); + } + + /// Forgets all entries of the given file (deleted on disk or re-registered afterwards). + public void removeFile(Path yamlFile) { + Optional.ofNullable(entryIdsByFile.remove(yamlFile)).ifPresent(entryIds -> entryIds.forEach(sourceByEntryId::remove)); + } } diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java new file mode 100644 index 000000000000..f6573a467afb --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -0,0 +1,517 @@ +package org.jabref.logic.directorylibrary; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.SequencedMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.jabref.logic.bibtex.FileFieldWriter; +import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.importer.fileformat.HayagrivaImporter; +import org.jabref.logic.util.DirectoryMonitor; +import org.jabref.logic.util.StandardFileType; +import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.LinkedFile; +import org.jabref.model.entry.event.EntriesEventSource; +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.StandardField; + +import org.apache.commons.io.IOCase; +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.apache.commons.io.monitor.FileEntry; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/// Keeps an open directory library in sync with external file changes (inbound direction: +/// file system to [BibDatabaseContext]). Registered as a [FileAlterationListener] with the +/// polling [DirectoryMonitor]; all event handling is serialized on a single "directory-sync" +/// executor, and model mutations are marshalled through the injected `modelUpdateMarshaller` +/// (the GUI passes the JavaFX thread executor). +/// +/// All database mutations use [EntriesEventSource#SHARED] so that the future write-back +/// direction can ignore them (same echo-prevention policy as the shared-SQL synchronizer). +/// Conversely, [#recordWrittenFile] lets the write-back direction register a fingerprint of +/// its own writes, which this class then swallows instead of re-importing. +/// +/// The file monitor reports renames as delete + create. Deletions are therefore staged for a +/// grace period spanning two poll cycles: a create whose parsed entries equal a staged +/// deletion's entries is treated as a move (the [BibEntry] instances survive, preserving +/// selection and undo history); only unmatched deletions are committed. +/// +/// Sidecars come in two forms (see [MarkdownSidecar]): plain Hayagriva `.yml`/`.yaml` files and +/// Markdown `.md` files whose Hayagriva frontmatter carries the data; both are watched alike. +// [impl->req~directory-library.inbound-sync~2] +@NullMarked +public class DirectoryLibrarySynchronizer implements FileAlterationListener { + + private static final Logger LOGGER = LoggerFactory.getLogger(DirectoryLibrarySynchronizer.class); + + /// Two poll cycles of [DirectoryMonitor], so a rename's create event can arrive in the poll + /// cycle after its delete event. + private static final Duration RENAME_GRACE = DirectoryMonitor.POLL_INTERVAL.multipliedBy(2).plusMillis(500); + + /// In precedence order when several sidecars share a base name. + private static final List SIDECAR_EXTENSIONS = List.of("yml", "yaml", MarkdownSidecar.MARKDOWN_EXTENSION); + private static final String PDF_EXTENSION = "pdf"; + + private final BibDatabaseContext databaseContext; + private final DirectoryLibraryCatalog catalog; + private final PdfEntryFactory pdfEntryFactory; + private final Path root; + private final Consumer modelUpdateMarshaller; + private final Clock clock; + private final HayagrivaImporter importer = new HayagrivaImporter(); + private final MarkdownSidecar markdownSidecar = new MarkdownSidecar(); + private final ScheduledExecutorService syncExecutor; + + private final Map stagedDeletions = new HashMap<>(); + private final Map lastWrittenFingerprints = new HashMap<>(); + + private @Nullable Watch watch; + + private record StagedDeletion(List entries, Instant expiry) { + } + + private record Watch(DirectoryMonitor monitor, FileAlterationObserver observer) { + } + + public DirectoryLibrarySynchronizer(BibDatabaseContext databaseContext, + DirectoryLibraryCatalog catalog, + PdfEntryFactory pdfEntryFactory, + Consumer modelUpdateMarshaller) { + this(databaseContext, catalog, pdfEntryFactory, modelUpdateMarshaller, Clock.systemUTC()); + } + + DirectoryLibrarySynchronizer(BibDatabaseContext databaseContext, + DirectoryLibraryCatalog catalog, + PdfEntryFactory pdfEntryFactory, + Consumer modelUpdateMarshaller, + Clock clock) { + this.databaseContext = databaseContext; + this.catalog = catalog; + this.pdfEntryFactory = pdfEntryFactory; + this.root = databaseContext.getDirectoryLibraryRoot().orElseThrow( + () -> new IllegalArgumentException("Context is not a directory library")); + this.modelUpdateMarshaller = modelUpdateMarshaller; + this.clock = clock; + // A dedicated single thread (not BackgroundTask: events must be serialized and writes + // debounced). Events polled while this synchronizer shuts down are dropped instead of + // throwing into the shared monitor thread. + ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, + Thread.ofPlatform().name("directory-sync").daemon(true).factory()); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + this.syncExecutor = executor; + } + + public void startWatching(DirectoryMonitor monitor) { + IOFileFilter relevantFiles = FileFilterUtils.or( + FileFilterUtils.directoryFileFilter(), + FileFilterUtils.suffixFileFilter(".yml", IOCase.INSENSITIVE), + FileFilterUtils.suffixFileFilter(".yaml", IOCase.INSENSITIVE), + FileFilterUtils.suffixFileFilter(".md", IOCase.INSENSITIVE), + FileFilterUtils.suffixFileFilter(".pdf", IOCase.INSENSITIVE)); + IOFileFilter notHidden = FileFilterUtils.notFileFilter(FileFilterUtils.prefixFileFilter(".")); + FileAlterationObserver observer = FileAlterationObserver.builder() + .setRootEntry(new FileEntry(root.toFile())) + .setFileFilter(FileFilterUtils.and(notHidden, relevantFiles)) + .getUnchecked(); + watch = new Watch(monitor, observer); + // The monitor is already running and never initializes late-joining observers, so the + // first poll would report every existing file as created. Checking once without any + // listener attached takes the baseline snapshot silently — off the caller's thread, + // since it walks the whole tree. + syncExecutor.execute(() -> { + observer.checkAndNotify(); + monitor.addObserver(observer, this); + }); + } + + public void shutdown() { + Optional.ofNullable(watch).ifPresent(active -> active.monitor().removeObserver(active.observer())); + syncExecutor.shutdown(); + } + + /// Registers the fingerprint of a file this application just wrote itself, so the next + /// change event for it is recognized as a self-echo and not re-imported. Consumed on match. + public synchronized void recordWrittenFile(Path file, byte[] content) { + lastWrittenFingerprints.put(file.toAbsolutePath().normalize(), hash(content)); + } + + @Override + public void onFileCreate(File file) { + syncExecutor.execute(() -> handleFileCreated(file.toPath())); + } + + @Override + public void onFileChange(File file) { + syncExecutor.execute(() -> handleFileChanged(file.toPath())); + } + + @Override + public void onFileDelete(File file) { + syncExecutor.execute(() -> handleFileDeleted(file.toPath())); + } + + @Override + public void onDirectoryCreate(File directory) { + // files inside are reported individually + } + + @Override + public void onDirectoryChange(File directory) { + // files inside are reported individually + } + + @Override + public void onDirectoryDelete(File directory) { + // files inside are reported individually + } + + @Override + public void onStart(FileAlterationObserver observer) { + // no bookkeeping per scan round needed + } + + @Override + public void onStop(FileAlterationObserver observer) { + syncExecutor.execute(this::commitExpiredStagedDeletions); + } + + synchronized void handleFileCreated(Path file) { + commitExpiredStagedDeletions(); + if (isSidecar(file)) { + if (consumeSelfEcho(file)) { + return; + } + importFile(file); + } else if (isPdf(file)) { + handlePdfCreated(file); + } + } + + synchronized void handleFileChanged(Path file) { + commitExpiredStagedDeletions(); + if (!isSidecar(file) || consumeSelfEcho(file)) { + return; + } + List knownEntries = entriesOf(file); + if (knownEntries.isEmpty()) { + importFile(file); + return; + } + if (!looksLikeSidecar(file)) { + // The file stopped being a sidecar — or an editor that truncates and rewrites was + // polled mid-write, so the entries are only staged: a complete sidecar arriving + // within the grace window keeps them + stageDeletion(file, knownEntries); + return; + } + parse(file).ifPresentOrElse(parsedEntries -> { + stagedDeletions.remove(file); + applyChangedFile(file, knownEntries, parsedEntries); + }, () -> LOGGER.warn("Not applying changes of unparseable Hayagriva file {}", file)); + } + + synchronized void handleFileDeleted(Path file) { + commitExpiredStagedDeletions(); + if (isSidecar(file)) { + List entries = entriesOf(file); + if (entries.isEmpty()) { + return; + } + stageDeletion(file, entries); + } else if (isPdf(file)) { + handlePdfDeleted(file); + } + } + + private void stageDeletion(Path file, List entries) { + stagedDeletions.put(file, new StagedDeletion(entries, clock.instant().plus(RENAME_GRACE))); + syncExecutor.schedule(this::commitExpiredStagedDeletions, RENAME_GRACE.toMillis() + 100, TimeUnit.MILLISECONDS); + } + + synchronized void commitExpiredStagedDeletions() { + Instant now = clock.instant(); + List> expired = stagedDeletions.entrySet().stream() + .filter(staged -> !staged.getValue().expiry().isAfter(now)) + .toList(); + for (Map.Entry staged : expired) { + stagedDeletions.remove(staged.getKey()); + removeEntries(staged.getValue().entries(), staged.getKey()); + } + } + + private void importFile(Path file) { + if (!entriesOf(file).isEmpty()) { + // Already known: a create event for a file the scan covered, or a deletion undone + // within the grace window — diff instead of importing twice + stagedDeletions.remove(file); + handleFileChanged(file); + return; + } + if (!looksLikeSidecar(file)) { + return; + } + List newEntries = parse(file).orElse(List.of()); + if (newEntries.isEmpty()) { + return; + } + + // A staged deletion with equal content is this file being moved, not new content + stagedDeletions.entrySet().stream() + .filter(staged -> entriesMatch(staged.getValue().entries(), newEntries)) + .map(Map.Entry::getKey) + .findFirst() + .ifPresentOrElse(movedFrom -> { + stagedDeletions.remove(movedFrom); + catalog.relocateFile(movedFrom, file); + LOGGER.debug("Detected move {} -> {}", movedFrom, file); + }, () -> insertNewEntries(file, newEntries)); + } + + private void insertNewEntries(Path file, List newEntries) { + newEntries.forEach(entry -> catalog.register(entry, file, entry.getCitationKey().orElseThrow())); + // Safe without event source: the entry is not yet inserted, so no listeners see this + findPairedPdf(file).ifPresent(pdf -> newEntries.getFirst() + .addFile(new LinkedFile("", root.relativize(pdf), StandardFileType.PDF.getName()))); + modelUpdateMarshaller.accept(() -> + databaseContext.getDatabase().insertEntries(newEntries, EntriesEventSource.SHARED)); + } + + private void applyChangedFile(Path file, List knownEntries, List parsedEntries) { + SequencedMap knownByKey = byCitationKey(knownEntries); + SequencedMap parsedByKey = byCitationKey(parsedEntries); + + List toInsert = new ArrayList<>(); + List toRemove = new ArrayList<>(); + List fieldUpdates = new ArrayList<>(); + + parsedByKey.forEach((key, parsedEntry) -> + Optional.ofNullable(knownByKey.get(key)).ifPresentOrElse( + knownEntry -> fieldUpdates.add(() -> copyContent(parsedEntry, knownEntry)), + () -> { + catalog.register(parsedEntry, file, key); + toInsert.add(parsedEntry); + })); + knownByKey.forEach((key, knownEntry) -> { + if (!parsedByKey.containsKey(key)) { + toRemove.add(knownEntry); + } + }); + + modelUpdateMarshaller.accept(() -> { + fieldUpdates.forEach(Runnable::run); + if (!toInsert.isEmpty()) { + databaseContext.getDatabase().insertEntries(toInsert, EntriesEventSource.SHARED); + } + if (!toRemove.isEmpty()) { + databaseContext.getDatabase().removeEntries(toRemove, EntriesEventSource.SHARED); + } + }); + catalog.removeFile(file); + parsedByKey.forEach((key, parsedEntry) -> { + BibEntry target = knownByKey.getOrDefault(key, parsedEntry); + catalog.register(target, file, key); + }); + } + + /// Applies `source`'s type and fields onto `target` without replacing the instance, so + /// selection, undo history, and group membership survive external edits. + private void copyContent(BibEntry source, BibEntry target) { + if (!target.getType().equals(source.getType())) { + target.setType(source.getType(), EntriesEventSource.SHARED); + } + // The PDF link is maintained by this synchronizer, not by the file content + Optional preservedFiles = target.getField(StandardField.FILE); + source.getFields().forEach(field -> + source.getField(field).ifPresent(value -> target.setField(field, value, EntriesEventSource.SHARED))); + target.getFields().stream() + .filter(field -> StandardField.FILE != field) + .filter(field -> source.getField(field).isEmpty()) + .toList() + .forEach(field -> target.clearField(field, EntriesEventSource.SHARED)); + preservedFiles.ifPresent(files -> target.setField(StandardField.FILE, files, EntriesEventSource.SHARED)); + } + + private void handlePdfCreated(Path pdf) { + Optional sidecarEntry = findSidecarEntry(pdf); + if (sidecarEntry.isPresent()) { + BibEntry entry = sidecarEntry.get(); + if (entry.getFiles().isEmpty()) { + List files = List.of(new LinkedFile("", root.relativize(pdf), StandardFileType.PDF.getName())); + modelUpdateMarshaller.accept(() -> + entry.setField(StandardField.FILE, FileFieldWriter.getStringRepresentation(files), EntriesEventSource.SHARED)); + } + return; + } + BibEntry entry = pdfEntryFactory.createEntry(pdf, root, databaseContext); + modelUpdateMarshaller.accept(() -> { + databaseContext.getDatabase().insertEntries(List.of(entry), EntriesEventSource.SHARED); + pdfEntryFactory.generateCitationKeyIfMissing(entry, databaseContext); + }); + } + + private void handlePdfDeleted(Path pdf) { + String relativeLink = root.relativize(pdf).toString(); + List linking = databaseContext.getDatabase().getEntries().stream() + .filter(entry -> entry.getFiles().stream() + .anyMatch(linked -> relativeLink.equals(linked.getLink()))) + .toList(); + for (BibEntry entry : linking) { + boolean isStub = catalog.sourceOf(entry).isEmpty(); + modelUpdateMarshaller.accept(() -> { + if (isStub) { + databaseContext.getDatabase().removeEntries(List.of(entry), EntriesEventSource.SHARED); + } else { + List remaining = entry.getFiles().stream() + .filter(linked -> !relativeLink.equals(linked.getLink())) + .toList(); + entry.setField(StandardField.FILE, FileFieldWriter.getStringRepresentation(remaining), EntriesEventSource.SHARED); + } + }); + } + } + + private void removeEntries(List entries, Path file) { + catalog.removeFile(file); + modelUpdateMarshaller.accept(() -> + databaseContext.getDatabase().removeEntries(entries, EntriesEventSource.SHARED)); + } + + private List entriesOf(Path file) { + List ids = catalog.entryIdsIn(file); + if (ids.isEmpty()) { + return List.of(); + } + Map byId = new HashMap<>(); + databaseContext.getDatabase().getEntries().forEach(entry -> byId.put(entry.getId(), entry)); + return ids.stream().flatMap(id -> Optional.ofNullable(byId.get(id)).stream()).toList(); + } + + private static SequencedMap byCitationKey(List entries) { + SequencedMap byKey = new LinkedHashMap<>(); + entries.forEach(entry -> byKey.putIfAbsent(entry.getCitationKey().orElse(""), entry)); + return byKey; + } + + private static boolean entriesMatch(List staged, List parsed) { + return staged.size() == parsed.size() + && IntStream.range(0, staged.size()).allMatch(i -> sameContent(staged.get(i), parsed.get(i))); + } + + /// Live entries carry the PDF link this synchronizer maintains; freshly parsed ones do not. + private static boolean sameContent(BibEntry live, BibEntry parsed) { + return live.getType().equals(parsed.getType()) && fieldsWithoutFile(live).equals(fieldsWithoutFile(parsed)); + } + + private static Map fieldsWithoutFile(BibEntry entry) { + return entry.getFieldMap().entrySet().stream() + .filter(field -> StandardField.FILE != field.getKey()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private Optional> parse(Path file) { + try { + ParserResult parserResult = MarkdownSidecar.hasMarkdownExtension(file) + ? markdownSidecar.read(file) + : importer.importDatabase(file); + if (parserResult.isInvalid()) { + return Optional.empty(); + } + return Optional.of(parserResult.getDatabase().getEntries()); + } catch (IOException e) { + LOGGER.warn("Could not read {}", file, e); + return Optional.empty(); + } + } + + private boolean looksLikeSidecar(Path file) { + try { + if (MarkdownSidecar.hasMarkdownExtension(file)) { + return markdownSidecar.looksLikeSidecar(file); + } + try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + return importer.isRecognizedFormat(reader); + } + } catch (IOException e) { + LOGGER.warn("Could not read {}", file, e); + return false; + } + } + + private Optional findSidecarEntry(Path pdf) { + String baseName = FileUtil.getBaseName(pdf); + return SIDECAR_EXTENSIONS.stream() + .map(extension -> entriesOf(pdf.resolveSibling(baseName + "." + extension))) + .filter(entries -> !entries.isEmpty()) + .map(List::getFirst) + .findFirst(); + } + + private Optional findPairedPdf(Path yamlFile) { + return Optional.of(yamlFile.resolveSibling(FileUtil.getBaseName(yamlFile) + ".pdf")).filter(Files::exists); + } + + private boolean consumeSelfEcho(Path file) { + Path normalized = file.toAbsolutePath().normalize(); + if (!lastWrittenFingerprints.containsKey(normalized)) { + return false; + } + return currentHash(file).map(current -> lastWrittenFingerprints.remove(normalized, current)).orElse(false); + } + + private static Optional currentHash(Path file) { + try { + return Optional.of(hash(Files.readAllBytes(file))); + } catch (IOException e) { + LOGGER.debug("Could not fingerprint {}", file, e); + return Optional.empty(); + } + } + + private static String hash(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("SHA-256 is guaranteed to be available", e); + } + } + + private static boolean isSidecar(Path file) { + return SIDECAR_EXTENSIONS.contains(FileUtil.getFileExtension(file).orElse("").toLowerCase(Locale.ROOT)); + } + + private static boolean isPdf(Path file) { + return PDF_EXTENSION.equals(FileUtil.getFileExtension(file).orElse("").toLowerCase(Locale.ROOT)); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/util/DirectoryMonitor.java b/jablib/src/main/java/org/jabref/logic/util/DirectoryMonitor.java similarity index 85% rename from jabgui/src/main/java/org/jabref/gui/util/DirectoryMonitor.java rename to jablib/src/main/java/org/jabref/logic/util/DirectoryMonitor.java index def65f9549f2..6a3f14edc3bf 100644 --- a/jabgui/src/main/java/org/jabref/gui/util/DirectoryMonitor.java +++ b/jablib/src/main/java/org/jabref/logic/util/DirectoryMonitor.java @@ -1,4 +1,6 @@ -package org.jabref.gui.util; +package org.jabref.logic.util; + +import java.time.Duration; import org.apache.commons.io.monitor.FileAlterationListener; import org.apache.commons.io.monitor.FileAlterationMonitor; @@ -8,13 +10,14 @@ public class DirectoryMonitor { + public static final Duration POLL_INTERVAL = Duration.ofSeconds(1); + private static final Logger LOGGER = LoggerFactory.getLogger(DirectoryMonitor.class); - private static final int POLL_INTERVAL = 1000; private final FileAlterationMonitor monitor; public DirectoryMonitor() { - monitor = new FileAlterationMonitor(POLL_INTERVAL); + monitor = new FileAlterationMonitor(POLL_INTERVAL.toMillis()); start(); } diff --git a/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java b/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java index ff217fa28b29..7e3b250fac2e 100644 --- a/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java +++ b/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java @@ -20,6 +20,7 @@ import org.jabref.logic.JabRefException; import org.jabref.logic.crawler.Crawler; import org.jabref.logic.crawler.StudyRepository; +import org.jabref.logic.directorylibrary.DirectoryLibrarySynchronizer; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.ParserResult; import org.jabref.logic.importer.fileformat.BibtexParser; @@ -73,6 +74,9 @@ public class BibDatabaseContext { @Nullable private Path directoryLibraryRoot; + @Nullable + private DirectoryLibrarySynchronizer directorySynchronizer; + private DatabaseLocation location; public BibDatabaseContext() { @@ -297,6 +301,14 @@ public Optional getPathOnDisk() { return getDatabasePath().or(this::getDirectoryLibraryRoot); } + public void attachDirectorySynchronizer(DirectoryLibrarySynchronizer directorySynchronizer) { + this.directorySynchronizer = directorySynchronizer; + } + + public @Nullable DirectoryLibrarySynchronizer getDirectorySynchronizer() { + return directorySynchronizer; + } + public void convertToLocalDatabase() { if (dbmsListener != null && (location == DatabaseLocation.SHARED)) { if (dbmsSynchronizer != null) { @@ -304,6 +316,10 @@ public void convertToLocalDatabase() { } dbmsListener.shutdown(); } + if (directorySynchronizer != null) { + directorySynchronizer.shutdown(); + directorySynchronizer = null; + } this.directoryLibraryRoot = null; this.location = DatabaseLocation.LOCAL; diff --git a/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java new file mode 100644 index 000000000000..a2616e67f92f --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java @@ -0,0 +1,372 @@ +package org.jabref.logic.directorylibrary; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import javafx.collections.FXCollections; + +import org.jabref.logic.FilePreferences; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.logic.importer.fetcher.CrossRef; +import org.jabref.logic.importer.fetcher.DoiFetcher; +import org.jabref.logic.importer.util.GrobidPreferences; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DirectoryLibrarySynchronizerTest { + + private static final String ARTICLE_YAML = """ + smith2020: + type: article + title: A Test Article + author: Smith, Jane + note: first version + """; + + private static final String MARKDOWN_SIDECAR = """ + --- + smith2020: + type: article + title: A Test Article + author: Smith, Jane + --- + + # Notes + + Shared comment text. + """; + + /// Deterministic clock for the rename grace window. + private static final class SteppingClock extends Clock { + private Instant now = Instant.parse("2026-07-13T12:00:00Z"); + + private void advance(Duration duration) { + now = now.plus(duration); + } + + @Override + public Instant instant() { + return now; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + } + + @TempDir + Path root; + + private final SteppingClock clock = new SteppingClock(); + + private BibDatabaseContext context; + private DirectoryLibrarySynchronizer synchronizer; + + private void openLibrary() throws IOException { + PdfEntryFactory pdfEntryFactory = offlinePdfEntryFactory(); + DirectoryLibraryScanner.ScanResult scanResult = new DirectoryLibraryScanner(pdfEntryFactory).scan(root); + context = scanResult.databaseContext(); + synchronizer = new DirectoryLibrarySynchronizer(context, scanResult.catalog(), pdfEntryFactory, Runnable::run, clock); + } + + /// GROBID off and no identifiers in the fixtures, so no network is touched + private static PdfEntryFactory offlinePdfEntryFactory() { + GrobidPreferences noGrobid = mock(GrobidPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(noGrobid.isGrobidEnabled()).thenReturn(false); + ImportFormatPreferences importFormatPreferences = mock(ImportFormatPreferences.class, Answers.RETURNS_DEEP_STUBS); + when(importFormatPreferences.fieldPreferences().getNonWrappableFields()).thenReturn(FXCollections.emptyObservableList()); + when(importFormatPreferences.grobidPreferences()).thenReturn(noGrobid); + return new PdfEntryFactory(importFormatPreferences, mock(FilePreferences.class, Answers.RETURNS_DEEP_STUBS), + DirectoryLibraryScannerTest.authYearPatternPreferences(), mock(CrossRef.class), mock(DoiFetcher.class)); + } + + @AfterEach + void shutdown() { + synchronizer.shutdown(); + } + + private List entries() { + return context.getDatabase().getEntries(); + } + + @Test + void externallyCreatedSidecarAddsEntryAndLinksPdf() throws IOException { + Files.createFile(root.resolve("smith2020.pdf")); + openLibrary(); + // The bare PDF became a stub during the scan; an appearing sidecar adds its entry + Path sidecar = root.resolve("smith2020.yml"); + Files.writeString(sidecar, ARTICLE_YAML); + + synchronizer.handleFileCreated(sidecar); + + assertEquals(2, entries().size()); + BibEntry added = entries().getLast(); + assertEquals(Optional.of("smith2020"), added.getCitationKey()); + assertEquals(1, added.getFiles().size()); + } + + @Test + void externalChangeUpdatesTheSameEntryInstance() throws IOException { + Path sidecar = root.resolve("smith2020.yml"); + Files.writeString(sidecar, ARTICLE_YAML); + Files.createFile(root.resolve("smith2020.pdf")); + openLibrary(); + BibEntry entry = entries().getFirst(); + + Files.writeString(sidecar, ARTICLE_YAML.replace("first version", "second version")); + synchronizer.handleFileChanged(sidecar); + + assertEquals(1, entries().size()); + assertSame(entry, entries().getFirst()); + assertEquals(Optional.of("second version"), entry.getField(StandardField.NOTE)); + assertEquals(1, entry.getFiles().size()); + } + + @Test + void externallyCreatedMarkdownSidecarAddsEntryWithComments() throws IOException { + openLibrary(); + Path sidecar = root.resolve("smith2020.md"); + Files.writeString(sidecar, MARKDOWN_SIDECAR); + + synchronizer.handleFileCreated(sidecar); + + assertEquals(1, entries().size()); + BibEntry added = entries().getFirst(); + assertEquals(Optional.of("smith2020"), added.getCitationKey()); + assertEquals(Optional.of("Shared comment text."), added.getField(StandardField.COMMENT)); + } + + @Test + void externalMarkdownChangeUpdatesCommentOnTheSameEntryInstance() throws IOException { + Path sidecar = root.resolve("smith2020.md"); + Files.writeString(sidecar, MARKDOWN_SIDECAR); + openLibrary(); + BibEntry entry = entries().getFirst(); + + Files.writeString(sidecar, MARKDOWN_SIDECAR.replace("Shared comment text.", "Updated comment text.")); + synchronizer.handleFileChanged(sidecar); + + assertEquals(1, entries().size()); + assertSame(entry, entries().getFirst()); + assertEquals(Optional.of("Updated comment text."), entry.getField(StandardField.COMMENT)); + } + + @Test + void externalChangeAddsAndRemovesEntriesOfMultiEntryFile() throws IOException { + Path file = root.resolve("collection.yml"); + Files.writeString(file, """ + first: + type: article + title: First + second: + type: article + title: Second + """); + openLibrary(); + + Files.writeString(file, """ + first: + type: article + title: First + third: + type: article + title: Third + """); + synchronizer.handleFileChanged(file); + + assertEquals(List.of(Optional.of("first"), Optional.of("third")), + entries().stream().map(BibEntry::getCitationKey).toList()); + } + + @Test + void externalDeleteRemovesEntriesOnlyAfterGraceWindow() throws IOException { + Path sidecar = root.resolve("smith2020.yml"); + Files.writeString(sidecar, ARTICLE_YAML); + openLibrary(); + + Files.delete(sidecar); + synchronizer.handleFileDeleted(sidecar); + assertEquals(1, entries().size()); + + clock.advance(Duration.ofSeconds(3)); + synchronizer.commitExpiredStagedDeletions(); + assertEquals(0, entries().size()); + } + + @Test + void renameIsDetectedAsMoveAndPreservesEntryInstance() throws IOException { + Path oldFile = root.resolve("smith2020.yml"); + Files.writeString(oldFile, ARTICLE_YAML); + openLibrary(); + BibEntry entry = entries().getFirst(); + + Path newFile = root.resolve("renamed.yml"); + Files.move(oldFile, newFile); + synchronizer.handleFileDeleted(oldFile); + synchronizer.handleFileCreated(newFile); + + clock.advance(Duration.ofSeconds(3)); + synchronizer.commitExpiredStagedDeletions(); + + assertEquals(List.of(entry), entries()); + } + + @Test + void renameOfSidecarWithPairedPdfIsDetectedAsMove() throws IOException { + Path oldFile = root.resolve("smith2020.yml"); + Files.writeString(oldFile, ARTICLE_YAML); + Files.createFile(root.resolve("smith2020.pdf")); + openLibrary(); + BibEntry entry = entries().getFirst(); + + Path newFile = root.resolve("renamed.yml"); + Files.move(oldFile, newFile); + synchronizer.handleFileDeleted(oldFile); + synchronizer.handleFileCreated(newFile); + clock.advance(Duration.ofSeconds(3)); + synchronizer.commitExpiredStagedDeletions(); + + assertEquals(List.of(entry), entries()); + assertEquals("smith2020.pdf", entry.getFiles().getFirst().getLink()); + } + + @Test + void deletionUndoneWithinGraceWindowKeepsEntry() throws IOException { + Path sidecar = root.resolve("smith2020.yml"); + Files.writeString(sidecar, ARTICLE_YAML); + openLibrary(); + BibEntry entry = entries().getFirst(); + + Files.delete(sidecar); + synchronizer.handleFileDeleted(sidecar); + Files.writeString(sidecar, ARTICLE_YAML.replace("first version", "restored version")); + synchronizer.handleFileCreated(sidecar); + clock.advance(Duration.ofSeconds(3)); + synchronizer.commitExpiredStagedDeletions(); + + assertEquals(List.of(entry), entries()); + assertEquals(Optional.of("restored version"), entry.getField(StandardField.NOTE)); + } + + @Test + void selfWrittenFileIsNotReimported() throws IOException { + openLibrary(); + Path sidecar = root.resolve("smith2020.yml"); + byte[] content = ARTICLE_YAML.getBytes(StandardCharsets.UTF_8); + Files.write(sidecar, content); + synchronizer.recordWrittenFile(sidecar, content); + + synchronizer.handleFileCreated(sidecar); + + assertEquals(0, entries().size()); + } + + @Test + void changeToNonHayagrivaContentRemovesItsEntriesAfterGraceWindow() throws IOException { + Path file = root.resolve("smith2020.yml"); + Files.writeString(file, ARTICLE_YAML); + openLibrary(); + + Files.writeString(file, """ + jobs: + build: + runs-on: ubuntu-latest + """); + synchronizer.handleFileChanged(file); + assertEquals(1, entries().size()); + + clock.advance(Duration.ofSeconds(3)); + synchronizer.commitExpiredStagedDeletions(); + assertEquals(List.of(), entries()); + } + + /// Editors that truncate and rewrite can be polled mid-write. + @Test + void sidecarCompletedWithinGraceWindowKeepsEntry() throws IOException { + Path file = root.resolve("smith2020.yml"); + Files.writeString(file, ARTICLE_YAML); + openLibrary(); + BibEntry entry = entries().getFirst(); + + Files.writeString(file, "smith2020:\n"); + synchronizer.handleFileChanged(file); + Files.writeString(file, ARTICLE_YAML.replace("first version", "second version")); + synchronizer.handleFileChanged(file); + clock.advance(Duration.ofSeconds(3)); + synchronizer.commitExpiredStagedDeletions(); + + assertEquals(List.of(entry), entries()); + assertEquals(Optional.of("second version"), entry.getField(StandardField.NOTE)); + } + + @Test + void createdPdfLinksToExistingSidecarEntry() throws IOException { + Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); + openLibrary(); + BibEntry entry = entries().getFirst(); + assertEquals(List.of(), entry.getFiles()); + + Path pdf = root.resolve("smith2020.pdf"); + Files.createFile(pdf); + synchronizer.handleFileCreated(pdf); + + assertEquals(1, entry.getFiles().size()); + assertEquals("smith2020.pdf", entry.getFiles().getFirst().getLink()); + } + + @Test + void createdPdfWithoutSidecarBecomesStub() throws IOException { + openLibrary(); + Path pdf = root.resolve("interesting-paper.pdf"); + Files.createFile(pdf); + + synchronizer.handleFileCreated(pdf); + + assertEquals(1, entries().size()); + assertEquals(Optional.of("interesting-paper"), entries().getFirst().getField(StandardField.TITLE)); + } + + @Test + void deletedPdfRemovesStubButKeepsSidecarEntry() throws IOException { + Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); + Files.createFile(root.resolve("smith2020.pdf")); + Files.createFile(root.resolve("loose.pdf")); + openLibrary(); + assertEquals(2, entries().size()); + + Files.delete(root.resolve("loose.pdf")); + synchronizer.handleFileDeleted(root.resolve("loose.pdf")); + assertEquals(1, entries().size()); + + Files.delete(root.resolve("smith2020.pdf")); + synchronizer.handleFileDeleted(root.resolve("smith2020.pdf")); + assertEquals(1, entries().size()); + assertEquals(List.of(), entries().getFirst().getFiles()); + } +}