From 3b039414cab8727b3902020c69c1c80d017e598a Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Thu, 3 Sep 2026 15:17:13 +0200 Subject: [PATCH 1/4] Split DirectoryLibrarySynchronizer into collaborators The synchronizer keeps the inbound direction and the lifecycle; SidecarWriteBack writes sidecars (including the pair rename), BibMirror maintains the .bib mirror and its merge-back, PendingWrites debounces and retries writes, and TrackedFiles holds the fingerprints and merge bases both directions share. Public API and behaviour are unchanged; the package gets its package-info. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Vr3E1Gg5DRU4LQDDVnhPys --- .../ConvertToDirectoryLibraryAction.java | 4 +- .../logic/directorylibrary/BibMirror.java | 251 +++++++ .../DirectoryLibraryConverter.java | 4 +- .../DirectoryLibrarySynchronizer.java | 652 +++--------------- .../logic/directorylibrary/PendingWrites.java | 101 +++ .../directorylibrary/SidecarWriteBack.java | 252 +++++++ .../logic/directorylibrary/TrackedFiles.java | 152 ++++ .../logic/directorylibrary/package-info.java | 25 + .../http/server/services/ServerUtils.java | 4 +- 9 files changed, 879 insertions(+), 566 deletions(-) create mode 100644 jablib/src/main/java/org/jabref/logic/directorylibrary/BibMirror.java create mode 100644 jablib/src/main/java/org/jabref/logic/directorylibrary/PendingWrites.java create mode 100644 jablib/src/main/java/org/jabref/logic/directorylibrary/SidecarWriteBack.java create mode 100644 jablib/src/main/java/org/jabref/logic/directorylibrary/TrackedFiles.java create mode 100644 jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java diff --git a/jabgui/src/main/java/org/jabref/gui/importer/actions/ConvertToDirectoryLibraryAction.java b/jabgui/src/main/java/org/jabref/gui/importer/actions/ConvertToDirectoryLibraryAction.java index 6126af938e26..5c8eb2116c84 100644 --- a/jabgui/src/main/java/org/jabref/gui/importer/actions/ConvertToDirectoryLibraryAction.java +++ b/jabgui/src/main/java/org/jabref/gui/importer/actions/ConvertToDirectoryLibraryAction.java @@ -13,8 +13,8 @@ import org.jabref.gui.actions.SimpleCommand; import org.jabref.gui.exporter.SaveDatabaseAction; import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.directorylibrary.BibMirror; import org.jabref.logic.directorylibrary.DirectoryLibraryConverter; -import org.jabref.logic.directorylibrary.DirectoryLibrarySynchronizer; import org.jabref.logic.journals.JournalAbbreviationRepository; import org.jabref.logic.l10n.Localization; import org.jabref.logic.util.BackgroundTask; @@ -82,7 +82,7 @@ private void convert(LibraryTab libraryTab, BibDatabaseContext context, Path roo return; } - Path mirrorTarget = root.resolve(DirectoryLibrarySynchronizer.mirrorFileName(root)); + Path mirrorTarget = root.resolve(BibMirror.fileName(root)); boolean overwritesForeignFile = context.getDatabasePath().filter(not(mirrorTarget::equals)).isPresent() && Files.exists(mirrorTarget); if (overwritesForeignFile) { dialogService.showErrorDialogAndWait(title, diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/BibMirror.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/BibMirror.java new file mode 100644 index 000000000000..b794931748f4 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/BibMirror.java @@ -0,0 +1,251 @@ +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.util.List; +import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.jabref.logic.exporter.AtomicFileOutputStream; +import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; +import org.jabref.logic.git.merge.execution.GitMergeApplier; +import org.jabref.logic.git.merge.planning.SemanticMergeAnalyzer; +import org.jabref.logic.git.model.MergeAnalysis; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.groups.DirectoryStructureGroup; +import org.jabref.model.groups.GroupTreeNode; + +import org.jspecify.annotations.NullMarked; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/// The `.bib` mirror of a directory library: the whole library as one BibTeX file, +/// `/.bib`, so plain BibTeX consumers (and collaborators without this feature) +/// can read and edit the library as one file. Every model change refreshes the mirror +/// (debounced through [PendingWrites]); a copy of the last written mirror is kept under +/// `.jabref/mirror-base.bib` as the merge base. External edits of the mirror — live or while +/// JabRef was closed — are three-way merged into the library with the git-sync semantic merge +/// ([SemanticMergeAnalyzer]); auto-mergeable changes apply as local changes (so the sidecar +/// write-back persists them), true conflicts go to the injected [GitConflictResolverStrategy], +/// and a cancelled resolution keeps the library's state. The mirror's metadata block is also +/// where a directory library's user-defined groups survive a restart. +// [impl->req~directory-library.bib-mirror~2] +@NullMarked +public class BibMirror { + + private static final Logger LOGGER = LoggerFactory.getLogger(BibMirror.class); + + private final Object lock; + private final Path root; + private final BibDatabaseContext databaseContext; + private final TrackedFiles files; + private final ScheduledExecutorService syncExecutor; + private final Consumer modelUpdateMarshaller; + private final Supplier serializer; + private final Function> parser; + private final GitConflictResolverStrategy conflictResolver; + private final Runnable groupsViewRefresher; + private final Consumer writeScheduler; + + /// @param serializer serializes the live library to BibTeX; runs on the UI thread + /// @param writeScheduler schedules the (debounced) write of the mirror file + BibMirror(Object lock, + Path root, + BibDatabaseContext databaseContext, + TrackedFiles files, + ScheduledExecutorService syncExecutor, + Consumer modelUpdateMarshaller, + Supplier serializer, + Function> parser, + GitConflictResolverStrategy conflictResolver, + Runnable groupsViewRefresher, + Consumer writeScheduler) { + this.lock = lock; + this.root = root; + this.databaseContext = databaseContext; + this.files = files; + this.syncExecutor = syncExecutor; + this.modelUpdateMarshaller = modelUpdateMarshaller; + this.serializer = serializer; + this.parser = parser; + this.conflictResolver = conflictResolver; + this.groupsViewRefresher = groupsViewRefresher; + this.writeScheduler = writeScheduler; + } + + /// The mirror's file name for a library root: a filesystem root (`/`, `C:\\`) has no file + /// name. + public static String fileName(Path root) { + return Optional.ofNullable(root.getFileName()).map(Path::toString).orElse("library") + ".bib"; + } + + /// The snapshot of the mirror as this application last wrote it — the base of the + /// three-way merge when the mirror is changed externally. + public static Path baseFile(Path root) { + return root.resolve(".jabref").resolve("mirror-base.bib"); + } + + public Path file() { + return root.resolve(fileName(root)); + } + + boolean is(Path file) { + return file.toAbsolutePath().normalize().equals(file().toAbsolutePath().normalize()); + } + + /// Every model change — user edit or inbound — stales the mirror. Hops to the sync thread, + /// so the UI thread never waits for the synchronizer's monitor while files are written. + void markDirty() { + syncExecutor.execute(() -> writeScheduler.accept(file())); + } + + /// Brings mirror and library together after opening: creates a missing mirror, merges an + /// externally changed one (changed while this application was not watching), and adopts a + /// pre-existing `.bib` (no recorded base) by importing it against an empty base — which can + /// only add or conflict, never delete library content. + void initialize() { + syncExecutor.execute(this::doInitialize); + } + + void doInitialize() { + synchronized (lock) { + Path mirror = file(); + if (!Files.exists(mirror)) { + writeScheduler.accept(mirror); + return; + } + // The mirror's metadata is the only place user-defined groups of a directory + // library survive a restart — the sidecars carry entries, not library metadata + readBibContext(mirror).ifPresent(this::adoptUserGroups); + try { + if (Files.exists(baseFile(root)) && Files.mismatch(mirror, baseFile(root)) == -1L) { + return; + } + } catch (IOException e) { + LOGGER.warn("Could not compare mirror {} with its base", mirror, e); + return; + } + } + syncExecutor.execute(this::merge); + } + + /// The watcher saw the mirror change (or appear). + void handleChanged(Path file) { + if (files.consumeSelfEcho(file)) { + return; + } + // Runs as its own task, NOT under the synchronizer's monitor: conflict resolution blocks + // on the GUI thread, and the GUI thread meanwhile posts entry events into synchronized + // methods of the synchronizer — holding the monitor here would deadlock. + syncExecutor.execute(this::merge); + } + + /// Three-way merge of an externally modified mirror into the library: base = the mirror as + /// last written (empty when unknown), local = the library, remote = the mirror's current + /// content. The auto-plan and resolved conflicts are applied as local changes, so the + /// regular write-back persists them into the sidecars; afterwards the mirror is rewritten + /// from the merged library state. + void merge() { + readBibContext(file()).ifPresentOrElse(this::merge, + () -> LOGGER.warn("Not applying unparseable mirror {}", file())); + } + + private void merge(BibDatabaseContext remote) { + BibDatabaseContext base = readBibContext(baseFile(root)).orElseGet(BibDatabaseContext::new); + MergeAnalysis analysis = SemanticMergeAnalyzer.analyze(base, databaseContext, remote); + if (!analysis.autoPlan().isEmpty()) { + modelUpdateMarshaller.accept(() -> { + GitMergeApplier.applyAutoPlan(databaseContext, analysis.autoPlan()); + groupsViewRefresher.run(); + }); + } + if (!analysis.conflicts().isEmpty()) { + List resolved = conflictResolver.resolveConflicts(analysis.conflicts()); + if (resolved.isEmpty()) { + LOGGER.info("Conflict resolution cancelled — keeping the library's state for {} conflicting entries", analysis.conflicts().size()); + } else { + modelUpdateMarshaller.accept(() -> { + GitMergeApplier.applyResolved(databaseContext, resolved); + groupsViewRefresher.run(); + }); + } + } + // The merged state (or, on cancel, the library's state) becomes the new mirror + base + markDirty(); + } + + /// Restores user-defined groups from the mirror's metadata into the freshly scanned + /// context (whose tree only holds the automatic directory-structure group). The + /// serialized directory-structure group itself is skipped — the scanner installs it with + /// a live lookup, the parsed one would be an empty duplicate. + private void adoptUserGroups(BibDatabaseContext remote) { + remote.getMetaData().getGroups().ifPresent(remoteRoot -> + databaseContext.getMetaData().getGroups().ifPresent(localRoot -> { + List adoptable = remoteRoot.getChildren().stream() + .filter(child -> !(child.getGroup() instanceof DirectoryStructureGroup)) + .toList(); + if (adoptable.isEmpty()) { + return; + } + modelUpdateMarshaller.accept(() -> { + adoptable.forEach(child -> child.moveTo(localRoot)); + groupsViewRefresher.run(); + }); + })); + } + + private Optional readBibContext(Path file) { + if (!Files.exists(file)) { + return Optional.empty(); + } + try { + return parser.apply(Files.readString(file, StandardCharsets.UTF_8)); + } catch (IOException e) { + LOGGER.warn("Could not read {}", file, e); + return Optional.empty(); + } + } + + /// Serializing walks the live model, which only the UI thread may do safely: the debounced + /// path serializes there and hands the bytes back to the sync thread, while a flush — + /// called on the UI thread — does both inline. + /// + /// @return always `true`: the deferred path reports its failure by re-scheduling + boolean write(boolean immediate) throws IOException { + if (immediate) { + writeContent(serializer.get()); + return true; + } + modelUpdateMarshaller.accept(() -> { + String content = serializer.get(); + syncExecutor.execute(() -> { + try { + writeContent(content); + } catch (IOException e) { + LOGGER.error("Could not write mirror {}", file(), e); + writeScheduler.accept(file()); + } + }); + }); + return true; + } + + private void writeContent(String document) throws IOException { + synchronized (lock) { + Path mirror = file(); + byte[] content = document.getBytes(StandardCharsets.UTF_8); + try (AtomicFileOutputStream output = new AtomicFileOutputStream(mirror, false)) { + output.write(content); + } + files.recordWritten(mirror, content); + Files.createDirectories(baseFile(root).getParent()); + Files.write(baseFile(root), content); + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java index b7252b5ee00b..30979e28b06d 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java @@ -80,12 +80,12 @@ public List obstacles(BibDatabaseContext context, Path root, FilePrefere /// @return the mirror file public Path convert(BibDatabaseContext context, Path root, FilePreferences filePreferences) throws IOException { Path bibFile = context.getDatabasePath().orElseThrow(); - Path mirror = root.resolve(DirectoryLibrarySynchronizer.mirrorFileName(root)); + Path mirror = root.resolve(BibMirror.fileName(root)); writeSidecars(context, root, filePreferences); if (!mirror.equals(bibFile)) { Files.move(bibFile, mirror); } - Path base = DirectoryLibrarySynchronizer.mirrorBaseFile(root); + Path base = BibMirror.baseFile(root); Files.createDirectories(base.getParent()); Files.copy(mirror, base, StandardCopyOption.REPLACE_EXISTING); return mirror; diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java index 4fc4d5ed313c..44cafe5902f6 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -6,15 +6,11 @@ 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.HashSet; -import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -22,11 +18,9 @@ import java.util.Map; import java.util.Optional; import java.util.SequencedMap; -import java.util.SequencedSet; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -37,17 +31,11 @@ import java.util.stream.IntStream; import org.jabref.logic.bibtex.FileFieldWriter; -import org.jabref.logic.exporter.AtomicFileOutputStream; -import org.jabref.logic.exporter.HayagrivaEntryWriter; import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; -import org.jabref.logic.git.merge.execution.GitMergeApplier; -import org.jabref.logic.git.merge.planning.SemanticMergeAnalyzer; -import org.jabref.logic.git.model.MergeAnalysis; 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.FileNameCleaner; import org.jabref.logic.util.io.FileUtil; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.database.event.EntriesAddedEvent; @@ -59,8 +47,6 @@ import org.jabref.model.entry.event.EntryChangedEvent; import org.jabref.model.entry.field.Field; import org.jabref.model.entry.field.StandardField; -import org.jabref.model.groups.DirectoryStructureGroup; -import org.jabref.model.groups.GroupTreeNode; import org.jabref.model.groups.event.GroupUpdatedEvent; import org.jabref.model.metadata.event.MetaDataChangedEvent; @@ -75,20 +61,22 @@ import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import tools.jackson.core.JacksonException; -import static java.util.function.Predicate.not; - -/// 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). +/// Keeps an open directory library in sync with its files. This class owns the inbound +/// direction (file system to [BibDatabaseContext]) and the lifecycle: it is registered as a +/// [FileAlterationListener] with the polling [DirectoryMonitor], serializes all event handling +/// on a single "directory-sync" executor, and marshals model mutations through the injected +/// `modelUpdateMarshaller` (the GUI passes the JavaFX thread executor). The outbound direction +/// is delegated: entry events (relayed through the [org.jabref.logic.util.CoarseChangeFilter] +/// installed by [BibDatabaseContext#attachDirectorySynchronizer]) mark files pending in +/// [PendingWrites], which writes them through [SidecarWriteBack] and [BibMirror]; [#flush] +/// forces those writes and reports the files that could not be written. /// -/// 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. +/// All database mutations use [EntriesEventSource#SHARED] so that the write-back ignores them +/// (same echo-prevention policy as the shared-SQL synchronizer). Conversely, the write-back +/// registers a fingerprint of its own writes ([TrackedFiles]), which this class swallows +/// instead of re-importing. An external edit of a sidecar is applied field-wise against the +/// content last read or written, so in-memory edits of other fields survive. /// /// 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 @@ -97,48 +85,22 @@ /// /// 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. -/// -/// The outbound direction subscribes to entry events (relayed through the -/// [org.jabref.logic.util.CoarseChangeFilter] installed by -/// [BibDatabaseContext#attachDirectorySynchronizer]) and persists user changes back into the -/// sidecar files: edits rewrite the entry's file read-modify-write, the first user edit of an -/// entry without a sidecar creates one (next to its PDF, sharing the base name), a citation-key -/// edit renames the YAML map key, and deleting an entry removes it from its file (disposing the -/// file once its last entry is gone — the paired PDF is never touched). Writes are debounced -/// per file; [#flush] forces them, and shutdown flushes implicitly. A file that could not be -/// written stays pending and is reported by [#flush], so the GUI can tell the user. -/// -/// The library is additionally mirrored into a single `/.bib` file so plain -/// BibTeX consumers (and collaborators without this feature) can read and edit the library as -/// one file. Every model change refreshes the mirror (same debounce); a copy of the last -/// written mirror is kept under `.jabref/mirror-base.bib` as the merge base. External edits of -/// the mirror — live or while JabRef was closed — are three-way merged into the library with -/// the git-sync semantic merge ([SemanticMergeAnalyzer]); auto-mergeable changes apply as -/// local changes (so the sidecar write-back persists them), true conflicts go to the injected -/// [GitConflictResolverStrategy], and a cancelled resolution keeps the library's state. // [impl->req~directory-library.inbound-sync~2] -// [impl->req~directory-library.write-back~2] -// [impl->req~directory-library.bib-mirror~2] @NullMarked public class DirectoryLibrarySynchronizer implements FileAlterationListener { + /// In precedence order when several sidecars share a base name. + static final List SIDECAR_EXTENSIONS = List.of("yml", "yaml", MarkdownSidecar.MARKDOWN_EXTENSION); + 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"; - /// Collects keystroke-level bursts into one write per file. Trailing edge: every change - /// event re-arms the file's timer, so the write fires once typing pauses and always - /// persists the latest state. - private static final Duration WRITE_DEBOUNCE = Duration.ofMillis(500); - private final BibDatabaseContext databaseContext; - private final DirectoryLibraryCatalog catalog; private final PdfEntryFactory pdfEntryFactory; private final Path root; private final Consumer modelUpdateMarshaller; @@ -146,24 +108,12 @@ public class DirectoryLibrarySynchronizer implements FileAlterationListener { private final HayagrivaImporter importer = new HayagrivaImporter(); private final MarkdownSidecar markdownSidecar = new MarkdownSidecar(); private final ScheduledExecutorService syncExecutor; + private final TrackedFiles files; + private final SidecarWriteBack writeBack; + private final BibMirror mirror; + private final PendingWrites pendingWrites; private final Map stagedDeletions = new HashMap<>(); - private final Map lastWrittenFingerprints = new HashMap<>(); - /// Content of each sidecar as last read or written, so a write notices an external edit - /// that landed in between and takes it into the model first instead of overwriting it. - private final Map lastSeenFingerprints = new HashMap<>(); - /// Entries (by Hayagriva key) as last read from or written to each file: the base of the - /// three-way merge in [#applyChangedFile], so an external edit only touches the fields it - /// changed and in-memory edits of other fields survive. - private final Map> baselines = new HashMap<>(); - private final HayagrivaEntryWriter entryWriter = new HayagrivaEntryWriter(); - private final SequencedSet dirtyFiles = new LinkedHashSet<>(); - private final Map> scheduledWrites = new HashMap<>(); - private final Consumer fileDisposer; - private final Function> fileNameGenerator; - private final Supplier mirrorSerializer; - private final Function> bibParser; - private final GitConflictResolverStrategy conflictResolver; private @Nullable Watch watch; @@ -196,13 +146,7 @@ public DirectoryLibrarySynchronizer(BibDatabaseContext databaseContext, Consumer modelUpdateMarshaller, Clock clock) { this.databaseContext = databaseContext; - this.catalog = catalog; this.pdfEntryFactory = pdfEntryFactory; - this.fileDisposer = fileDisposer; - this.fileNameGenerator = fileNameGenerator; - this.mirrorSerializer = mirrorSerializer; - this.bibParser = bibParser; - this.conflictResolver = conflictResolver; this.root = databaseContext.getDirectoryLibraryRoot().orElseThrow( () -> new IllegalArgumentException("Context is not a directory library")); this.modelUpdateMarshaller = modelUpdateMarshaller; @@ -216,6 +160,16 @@ public DirectoryLibrarySynchronizer(BibDatabaseContext databaseContext, // Pending debounce and grace timers are superseded by the final flush on shutdown executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); this.syncExecutor = executor; + + this.files = new TrackedFiles(databaseContext, catalog); + this.writeBack = new SidecarWriteBack(files, root, modelUpdateMarshaller, fileDisposer, fileNameGenerator, this::handleFileChanged); + this.pendingWrites = new PendingWrites(this, syncExecutor, this::writePendingFile); + this.mirror = new BibMirror(this, root, databaseContext, files, syncExecutor, modelUpdateMarshaller, + mirrorSerializer, bibParser, conflictResolver, this::refreshGroupsView, pendingWrites::schedule); + } + + private boolean writePendingFile(Path file, boolean immediate) throws IOException { + return mirror.is(file) ? mirror.write(immediate) : writeBack.write(file, immediate); } public void startWatching(DirectoryMonitor monitor) { @@ -243,23 +197,14 @@ public void startWatching(DirectoryMonitor monitor) { }); } - /// Records the scanned files' content as the merge base; the live entries still equal it - /// at this point. - synchronized void takeBaseline() { - for (Path file : catalog.files()) { - currentHash(file).ifPresent(fingerprint -> lastSeenFingerprints.put(file.toAbsolutePath().normalize(), fingerprint)); - baselines.put(file, copiesByKey(entriesOf(file))); - } - } - - /// The sidecar an entry is written to (tests). - Path sidecarOf(BibEntry entry) { - return catalog.sourceOf(entry).map(DirectoryLibraryCatalog.EntrySource::yamlFile).orElseThrow(); + /// See [BibMirror#initialize]. + public void initializeMirror() { + mirror.initialize(); } - /// Waits until every event queued so far has been handled (tests). - void awaitPendingEvents() throws InterruptedException, ExecutionException { - syncExecutor.submit(() -> { }).get(); + /// The library's `.bib` mirror file. + public Path getMirrorFile() { + return mirror.file(); } /// Stops watching and writes what is still pending. Events already queued (the last @@ -277,21 +222,39 @@ public List shutdown() { return flush(); } - /// Writes all pending sidecar changes now (they are otherwise debounced). + /// Writes all pending changes (sidecars and mirror) now; they are otherwise debounced. /// /// @return the files whose changes could not be written; they stay pending - public synchronized List flush() { - scheduledWrites.values().forEach(pending -> pending.cancel(false)); - scheduledWrites.clear(); - return writeFiles(List.copyOf(dirtyFiles), true); + public List flush() { + return pendingWrites.flush(); } /// 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) { - String fingerprint = hash(content); - lastWrittenFingerprints.put(file.toAbsolutePath().normalize(), fingerprint); - lastSeenFingerprints.put(file.toAbsolutePath().normalize(), fingerprint); + files.recordWritten(file, content); + } + + synchronized void takeBaseline() { + files.takeBaseline(); + } + + /// The sidecar an entry is written to (tests). + Path sidecarOf(BibEntry entry) { + return files.catalog().sourceOf(entry).map(DirectoryLibraryCatalog.EntrySource::yamlFile).orElseThrow(); + } + + /// Waits until every event queued so far has been handled (tests). + void awaitPendingEvents() throws InterruptedException, ExecutionException { + syncExecutor.submit(() -> { }).get(); + } + + void doInitializeMirror() { + mirror.doInitialize(); + } + + void mergeExternalMirror() { + mirror.merge(); } @Override @@ -338,7 +301,7 @@ public void onStop(FileAlterationObserver observer) { public void listen(EntryChangedEvent event) { // Regardless of the source — user edit or inbound sync — the model changed, so the // .bib mirror is stale - markMirrorDirty(); + mirror.markDirty(); if (!isUserChange(event)) { return; } @@ -351,7 +314,7 @@ public void listen(EntryChangedEvent event) { @Subscribe public void listen(EntriesAddedEvent event) { - markMirrorDirty(); + mirror.markDirty(); if (!isUserChange(event)) { return; } @@ -361,7 +324,7 @@ public void listen(EntriesAddedEvent event) { @Subscribe public void listen(EntriesRemovedEvent event) { - markMirrorDirty(); + mirror.markDirty(); if (!isUserChange(event)) { return; } @@ -372,13 +335,13 @@ public void listen(EntriesRemovedEvent event) { /// Groups (and other library settings) live only in the mirror's metadata block. @Subscribe public void listen(MetaDataChangedEvent event) { - markMirrorDirty(); + mirror.markDirty(); } /// Group tree edits (add, rename, remove) are posted as group events, not metadata events. @Subscribe public void listen(GroupUpdatedEvent event) { - markMirrorDirty(); + mirror.markDirty(); } private static boolean isUserChange(EntriesEvent event) { @@ -386,403 +349,20 @@ private static boolean isUserChange(EntriesEvent event) { || event.getEntriesEventSource() == EntriesEventSource.UNDO; } - /// Restores user-defined groups from the mirror's metadata into the freshly scanned - /// context (whose tree only holds the automatic directory-structure group). The - /// serialized directory-structure group itself is skipped — the scanner installs it with - /// a live lookup, the parsed one would be an empty duplicate. - private void adoptUserGroups(BibDatabaseContext remote) { - Optional remoteRoot = remote.getMetaData().getGroups(); - Optional localRoot = databaseContext.getMetaData().getGroups(); - if (remoteRoot.isEmpty() || localRoot.isEmpty()) { - return; - } - List adoptable = remoteRoot.get().getChildren().stream() - .filter(child -> !(child.getGroup() instanceof DirectoryStructureGroup)) - .toList(); - if (adoptable.isEmpty()) { - return; - } - modelUpdateMarshaller.accept(() -> { - adoptable.forEach(child -> child.moveTo(localRoot.get())); - refreshGroupsView(); - }); - } - synchronized void handleLocalChange(BibEntry entry) { - scheduleWrite(catalog.sourceOf(entry) - .map(DirectoryLibraryCatalog.EntrySource::yamlFile) - .orElseGet(() -> assignSidecar(entry))); + pendingWrites.schedule(writeBack.fileFor(entry)); } - /// The catalog keeps the removed entries' sources until the debounced write runs, so an - /// undo within that window lands the entry back in its own file instead of a fresh one. synchronized void handleLocalRemoval(List entries) { - entries.stream() - .flatMap(entry -> catalog.sourceOf(entry).stream()) - .map(DirectoryLibraryCatalog.EntrySource::yamlFile) - .distinct() - .forEach(this::scheduleWrite); - } - - /// The first user change of an entry without a source materializes its sidecar — a Markdown - /// sidecar (see [MarkdownSidecar]): next to the entry's PDF (sharing the base name, per the - /// pairing convention), or named after the citation key for entries without a file. - private Path assignSidecar(BibEntry entry) { - Path sidecar = entry.getFiles().stream() - .filter(linkedFile -> !linkedFile.isOnlineLink()) - .map(linkedFile -> root.resolve(linkedFile.getLink()).normalize()) - .filter(linkedPath -> linkedPath.startsWith(root)) - .findFirst() - .map(paired -> paired.resolveSibling(FileUtil.getBaseName(paired) + "." + MarkdownSidecar.MARKDOWN_EXTENSION)) - // A second entry linking the same PDF, or a foreign file of that name, cannot share it - .filter(candidate -> !Files.exists(candidate) && catalog.entryIdsIn(candidate).isEmpty()) - .orElseGet(() -> unusedSidecar(entry.getCitationKey() - .map(FileNameCleaner::cleanFileName) - .filter(not(String::isBlank)) - .orElse("entry"))); - catalog.register(entry, sidecar, entry.getCitationKey().orElse("")); - return sidecar; - } - - /// Also skips names already assigned to entries whose sidecar is not written yet. - private Path unusedSidecar(String baseName) { - Path candidate = root.resolve(baseName + "." + MarkdownSidecar.MARKDOWN_EXTENSION); - for (int counter = 1; Files.exists(candidate) || !catalog.entryIdsIn(candidate).isEmpty(); counter++) { - candidate = root.resolve(baseName + "-" + counter + "." + MarkdownSidecar.MARKDOWN_EXTENSION); - } - return candidate; - } - - /// The library's `.bib` mirror: the whole library as one BibTeX file, named after the - /// library root, inside it. - public Path getMirrorFile() { - return root.resolve(mirrorFileName(root)); - } - - /// A filesystem root (`/`, `C:\\`) has no file name. - public static String mirrorFileName(Path root) { - return Optional.ofNullable(root.getFileName()).map(Path::toString).orElse("library") + ".bib"; - } - - /// The snapshot of the mirror as this application last wrote it — the base of the - /// three-way merge when the mirror is changed externally. - private Path mirrorBaseFile() { - return mirrorBaseFile(root); - } - - static Path mirrorBaseFile(Path root) { - return root.resolve(".jabref").resolve("mirror-base.bib"); - } - - private boolean isMirror(Path file) { - return file.toAbsolutePath().normalize().equals(getMirrorFile().toAbsolutePath().normalize()); - } - - /// Every model change — user edit or inbound — stales the mirror. Hops to the sync thread, - /// so the UI thread never waits for this synchronizer's monitor while files are written. - private void markMirrorDirty() { - syncExecutor.execute(() -> scheduleWrite(getMirrorFile())); - } - - /// Brings mirror and library together after opening: creates a missing mirror, merges an - /// externally changed one (changed while this application was not watching), and adopts a - /// pre-existing `.bib` (no recorded base) by importing it against an empty base — which can - /// only add or conflict, never delete library content. - public void initializeMirror() { - syncExecutor.execute(this::doInitializeMirror); - } - - synchronized void doInitializeMirror() { - Path mirror = getMirrorFile(); - if (!Files.exists(mirror)) { - scheduleWrite(mirror); - return; - } - // The mirror's metadata is the only place user-defined groups of a directory library - // survive a restart — the sidecars carry entries, not library metadata - readBibContext(mirror).ifPresent(this::adoptUserGroups); - try { - if (Files.exists(mirrorBaseFile()) && Files.mismatch(mirror, mirrorBaseFile()) == -1L) { - return; - } - } catch (IOException e) { - LOGGER.warn("Could not compare mirror {} with its base", mirror, e); - return; - } - syncExecutor.execute(this::mergeExternalMirror); - } - - private void handleMirrorChanged(Path file) { - if (consumeSelfEcho(file)) { - return; - } - // Runs as its own task, NOT under this object's monitor: conflict resolution blocks on - // the GUI thread, and the GUI thread meanwhile posts entry events into synchronized - // methods of this class — holding the monitor here would deadlock. - syncExecutor.execute(this::mergeExternalMirror); - } - - /// Three-way merge of an externally modified mirror into the library: base = the mirror as - /// last written (empty when unknown), local = the library, remote = the mirror's current - /// content. The auto-plan and resolved conflicts are applied as local changes, so the - /// regular write-back persists them into the sidecars; afterwards the mirror is rewritten - /// from the merged library state. - void mergeExternalMirror() { - readBibContext(getMirrorFile()).ifPresentOrElse(this::mergeExternalMirror, - () -> LOGGER.warn("Not applying unparseable mirror {}", getMirrorFile())); - } - - private void mergeExternalMirror(BibDatabaseContext remote) { - BibDatabaseContext base = readBibContext(mirrorBaseFile()).orElseGet(BibDatabaseContext::new); - MergeAnalysis analysis = SemanticMergeAnalyzer.analyze(base, databaseContext, remote); - if (!analysis.autoPlan().isEmpty()) { - modelUpdateMarshaller.accept(() -> { - GitMergeApplier.applyAutoPlan(databaseContext, analysis.autoPlan()); - refreshGroupsView(); - }); - } - if (!analysis.conflicts().isEmpty()) { - List resolved = conflictResolver.resolveConflicts(analysis.conflicts()); - if (resolved.isEmpty()) { - LOGGER.info("Conflict resolution cancelled — keeping the library's state for {} conflicting entries", analysis.conflicts().size()); - } else { - modelUpdateMarshaller.accept(() -> { - GitMergeApplier.applyResolved(databaseContext, resolved); - refreshGroupsView(); - }); - } - } - // The merged state (or, on cancel, the library's state) becomes the new mirror + base - markMirrorDirty(); - } - - private Optional readBibContext(Path file) { - if (!Files.exists(file)) { - return Optional.empty(); - } - try { - return bibParser.apply(Files.readString(file, StandardCharsets.UTF_8)); - } catch (IOException e) { - LOGGER.warn("Could not read {}", file, e); - return Optional.empty(); - } - } - - /// Serializing walks the live model, which only the UI thread may do safely: the debounced - /// path serializes there and hands the bytes back to this thread, while a flush — called on - /// the UI thread — does both inline. - private void writeMirror(boolean immediate) throws IOException { - if (immediate) { - writeMirrorContent(mirrorSerializer.get()); - return; - } - modelUpdateMarshaller.accept(() -> { - String content = mirrorSerializer.get(); - syncExecutor.execute(() -> { - try { - writeMirrorContent(content); - } catch (IOException e) { - LOGGER.error("Could not write mirror {}", getMirrorFile(), e); - scheduleWrite(getMirrorFile()); - } - }); - }); - } - - private synchronized void writeMirrorContent(String document) throws IOException { - Path mirror = getMirrorFile(); - byte[] content = document.getBytes(StandardCharsets.UTF_8); - try (AtomicFileOutputStream output = new AtomicFileOutputStream(mirror, false)) { - output.write(content); - } - recordWrittenFile(mirror, content); - Files.createDirectories(mirrorBaseFile().getParent()); - Files.write(mirrorBaseFile(), content); - } - - private synchronized void scheduleWrite(Path file) { - dirtyFiles.add(file); - Optional.ofNullable(scheduledWrites.remove(file)).ifPresent(pending -> pending.cancel(false)); - if (syncExecutor.isShutdown()) { - // Written by the final flush - return; - } - scheduledWrites.put(file, syncExecutor.schedule(() -> writeScheduled(file), WRITE_DEBOUNCE.toMillis(), TimeUnit.MILLISECONDS)); - } - - private synchronized void writeScheduled(Path file) { - scheduledWrites.remove(file); - writeFiles(List.of(file), false); - } - - /// Files that could not be written stay dirty: the next flush retries them and the caller - /// can report them. `immediate` writes even if the file changed externally in between (the - /// external edit has then been taken into the model on the caller's thread, see - /// [#writeFile]). - private synchronized List writeFiles(List files, boolean immediate) { - List failed = new ArrayList<>(); - for (Path file : files) { - if (!dirtyFiles.contains(file)) { - continue; - } - try { - if (isMirror(file)) { - writeMirror(immediate); - dirtyFiles.remove(file); - } else if (writeFile(file, immediate)) { - dirtyFiles.remove(file); - } else { - scheduleWrite(file); - } - } catch (IOException | JacksonException e) { - LOGGER.error("Could not write sidecar {}", file, e); - failed.add(file); - } - } - return failed; - } - - /// @return whether the file was written; `false` defers the write until the model has taken - /// in an external edit that landed since the file was last read or written - private boolean writeFile(Path file, boolean immediate) throws IOException { - Path normalized = file.toAbsolutePath().normalize(); - boolean changedExternally = Optional.ofNullable(lastSeenFingerprints.get(normalized)) - .map(lastSeen -> Files.exists(file) && !currentHash(file).equals(Optional.of(lastSeen))) - .orElse(false); - if (changedExternally) { - // The model update is marshalled (asynchronously in the GUI), so the write is retried - // one debounce later — unless the caller flushes, where the user's state must win - handleFileChanged(file); - if (!immediate) { - return false; - } - } - - List entries = entriesOf(file); - Set liveIds = entries.stream().map(BibEntry::getId).collect(Collectors.toSet()); - catalog.entryIdsIn(file).stream().filter(id -> !liveIds.contains(id)).forEach(catalog::removeEntry); - if (entries.isEmpty()) { - catalog.removeFile(file); - lastSeenFingerprints.remove(normalized); - baselines.remove(file); - if (Files.exists(file)) { - fileDisposer.accept(file); - } - return true; - } - // The user's rename rule: a single-entry sidecar and its paired PDF share the base name - // generated by the configured filename pattern; multi-entry files have no single - // generating entry and keep their name - Path target = entries.size() == 1 ? applyFileNamePattern(file, entries.getFirst()) : file; - List keyedEntries = new ArrayList<>(); - Set usedKeys = new HashSet<>(); - for (BibEntry entry : entries) { - String previousKey = catalog.sourceOf(entry) - .map(DirectoryLibraryCatalog.EntrySource::hayagrivaKey) - .orElse(""); - String targetKey = entry.getCitationKey() - .filter(key -> !key.isBlank()) - .orElse(previousKey.isBlank() ? "entry" : previousKey); - String uniqueKey = targetKey; - int counter = 1; - while (!usedKeys.add(uniqueKey)) { - uniqueKey = targetKey + "-" + counter++; - } - keyedEntries.add(new HayagrivaEntryWriter.KeyedEntry(previousKey, uniqueKey, entry)); - } - String existingDocument = Files.exists(target) ? Files.readString(target, StandardCharsets.UTF_8) : ""; - String document = MarkdownSidecar.hasMarkdownExtension(target) - ? markdownSidecar.merge(existingDocument, keyedEntries) - : entryWriter.mergeIntoDocument(existingDocument, keyedEntries); - byte[] content = document.getBytes(StandardCharsets.UTF_8); - // Written atomically: the polling watcher (or another process) must never see a - // half-written sidecar. The fingerprint is recorded only once the file is really there. - try (AtomicFileOutputStream output = new AtomicFileOutputStream(target, false)) { - output.write(content); - } - recordWrittenFile(target, content); - keyedEntries.forEach(keyedEntry -> catalog.updateHayagrivaKey(keyedEntry.entry(), keyedEntry.targetKey())); - SequencedMap written = new LinkedHashMap<>(); - keyedEntries.forEach(keyedEntry -> written.put(keyedEntry.targetKey(), new BibEntry(keyedEntry.entry()))); - baselines.put(target, written); - return true; - } - - /// Renames the sidecar and its equally named PDF to the base name the filename pattern - /// generates for the entry — kept in sync as a pair, per the pairing convention. A pattern - /// failure, or a target name any pair member of another entry already occupies, leaves the - /// current name untouched. Never touches other files. - // [impl->req~directory-library.pattern-rename~1] - private Path applyFileNamePattern(Path file, BibEntry entry) { - return fileNameGenerator.apply(entry) - .map(String::trim) - .filter(not(String::isEmpty)) - .filter(not(FileUtil.getBaseName(file)::equals)) - .map(newBaseName -> renamePair(file, entry, newBaseName)) - .orElse(file); - } - - private Path renamePair(Path file, BibEntry entry, String newBaseName) { - Path newSidecar = file.resolveSibling(newBaseName + "." + FileUtil.getFileExtension(file).orElseThrow()); - Path oldPdf = file.resolveSibling(FileUtil.getBaseName(file) + ".pdf"); - Path newPdf = file.resolveSibling(newBaseName + ".pdf"); - boolean occupied = (Files.exists(newPdf) && !linksFile(entry, newPdf)) - || SIDECAR_EXTENSIONS.stream().anyMatch(extension -> Files.exists(file.resolveSibling(newBaseName + "." + extension))); - if (occupied) { - return file; - } - boolean hasPdf = Files.exists(oldPdf); - try { - // The PDF first: if that fails nothing has changed, and a failing sidecar move is - // rolled back, so the pair never ends up half renamed - if (hasPdf) { - Files.move(oldPdf, newPdf); - } - try { - if (Files.exists(file)) { - Files.move(file, newSidecar); - } - } catch (IOException e) { - if (hasPdf) { - Files.move(newPdf, oldPdf); - } - throw e; - } - } catch (IOException e) { - LOGGER.warn("Could not rename {} to the configured pattern", file, e); - return file; - } - catalog.relocateFile(file, newSidecar); - Optional.ofNullable(baselines.remove(file)).ifPresent(baseline -> baselines.put(newSidecar, baseline)); - Optional.ofNullable(lastSeenFingerprints.remove(file.toAbsolutePath().normalize())) - .ifPresent(fingerprint -> lastSeenFingerprints.put(newSidecar.toAbsolutePath().normalize(), fingerprint)); - if (hasPdf) { - String newLink = root.relativize(newPdf).toString(); - String oldLink = root.relativize(oldPdf).toString(); - modelUpdateMarshaller.accept(() -> { - List updated = entry.getFiles().stream() - .map(linkedFile -> oldLink.equals(linkedFile.getLink()) - ? new LinkedFile(linkedFile.getDescription(), newLink, linkedFile.getFileType()) - : linkedFile) - .toList(); - entry.setField(StandardField.FILE, FileFieldWriter.getStringRepresentation(updated), EntriesEventSource.SHARED); - }); - } - return newSidecar; - } - - private boolean linksFile(BibEntry entry, Path file) { - return entry.getFiles().stream() - .filter(linkedFile -> !linkedFile.isOnlineLink()) - .anyMatch(linkedFile -> root.resolve(linkedFile.getLink()).normalize().equals(file.toAbsolutePath().normalize())); + writeBack.filesOf(entries).forEach(pendingWrites::schedule); } synchronized void handleFileCreated(Path file) { commitExpiredStagedDeletions(); - if (isMirror(file)) { - handleMirrorChanged(file); + if (mirror.is(file)) { + mirror.handleChanged(file); } else if (isSidecar(file)) { - if (consumeSelfEcho(file)) { + if (files.consumeSelfEcho(file)) { return; } importFile(file); @@ -793,14 +373,14 @@ synchronized void handleFileCreated(Path file) { synchronized void handleFileChanged(Path file) { commitExpiredStagedDeletions(); - if (isMirror(file)) { - handleMirrorChanged(file); + if (mirror.is(file)) { + mirror.handleChanged(file); return; } - if (!isSidecar(file) || consumeSelfEcho(file)) { + if (!isSidecar(file) || files.consumeSelfEcho(file)) { return; } - List knownEntries = entriesOf(file); + List knownEntries = files.entriesOf(file); if (knownEntries.isEmpty()) { importFile(file); return; @@ -820,13 +400,13 @@ synchronized void handleFileChanged(Path file) { synchronized void handleFileDeleted(Path file) { commitExpiredStagedDeletions(); - if (isMirror(file)) { + if (mirror.is(file)) { // The mirror is derived state — recreate it - markMirrorDirty(); + mirror.markDirty(); return; } if (isSidecar(file)) { - List entries = entriesOf(file); + List entries = files.entriesOf(file); if (entries.isEmpty()) { return; } @@ -853,7 +433,7 @@ synchronized void commitExpiredStagedDeletions() { } private void importFile(Path file) { - if (!entriesOf(file).isEmpty()) { + if (!files.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); @@ -875,15 +455,14 @@ private void importFile(Path file) { .findFirst() .ifPresentOrElse(movedFrom -> { stagedDeletions.remove(movedFrom); - catalog.relocateFile(movedFrom, file); - Optional.ofNullable(baselines.remove(movedFrom)).ifPresent(baseline -> baselines.put(file, baseline)); + files.relocate(movedFrom, file); modelUpdateMarshaller.accept(this::refreshGroupsView); 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())); + newEntries.forEach(entry -> files.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()))); @@ -894,6 +473,7 @@ private void insertNewEntries(Path file, List newEntries) { } private void applyChangedFile(Path file, List knownEntries, List parsedEntries) { + DirectoryLibraryCatalog catalog = files.catalog(); SequencedMap knownByKey = byCitationKey(knownEntries); SequencedMap parsedByKey = byCitationKey(parsedEntries); @@ -901,7 +481,7 @@ private void applyChangedFile(Path file, List knownEntries, List toRemove = new ArrayList<>(); List fieldUpdates = new ArrayList<>(); - Map baseline = baselines.getOrDefault(file, new LinkedHashMap<>()); + Map baseline = files.baseline(file); parsedByKey.forEach((key, parsedEntry) -> Optional.ofNullable(knownByKey.get(key)).ifPresentOrElse( knownEntry -> fieldUpdates.add(() -> copyContent(parsedEntry, knownEntry, Optional.ofNullable(baseline.get(key)))), @@ -929,7 +509,7 @@ private void applyChangedFile(Path file, List knownEntries, List ba } } - private static SequencedMap copiesByKey(List entries) { - SequencedMap copies = new LinkedHashMap<>(); - entries.forEach(entry -> copies.putIfAbsent(entry.getCitationKey().orElse(""), new BibEntry(entry))); - return copies; - } - private void handlePdfCreated(Path pdf) { findSidecarEntry(pdf).ifPresentOrElse(entry -> { if (entry.getFiles().isEmpty()) { - List files = List.of(new LinkedFile("", root.relativize(pdf), StandardFileType.PDF.getName())); + List linkedFiles = List.of(new LinkedFile("", root.relativize(pdf), StandardFileType.PDF.getName())); modelUpdateMarshaller.accept(() -> - entry.setField(StandardField.FILE, FileFieldWriter.getStringRepresentation(files), EntriesEventSource.SHARED)); + entry.setField(StandardField.FILE, FileFieldWriter.getStringRepresentation(linkedFiles), EntriesEventSource.SHARED)); } }, () -> { BibEntry stub = pdfEntryFactory.createStub(pdf, root); @@ -995,7 +569,7 @@ private void handlePdfDeleted(Path pdf) { .anyMatch(linked -> relativeLink.equals(linked.getLink()))) .toList(); for (BibEntry entry : linking) { - boolean isStub = catalog.sourceOf(entry).isEmpty(); + boolean isStub = files.catalog().sourceOf(entry).isEmpty(); modelUpdateMarshaller.accept(() -> { if (isStub) { databaseContext.getDatabase().removeEntries(List.of(entry), EntriesEventSource.SHARED); @@ -1011,8 +585,7 @@ private void handlePdfDeleted(Path pdf) { } private void removeEntries(List entries, Path file) { - catalog.removeFile(file); - baselines.remove(file); + files.forget(file); modelUpdateMarshaller.accept(() -> { databaseContext.getDatabase().removeEntries(entries, EntriesEventSource.SHARED); refreshGroupsView(); @@ -1025,22 +598,6 @@ private void refreshGroupsView() { databaseContext.getMetaData().groupsBinding().invalidate(); } - /// Only entries still in the database: removed entries stay cataloged until their file is - /// rewritten (see [#handleLocalRemoval]). - private List entriesOf(Path file) { - List ids = catalog.entryIdsIn(file); - if (ids.isEmpty()) { - return List.of(); - } - Map byId = new HashMap<>(); - List allEntries = databaseContext.getDatabase().getEntries(); - // The UI thread mutates the (synchronized) list concurrently; field reads need no lock - synchronized (allEntries) { - allEntries.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)); @@ -1071,7 +628,7 @@ private Optional> parse(Path file) { if (parserResult.isInvalid()) { return Optional.empty(); } - currentHash(file).ifPresent(fingerprint -> lastSeenFingerprints.put(file.toAbsolutePath().normalize(), fingerprint)); + files.recordSeen(file); return Optional.of(parserResult.getDatabase().getEntries()); } catch (IOException e) { LOGGER.warn("Could not read {}", file, e); @@ -1096,7 +653,7 @@ private boolean looksLikeSidecar(Path file) { private Optional findSidecarEntry(Path pdf) { String baseName = FileUtil.getBaseName(pdf); return SIDECAR_EXTENSIONS.stream() - .map(extension -> entriesOf(pdf.resolveSibling(baseName + "." + extension))) + .map(extension -> files.entriesOf(pdf.resolveSibling(baseName + "." + extension))) .filter(entries -> !entries.isEmpty()) .map(List::getFirst) .findFirst(); @@ -1106,31 +663,6 @@ 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)); } diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/PendingWrites.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/PendingWrites.java new file mode 100644 index 000000000000..d5df018e5c2f --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/PendingWrites.java @@ -0,0 +1,101 @@ +package org.jabref.logic.directorylibrary; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.SequencedSet; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.jspecify.annotations.NullMarked; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; + +/// The files of a directory library whose write is still pending. Writes are debounced per +/// file with a trailing-edge timer: every change re-arms the file's timer, so the write fires +/// once the changes pause and always persists the latest state. [#flush] writes everything +/// now. A file that could not be written stays pending — the next attempt retries it and the +/// caller can report it. All work happens under the shared lock. +@NullMarked +class PendingWrites { + + /// @return whether the file was written; `false` asks for another attempt one debounce + /// later (the model has yet to take in an external edit, see [SidecarWriteBack]) + interface FileWriter { + boolean write(Path file, boolean immediate) throws IOException; + } + + private static final Logger LOGGER = LoggerFactory.getLogger(PendingWrites.class); + + private static final Duration DEBOUNCE = Duration.ofMillis(500); + + private final Object lock; + private final ScheduledExecutorService executor; + private final FileWriter writer; + private final SequencedSet dirtyFiles = new LinkedHashSet<>(); + private final Map> timers = new HashMap<>(); + + PendingWrites(Object lock, ScheduledExecutorService executor, FileWriter writer) { + this.lock = lock; + this.executor = executor; + this.writer = writer; + } + + void schedule(Path file) { + synchronized (lock) { + dirtyFiles.add(file); + Optional.ofNullable(timers.remove(file)).ifPresent(pending -> pending.cancel(false)); + if (executor.isShutdown()) { + // Written by the final flush + return; + } + timers.put(file, executor.schedule(() -> writeScheduled(file), DEBOUNCE.toMillis(), TimeUnit.MILLISECONDS)); + } + } + + /// Writes every pending file now. + /// + /// @return the files whose changes could not be written; they stay pending + List flush() { + synchronized (lock) { + timers.values().forEach(pending -> pending.cancel(false)); + timers.clear(); + return write(List.copyOf(dirtyFiles), true); + } + } + + private void writeScheduled(Path file) { + synchronized (lock) { + timers.remove(file); + write(List.of(file), false); + } + } + + private List write(List files, boolean immediate) { + List failed = new ArrayList<>(); + for (Path file : files) { + if (!dirtyFiles.contains(file)) { + continue; + } + try { + if (writer.write(file, immediate)) { + dirtyFiles.remove(file); + } else { + schedule(file); + } + } catch (IOException | JacksonException e) { + LOGGER.error("Could not write {}", file, e); + failed.add(file); + } + } + return failed; + } +} diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/SidecarWriteBack.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/SidecarWriteBack.java new file mode 100644 index 000000000000..88968c7dcd6d --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/SidecarWriteBack.java @@ -0,0 +1,252 @@ +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.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Optional; +import java.util.SequencedMap; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jabref.logic.bibtex.FileFieldWriter; +import org.jabref.logic.exporter.AtomicFileOutputStream; +import org.jabref.logic.exporter.HayagrivaEntryWriter; +import org.jabref.logic.util.io.FileNameCleaner; +import org.jabref.logic.util.io.FileUtil; +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.StandardField; + +import org.jspecify.annotations.NullMarked; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static java.util.function.Predicate.not; + +/// Persists user changes of a directory library into its sidecar files (outbound direction): +/// an edit rewrites the entry's file read-modify-write (content JabRef does not understand +/// survives), the first user edit of an entry without a sidecar creates one — a Markdown +/// sidecar next to its PDF, sharing the base name, or named after the citation key — a +/// citation-key edit renames the YAML map key, and an entry that left the database is removed +/// from its file, which is disposed once its last entry is gone (the paired PDF is never +/// touched). A single-entry sidecar and its PDF are renamed together to the base name the +/// configured filename pattern generates for the entry. +/// +/// The debounce, retry, and reporting of pending writes live in [PendingWrites]; this class +/// only knows how to write one file. Callers hold the synchronizer's monitor. +// [impl->req~directory-library.write-back~2] +// [impl->req~directory-library.pattern-rename~1] +@NullMarked +class SidecarWriteBack { + + private static final Logger LOGGER = LoggerFactory.getLogger(SidecarWriteBack.class); + + private final TrackedFiles files; + private final Path root; + private final Consumer modelUpdateMarshaller; + private final Consumer fileDisposer; + private final Function> fileNameGenerator; + private final Consumer externalChangeImporter; + private final HayagrivaEntryWriter entryWriter = new HayagrivaEntryWriter(); + private final MarkdownSidecar markdownSidecar = new MarkdownSidecar(); + + /// @param externalChangeImporter takes an external edit of a sidecar into the model (the + /// inbound direction), called before such a file is rewritten + SidecarWriteBack(TrackedFiles files, + Path root, + Consumer modelUpdateMarshaller, + Consumer fileDisposer, + Function> fileNameGenerator, + Consumer externalChangeImporter) { + this.files = files; + this.root = root; + this.modelUpdateMarshaller = modelUpdateMarshaller; + this.fileDisposer = fileDisposer; + this.fileNameGenerator = fileNameGenerator; + this.externalChangeImporter = externalChangeImporter; + } + + /// The file a changed entry is written to; assigned on the entry's first change. + Path fileFor(BibEntry entry) { + return files.catalog().sourceOf(entry) + .map(DirectoryLibraryCatalog.EntrySource::yamlFile) + .orElseGet(() -> assignSidecar(entry)); + } + + /// The files removed entries came from. They stay cataloged until the write runs, so an + /// undo within the debounce window lands the entry back in its own file. + List filesOf(List entries) { + return entries.stream() + .flatMap(entry -> files.catalog().sourceOf(entry).stream()) + .map(DirectoryLibraryCatalog.EntrySource::yamlFile) + .distinct() + .toList(); + } + + /// Writes the file's current entries; disposes the file when none is left. + /// + /// @param immediate write even if the file changed externally in between (the external + /// edit is taken into the model on the caller's thread first) + /// @return whether the file was written; `false` defers the write until the model has + /// taken in an external edit that landed since the file was last read or written + boolean write(Path file, boolean immediate) throws IOException { + if (files.changedExternally(file)) { + // The model update is marshalled (asynchronously in the GUI), so the write is retried + // one debounce later — unless the caller flushes, where the user's state must win + externalChangeImporter.accept(file); + if (!immediate) { + return false; + } + } + + DirectoryLibraryCatalog catalog = files.catalog(); + List entries = files.entriesOf(file); + Set liveIds = entries.stream().map(BibEntry::getId).collect(Collectors.toSet()); + catalog.entryIdsIn(file).stream().filter(id -> !liveIds.contains(id)).forEach(catalog::removeEntry); + if (entries.isEmpty()) { + files.forget(file); + if (Files.exists(file)) { + fileDisposer.accept(file); + } + return true; + } + // Multi-entry files have no single generating entry and keep their name + Path target = entries.size() == 1 ? applyFileNamePattern(file, entries.getFirst()) : file; + List keyedEntries = new ArrayList<>(); + Set usedKeys = new HashSet<>(); + for (BibEntry entry : entries) { + String previousKey = catalog.sourceOf(entry) + .map(DirectoryLibraryCatalog.EntrySource::hayagrivaKey) + .orElse(""); + String targetKey = entry.getCitationKey() + .filter(not(String::isBlank)) + .orElse(previousKey.isBlank() ? "entry" : previousKey); + String uniqueKey = targetKey; + int counter = 1; + while (!usedKeys.add(uniqueKey)) { + uniqueKey = targetKey + "-" + counter++; + } + keyedEntries.add(new HayagrivaEntryWriter.KeyedEntry(previousKey, uniqueKey, entry)); + } + String existingDocument = Files.exists(target) ? Files.readString(target, StandardCharsets.UTF_8) : ""; + String document = MarkdownSidecar.hasMarkdownExtension(target) + ? markdownSidecar.merge(existingDocument, keyedEntries) + : entryWriter.mergeIntoDocument(existingDocument, keyedEntries); + byte[] content = document.getBytes(StandardCharsets.UTF_8); + // Written atomically: the polling watcher (or another process) must never see a + // half-written sidecar. The fingerprint is recorded only once the file is really there. + try (AtomicFileOutputStream output = new AtomicFileOutputStream(target, false)) { + output.write(content); + } + files.recordWritten(target, content); + keyedEntries.forEach(keyedEntry -> catalog.updateHayagrivaKey(keyedEntry.entry(), keyedEntry.targetKey())); + SequencedMap written = new LinkedHashMap<>(); + keyedEntries.forEach(keyedEntry -> written.put(keyedEntry.targetKey(), new BibEntry(keyedEntry.entry()))); + files.setBaseline(target, written); + return true; + } + + /// The first user change of an entry without a source materializes its sidecar — a Markdown + /// sidecar (see [MarkdownSidecar]): next to the entry's PDF (sharing the base name, per the + /// pairing convention), or named after the citation key for entries without a file. + private Path assignSidecar(BibEntry entry) { + DirectoryLibraryCatalog catalog = files.catalog(); + Path sidecar = entry.getFiles().stream() + .filter(linkedFile -> !linkedFile.isOnlineLink()) + .map(linkedFile -> root.resolve(linkedFile.getLink()).normalize()) + .filter(linkedPath -> linkedPath.startsWith(root)) + .findFirst() + .map(paired -> paired.resolveSibling(FileUtil.getBaseName(paired) + "." + MarkdownSidecar.MARKDOWN_EXTENSION)) + // A second entry linking the same PDF, or a foreign file of that name, cannot share it + .filter(candidate -> !Files.exists(candidate) && catalog.entryIdsIn(candidate).isEmpty()) + .orElseGet(() -> unusedSidecar(entry.getCitationKey() + .map(FileNameCleaner::cleanFileName) + .filter(not(String::isBlank)) + .orElse("entry"))); + catalog.register(entry, sidecar, entry.getCitationKey().orElse("")); + return sidecar; + } + + /// Also skips names already assigned to entries whose sidecar is not written yet. + private Path unusedSidecar(String baseName) { + Path candidate = root.resolve(baseName + "." + MarkdownSidecar.MARKDOWN_EXTENSION); + for (int counter = 1; Files.exists(candidate) || !files.catalog().entryIdsIn(candidate).isEmpty(); counter++) { + candidate = root.resolve(baseName + "-" + counter + "." + MarkdownSidecar.MARKDOWN_EXTENSION); + } + return candidate; + } + + /// Renames the sidecar and its equally named PDF to the base name the filename pattern + /// generates for the entry — kept in sync as a pair, per the pairing convention. A pattern + /// failure, or a target name any pair member of another entry already occupies, leaves the + /// current name untouched. Never touches other files. + private Path applyFileNamePattern(Path file, BibEntry entry) { + return fileNameGenerator.apply(entry) + .map(String::trim) + .filter(not(String::isEmpty)) + .filter(not(FileUtil.getBaseName(file)::equals)) + .map(newBaseName -> renamePair(file, entry, newBaseName)) + .orElse(file); + } + + private Path renamePair(Path file, BibEntry entry, String newBaseName) { + Path newSidecar = file.resolveSibling(newBaseName + "." + FileUtil.getFileExtension(file).orElseThrow()); + Path oldPdf = file.resolveSibling(FileUtil.getBaseName(file) + ".pdf"); + Path newPdf = file.resolveSibling(newBaseName + ".pdf"); + boolean occupied = (Files.exists(newPdf) && !linksFile(entry, newPdf)) + || DirectoryLibrarySynchronizer.SIDECAR_EXTENSIONS.stream() + .anyMatch(extension -> Files.exists(file.resolveSibling(newBaseName + "." + extension))); + if (occupied) { + return file; + } + boolean hasPdf = Files.exists(oldPdf); + try { + // The PDF first: if that fails nothing has changed, and a failing sidecar move is + // rolled back, so the pair never ends up half renamed + if (hasPdf) { + Files.move(oldPdf, newPdf); + } + try { + if (Files.exists(file)) { + Files.move(file, newSidecar); + } + } catch (IOException e) { + if (hasPdf) { + Files.move(newPdf, oldPdf); + } + throw e; + } + } catch (IOException e) { + LOGGER.warn("Could not rename {} to the configured pattern", file, e); + return file; + } + files.relocate(file, newSidecar); + if (hasPdf) { + String newLink = root.relativize(newPdf).toString(); + String oldLink = root.relativize(oldPdf).toString(); + modelUpdateMarshaller.accept(() -> { + List updated = entry.getFiles().stream() + .map(linkedFile -> oldLink.equals(linkedFile.getLink()) + ? new LinkedFile(linkedFile.getDescription(), newLink, linkedFile.getFileType()) + : linkedFile) + .toList(); + entry.setField(StandardField.FILE, FileFieldWriter.getStringRepresentation(updated), EntriesEventSource.SHARED); + }); + } + return newSidecar; + } + + private boolean linksFile(BibEntry entry, Path file) { + return entry.getFiles().stream() + .filter(linkedFile -> !linkedFile.isOnlineLink()) + .anyMatch(linkedFile -> root.resolve(linkedFile.getLink()).normalize().equals(file.toAbsolutePath().normalize())); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/TrackedFiles.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/TrackedFiles.java new file mode 100644 index 000000000000..1836d026a8c7 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/TrackedFiles.java @@ -0,0 +1,152 @@ +package org.jabref.logic.directorylibrary; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.SequencedMap; + +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; + +import org.jspecify.annotations.NullMarked; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/// What the synchronizer knows about each file of a directory library beyond the +/// [DirectoryLibraryCatalog]: the fingerprint of its own last write (so the watcher's echo of +/// it is swallowed), the fingerprint of the content last read or written (so an external edit +/// is noticed before a pending write would overwrite it), and the entries as last read or +/// written — the base of the three-way merge that lets an external edit only touch the fields +/// it changed. Callers hold the synchronizer's monitor. +@NullMarked +class TrackedFiles { + + private static final Logger LOGGER = LoggerFactory.getLogger(TrackedFiles.class); + + private final BibDatabaseContext databaseContext; + private final DirectoryLibraryCatalog catalog; + private final Map lastWrittenFingerprints = new HashMap<>(); + private final Map lastSeenFingerprints = new HashMap<>(); + private final Map> baselines = new HashMap<>(); + + TrackedFiles(BibDatabaseContext databaseContext, DirectoryLibraryCatalog catalog) { + this.databaseContext = databaseContext; + this.catalog = catalog; + } + + DirectoryLibraryCatalog catalog() { + return catalog; + } + + /// Only entries still in the database: removed entries stay cataloged until their file is + /// rewritten (see [SidecarWriteBack]). + List entriesOf(Path file) { + List ids = catalog.entryIdsIn(file); + if (ids.isEmpty()) { + return List.of(); + } + Map byId = new HashMap<>(); + List allEntries = databaseContext.getDatabase().getEntries(); + // The UI thread mutates the (synchronized) list concurrently; field reads need no lock + synchronized (allEntries) { + allEntries.forEach(entry -> byId.put(entry.getId(), entry)); + } + return ids.stream().flatMap(id -> Optional.ofNullable(byId.get(id)).stream()).toList(); + } + + /// Records the scanned files' content as the merge base; the live entries still equal it + /// at this point. + void takeBaseline() { + for (Path file : catalog.files()) { + recordSeen(file); + baselines.put(file, copiesByKey(entriesOf(file))); + } + } + + /// Registers a file this application just wrote itself: the next change event for it is + /// recognized as a self-echo and not re-imported (consumed on match). + void recordWritten(Path file, byte[] content) { + String fingerprint = hash(content); + lastWrittenFingerprints.put(normalize(file), fingerprint); + lastSeenFingerprints.put(normalize(file), fingerprint); + } + + boolean consumeSelfEcho(Path file) { + Path normalized = normalize(file); + if (!lastWrittenFingerprints.containsKey(normalized)) { + return false; + } + return currentHash(file).map(current -> lastWrittenFingerprints.remove(normalized, current)).orElse(false); + } + + /// Remembers the file's current content as read. + void recordSeen(Path file) { + currentHash(file).ifPresent(fingerprint -> lastSeenFingerprints.put(normalize(file), fingerprint)); + } + + /// Whether the file's content differs from what was last read or written; unknown files + /// count as unchanged. + boolean changedExternally(Path file) { + return Optional.ofNullable(lastSeenFingerprints.get(normalize(file))) + .map(lastSeen -> Files.exists(file) && !currentHash(file).equals(Optional.of(lastSeen))) + .orElse(false); + } + + Map baseline(Path file) { + return baselines.getOrDefault(file, new LinkedHashMap<>()); + } + + void setBaseline(Path file, SequencedMap entriesByKey) { + baselines.put(file, entriesByKey); + } + + /// Re-homes everything known about `oldFile` to `newFile` (a rename or move). + void relocate(Path oldFile, Path newFile) { + catalog.relocateFile(oldFile, newFile); + Optional.ofNullable(baselines.remove(oldFile)).ifPresent(baseline -> baselines.put(newFile, baseline)); + Optional.ofNullable(lastSeenFingerprints.remove(normalize(oldFile))) + .ifPresent(fingerprint -> lastSeenFingerprints.put(normalize(newFile), fingerprint)); + } + + /// Forgets a file that no longer holds entries. + void forget(Path file) { + catalog.removeFile(file); + baselines.remove(file); + lastSeenFingerprints.remove(normalize(file)); + } + + static SequencedMap copiesByKey(List entries) { + SequencedMap copies = new LinkedHashMap<>(); + entries.forEach(entry -> copies.putIfAbsent(entry.getCitationKey().orElse(""), new BibEntry(entry))); + return copies; + } + + private static Path normalize(Path file) { + return file.toAbsolutePath().normalize(); + } + + 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); + } + } +} diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java new file mode 100644 index 000000000000..466a70f10e2e --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java @@ -0,0 +1,25 @@ +/// Opens a directory as a library: each Hayagriva `.yml` or Markdown sidecar +/// ([org.jabref.logic.directorylibrary.MarkdownSidecar]) next to a PDF holds one or more entries, +/// and the folder tree is mirrored as groups. Design: , +/// requirements: . +/// +/// Entry points: +/// +/// - [org.jabref.logic.directorylibrary.DirectoryLibraryScanner] builds the +/// [org.jabref.model.database.BibDatabaseContext] from a directory; sidecar-less PDFs become +/// stub entries that [org.jabref.logic.directorylibrary.PdfEnrichmentTask] enriches in the +/// background via [org.jabref.logic.directorylibrary.PdfEntryFactory]. +/// - [org.jabref.logic.directorylibrary.DirectoryLibrarySynchronizer] keeps the open library +/// and its files in sync: external file changes flow into the model (inbound), user changes +/// are written back through [org.jabref.logic.directorylibrary.SidecarWriteBack], and +/// [org.jabref.logic.directorylibrary.BibMirror] maintains the library's `.bib` mirror with +/// three-way merge-back. [org.jabref.logic.directorylibrary.DirectoryLibraryCatalog] and +/// [org.jabref.logic.directorylibrary.TrackedFiles] hold the entry-to-file bookkeeping they +/// share; [org.jabref.logic.directorylibrary.PendingWrites] debounces the writes. +/// - [org.jabref.logic.directorylibrary.DirectoryLibraryConverter] turns a regular `.bib` +/// library into a directory library. +/// +/// The Hayagriva mapping itself lives in `org.jabref.logic.importer.fileformat` (importer, +/// [org.jabref.logic.importer.fileformat.HayagrivaMapping]) and `org.jabref.logic.exporter` +/// ([org.jabref.logic.exporter.HayagrivaEntryWriter]). +package org.jabref.logic.directorylibrary; diff --git a/jabsrv/src/main/java/org/jabref/http/server/services/ServerUtils.java b/jabsrv/src/main/java/org/jabref/http/server/services/ServerUtils.java index a8782d9f0afe..a60aa6984a27 100644 --- a/jabsrv/src/main/java/org/jabref/http/server/services/ServerUtils.java +++ b/jabsrv/src/main/java/org/jabref/http/server/services/ServerUtils.java @@ -13,7 +13,7 @@ import org.jabref.http.SrvStateManager; import org.jabref.logic.ai.chatting.ChatModel; import org.jabref.logic.ai.chatting.util.ChatModelFactory; -import org.jabref.logic.directorylibrary.DirectoryLibrarySynchronizer; +import org.jabref.logic.directorylibrary.BibMirror; import org.jabref.logic.importer.FetcherException; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.fileformat.BibtexImporter; @@ -51,7 +51,7 @@ private static String libraryId(Path path) { /// @throws NotFoundException if no library with the given id is found public static @NonNull Path getLibraryFile(String id, SrvStateManager srvStateManager) { Path path = getLibraryPath(id, srvStateManager); - return Files.isDirectory(path) ? path.resolve(DirectoryLibrarySynchronizer.mirrorFileName(path)) : path; + return Files.isDirectory(path) ? path.resolve(BibMirror.fileName(path)) : path; } /// Returns ids of all libraries the state manager currently considers From 1af82558734c11722f50dc75a5f002b16de1255d Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Mon, 7 Sep 2026 00:17:04 +0200 Subject: [PATCH 2/4] Apply IntelliJ formatter after upstream resync Fixes the CI format check. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- .../directorylibrary/DirectoryLibrarySynchronizer.java | 3 ++- .../org/jabref/logic/exporter/HayagrivaEntryWriter.java | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java index 44cafe5902f6..7ef46174ebe0 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -246,7 +246,8 @@ Path sidecarOf(BibEntry entry) { /// Waits until every event queued so far has been handled (tests). void awaitPendingEvents() throws InterruptedException, ExecutionException { - syncExecutor.submit(() -> { }).get(); + syncExecutor.submit(() -> { + }).get(); } void doInitializeMirror() { diff --git a/jablib/src/main/java/org/jabref/logic/exporter/HayagrivaEntryWriter.java b/jablib/src/main/java/org/jabref/logic/exporter/HayagrivaEntryWriter.java index 3d6ac8edaf77..876fd667275a 100644 --- a/jablib/src/main/java/org/jabref/logic/exporter/HayagrivaEntryWriter.java +++ b/jablib/src/main/java/org/jabref/logic/exporter/HayagrivaEntryWriter.java @@ -352,9 +352,12 @@ private ObjectNode parentNode(String type, String title) { private String booktitleParentType(EntryType entryType) { return switch (entryType) { - case StandardEntryType.InProceedings -> "proceedings"; - case StandardEntryType.InBook -> "book"; - default -> "anthology"; + case StandardEntryType.InProceedings -> + "proceedings"; + case StandardEntryType.InBook -> + "book"; + default -> + "anthology"; }; } From c1edec30f5b847cd88c410035926a70d948065fc Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Mon, 7 Sep 2026 11:13:17 +0200 Subject: [PATCH 3/4] Renumber the directory-as-library ADR to 0072 Upstream added a 0071 ADR after this stack claimed the number, so the MADR duplicate-ID check failed. Co-Authored-By: Claude Opus 4.8 --- ....md => 0072-directory-as-library-with-hayagriva-sidecars.md} | 2 +- docs/requirements/directory-library.md | 2 +- .../java/org/jabref/logic/directorylibrary/package-info.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename docs/decisions/{0071-directory-as-library-with-hayagriva-sidecars.md => 0072-directory-as-library-with-hayagriva-sidecars.md} (99%) diff --git a/docs/decisions/0071-directory-as-library-with-hayagriva-sidecars.md b/docs/decisions/0072-directory-as-library-with-hayagriva-sidecars.md similarity index 99% rename from docs/decisions/0071-directory-as-library-with-hayagriva-sidecars.md rename to docs/decisions/0072-directory-as-library-with-hayagriva-sidecars.md index 3a1db01cbb2b..6ffd7c03e355 100644 --- a/docs/decisions/0071-directory-as-library-with-hayagriva-sidecars.md +++ b/docs/decisions/0072-directory-as-library-with-hayagriva-sidecars.md @@ -1,5 +1,5 @@ --- -nav_order: 0071 +nav_order: 0072 parent: Decision Records --- diff --git a/docs/requirements/directory-library.md b/docs/requirements/directory-library.md index 6c076fbda0a5..1c402919d1ad 100644 --- a/docs/requirements/directory-library.md +++ b/docs/requirements/directory-library.md @@ -19,7 +19,7 @@ online and the metadata behind it fills only the fields the PDF did not provide. and `.md` files without a Hayagriva frontmatter are skipped; unparseable Hayagriva files are reported as warnings without aborting the scan. Scanning must not write or modify any file in the directory. -See [ADR 71](../decisions/0071-directory-as-library-with-hayagriva-sidecars.md) for more details. +See [ADR 72](../decisions/0072-directory-as-library-with-hayagriva-sidecars.md) for more details. Needs: impl diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java index 466a70f10e2e..aa5efe7c935e 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java @@ -1,6 +1,6 @@ /// Opens a directory as a library: each Hayagriva `.yml` or Markdown sidecar /// ([org.jabref.logic.directorylibrary.MarkdownSidecar]) next to a PDF holds one or more entries, -/// and the folder tree is mirrored as groups. Design: , +/// and the folder tree is mirrored as groups. Design: , /// requirements: . /// /// Entry points: From a974c4b4b4b025202f8a3a5cbfc8d1ac7253bd32 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Sun, 13 Sep 2026 03:24:25 +0200 Subject: [PATCH 4/4] Point package docs to renumbered ADR 0075 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CajtxjcCEjS87f4hoTBTJD --- .../java/org/jabref/logic/directorylibrary/package-info.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java index aa5efe7c935e..b0b723e83abc 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/package-info.java @@ -1,6 +1,6 @@ /// Opens a directory as a library: each Hayagriva `.yml` or Markdown sidecar /// ([org.jabref.logic.directorylibrary.MarkdownSidecar]) next to a PDF holds one or more entries, -/// and the folder tree is mirrored as groups. Design: , +/// and the folder tree is mirrored as groups. Design: , /// requirements: . /// /// Entry points: