From d9c7c5c84f3e02e961909c7647551afa85aa0b3b Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Tue, 14 Jul 2026 11:09:53 +0200 Subject: [PATCH 01/15] Rename sidecar and PDF together to the configured filename pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When write-back touches a single-entry sidecar, the sidecar and its equally named PDF are renamed to the base name the filename pattern (Linked files preferences) generates for the entry, keeping the pair in sync — e.g. a citation-key edit renames both files to " - ". Multi-entry files keep their name, occupied targets and pattern failures leave the current name untouched, and the entry's file link and the catalog follow the rename so the watcher does not re-import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- CHANGELOG.md | 1 + docs/requirements/directory-library.md | 12 ++++ .../actions/OpenDirectoryLibraryAction.java | 8 ++- .../DirectoryLibrarySynchronizer.java | 62 ++++++++++++++++- .../DirectoryLibrarySynchronizerTest.java | 69 ++++++++++++++++++- 5 files changed, 149 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf3e30181607..fce43026437b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added +- In directory libraries, a sidecar and its PDF are now renamed together to the configured filename pattern (Preferences > Linked files) whenever the entry is edited — e.g. changing the citation key renames both files. [TODO] - The groups panel of a directory library now mirrors the folder structure: each subdirectory appears as a group containing the entries whose files live there (updated live as files change). [#740](https://github.com/JabRef/jabref-koppor/pull/740) - Directory libraries now save into their Hayagriva sidecar files: edits are written back automatically (debounced; Ctrl+S forces the write and no longer creates a `.bib`), the first edit of a PDF-only entry creates its sidecar, renaming a citation key renames the YAML key, and deleting an entry removes it from its file (the file is trashed once empty, the PDF stays). Hand-written YAML content that JabRef does not understand survives rewrites. [#739](https://github.com/JabRef/jabref-koppor/pull/739) - Directory libraries now stay in sync with external file changes: creating, editing, deleting, or renaming `.yml`/`.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) diff --git a/docs/requirements/directory-library.md b/docs/requirements/directory-library.md index 086ee5cfb0b0..fe88e8a1452f 100644 --- a/docs/requirements/directory-library.md +++ b/docs/requirements/directory-library.md @@ -68,4 +68,16 @@ entries or subgroups can be added to them, they cannot be dragged or edited. Needs: impl +## The sidecar and its PDF follow the configured filename pattern +`req~directory-library.pattern-rename~1` + +When write-back touches a single-entry sidecar, the sidecar and its equally named PDF are +renamed together to the base name the configured filename pattern (Linked files preferences) +generates for the entry, keeping the pair in sync. Multi-entry files have no single generating +entry and keep their name; occupied target names and pattern failures leave the current name +untouched. Entry file links and the catalog follow the rename; the watcher does not re-import +the renamed files. + +Needs: impl + <!-- markdownlint-disable-file MD022 --> 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 17522ea49a51..2f62aae81df6 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 @@ -3,6 +3,8 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Function; import javax.swing.undo.UndoManager; @@ -25,7 +27,9 @@ import org.jabref.logic.util.BackgroundTask; import org.jabref.logic.util.DirectoryMonitor; import org.jabref.logic.util.TaskExecutor; +import org.jabref.logic.util.io.FileUtil; import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.BibEntryTypesManager; import org.jabref.model.util.FileUpdateMonitor; @@ -134,8 +138,10 @@ private void showLibraryTab(DirectoryLibraryScanner.ScanResult scanResult) { PdfEntryFactory pdfEntryFactory = new PdfEntryFactory( preferences.getImportFormatPreferences(), preferences.getFilePreferences(), preferences.getCitationKeyPatternPreferences()); + Function<BibEntry, Optional<String>> fileNameGenerator = entry -> FileUtil.createFileNameFromPattern( + databaseContext.getDatabase(), entry, preferences.getFilePreferences().getFileNamePattern()); DirectoryLibrarySynchronizer synchronizer = new DirectoryLibrarySynchronizer( - databaseContext, scanResult.catalog(), pdfEntryFactory, this::disposeFile, + databaseContext, scanResult.catalog(), pdfEntryFactory, this::disposeFile, fileNameGenerator, UiTaskExecutor::runInJavaFXThread); databaseContext.attachDirectorySynchronizer(synchronizer); synchronizer.startWatching(Injector.instantiateModelOrService(DirectoryMonitor.class)); 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 9b46c250142b..9ff221e27646 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -28,6 +28,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.function.Function; import org.jabref.logic.bibtex.FileFieldWriter; import org.jabref.logic.exporter.HayagrivaEntryWriter; @@ -58,6 +59,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +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" @@ -114,6 +117,7 @@ public class DirectoryLibrarySynchronizer implements FileAlterationListener { private final HayagrivaEntryWriter entryWriter = new HayagrivaEntryWriter(); private final Set<Path> dirtyFiles = new LinkedHashSet<>(); private final Consumer<Path> fileDisposer; + private final Function<BibEntry, Optional<String>> fileNameGenerator; private boolean writeScheduled; private @Nullable FileAlterationObserver observer; @@ -126,20 +130,23 @@ public DirectoryLibrarySynchronizer(BibDatabaseContext databaseContext, DirectoryLibraryCatalog catalog, PdfEntryFactory pdfEntryFactory, Consumer<Path> fileDisposer, + Function<BibEntry, Optional<String>> fileNameGenerator, Consumer<Runnable> modelUpdateMarshaller) { - this(databaseContext, catalog, pdfEntryFactory, fileDisposer, modelUpdateMarshaller, Clock.systemUTC()); + this(databaseContext, catalog, pdfEntryFactory, fileDisposer, fileNameGenerator, modelUpdateMarshaller, Clock.systemUTC()); } DirectoryLibrarySynchronizer(BibDatabaseContext databaseContext, DirectoryLibraryCatalog catalog, PdfEntryFactory pdfEntryFactory, Consumer<Path> fileDisposer, + Function<BibEntry, Optional<String>> fileNameGenerator, Consumer<Runnable> modelUpdateMarshaller, Clock clock) { this.databaseContext = databaseContext; this.catalog = catalog; this.pdfEntryFactory = pdfEntryFactory; this.fileDisposer = fileDisposer; + this.fileNameGenerator = fileNameGenerator; this.root = databaseContext.getDirectoryLibraryRoot().orElseThrow( () -> new IllegalArgumentException("Context is not a directory library")); this.modelUpdateMarshaller = modelUpdateMarshaller; @@ -329,11 +336,64 @@ private synchronized void writeDirtyFiles() { files.forEach(this::writeFile); } + /// 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. Occupied + /// target names and pattern failures leave the current name untouched. Never touches other + /// files. + // [impl->req~directory-library.pattern-rename~1] + private Path applyFileNamePattern(Path file, BibEntry entry) { + Optional<String> generated = fileNameGenerator.apply(entry).map(String::trim).filter(not(String::isEmpty)); + if (generated.isEmpty() || generated.get().equals(FileUtil.getBaseName(file))) { + return file; + } + Path directory = file.getParent(); + if (directory == null) { + return file; + } + String oldBaseName = FileUtil.getBaseName(file); + String extension = FileUtil.getFileExtension(file).orElse("yml"); + Path newSidecar = directory.resolve(generated.get() + "." + extension); + Path oldPdf = directory.resolve(oldBaseName + ".pdf"); + Path newPdf = directory.resolve(generated.get() + ".pdf"); + if (Files.exists(newSidecar) || (Files.exists(oldPdf) && Files.exists(newPdf))) { + return file; + } + try { + if (Files.exists(file)) { + Files.move(file, newSidecar); + } + catalog.relocateFile(file, newSidecar); + if (Files.exists(oldPdf)) { + Files.move(oldPdf, newPdf); + String newLink = root.relativize(newPdf).toString(); + String oldLink = root.relativize(oldPdf).toString(); + modelUpdateMarshaller.accept(() -> { + List<LinkedFile> 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; + } catch (IOException e) { + LOGGER.warn("Could not rename {} to the configured pattern", file, e); + return file; + } + } + private void writeFile(Path file) { List<BibEntry> entries = entriesOf(file); if (entries.isEmpty()) { return; } + if (entries.size() == 1) { + // 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 + file = applyFileNamePattern(file, entries.getFirst()); + } List<HayagrivaEntryWriter.KeyedEntry> keyedEntries = new ArrayList<>(); Set<String> usedKeys = new HashSet<>(); for (BibEntry entry : entries) { diff --git a/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java index beb6eb39a7c2..db9573910b19 100644 --- a/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java +++ b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.function.Function; import javafx.collections.FXCollections; @@ -76,6 +77,9 @@ public Clock withZone(ZoneId zone) { private final List<Path> disposedFiles = new ArrayList<>(); + /// Tests opt into pattern renames by replacing this; the default keeps file names as-is. + private Function<BibEntry, Optional<String>> fileNameGenerator = entry -> Optional.empty(); + private BibDatabaseContext context; private DirectoryLibrarySynchronizer synchronizer; @@ -84,7 +88,7 @@ private void openLibrary() throws IOException { DirectoryLibraryScanner.ScanResult scanResult = new DirectoryLibraryScanner(pdfEntryFactory).scan(root); context = scanResult.databaseContext(); synchronizer = new DirectoryLibrarySynchronizer(context, scanResult.catalog(), pdfEntryFactory, - disposedFiles::add, Runnable::run, clock); + disposedFiles::add, fileNameGenerator, Runnable::run, clock); } /// GROBID off and no identifiers in the fixtures, so no network is touched @@ -386,4 +390,67 @@ void ownSidecarWritesAreNotReimported() throws IOException { assertEquals(1, entries().size()); assertEquals(Optional.of("written back"), entry.getField(StandardField.NOTE)); } + + @Test + void patternRenameMovesSidecarAndPairedPdfTogether() throws IOException { + fileNameGenerator = entry -> entry.getCitationKey(); + Path sidecar = root.resolve("smith2020.yml"); + Files.writeString(sidecar, ARTICLE_YAML); + Files.createFile(root.resolve("smith2020.pdf")); + openLibrary(); + BibEntry entry = entries().getFirst(); + + entry.setCitationKey("smith2021"); + synchronizer.handleLocalChange(entry); + synchronizer.flush(); + + assertTrue(Files.exists(root.resolve("smith2021.yml"))); + assertTrue(Files.exists(root.resolve("smith2021.pdf"))); + assertFalse(Files.exists(sidecar)); + assertFalse(Files.exists(root.resolve("smith2020.pdf"))); + assertEquals("smith2021.pdf", entry.getFiles().getFirst().getLink()); + assertTrue(Files.readString(root.resolve("smith2021.yml")).contains("smith2021:")); + } + + @Test + void patternRenameSkipsOccupiedTargetNames() throws IOException { + fileNameGenerator = entry -> Optional.of("taken"); + Path sidecar = root.resolve("smith2020.yml"); + Files.writeString(sidecar, ARTICLE_YAML); + Files.writeString(root.resolve("taken.yml"), ARTICLE_YAML.replace("smith2020", "taken")); + openLibrary(); + BibEntry entry = entries().stream() + .filter(candidate -> candidate.getCitationKey().equals(Optional.of("smith2020"))) + .findFirst().orElseThrow(); + + entry.setField(StandardField.NOTE, "changed"); + synchronizer.handleLocalChange(entry); + synchronizer.flush(); + + assertTrue(Files.exists(sidecar)); + assertTrue(Files.readString(sidecar).contains("changed")); + } + + @Test + void multiEntryFilesKeepTheirNameDespitePattern() throws IOException { + fileNameGenerator = entry -> Optional.of("wrong"); + Path file = root.resolve("collection.yml"); + Files.writeString(file, """ + first: + type: article + title: First + second: + type: article + title: Second + """); + openLibrary(); + BibEntry first = entries().getFirst(); + + first.setField(StandardField.NOTE, "edited"); + synchronizer.handleLocalChange(first); + synchronizer.flush(); + + assertTrue(Files.exists(file)); + assertFalse(Files.exists(root.resolve("wrong.yml"))); + } } From 6a5dc2063c95056445edc783d977608b92793fa9 Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Tue, 14 Jul 2026 11:11:28 +0200 Subject: [PATCH 02/15] Link CHANGELOG entry to its pull request Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fce43026437b..0c18e5087b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added -- In directory libraries, a sidecar and its PDF are now renamed together to the configured filename pattern (Preferences > Linked files) whenever the entry is edited — e.g. changing the citation key renames both files. [TODO] +- In directory libraries, a sidecar and its PDF are now renamed together to the configured filename pattern (Preferences > Linked files) whenever the entry is edited — e.g. changing the citation key renames both files. [#741](https://github.com/JabRef/jabref-koppor/pull/741) - The groups panel of a directory library now mirrors the folder structure: each subdirectory appears as a group containing the entries whose files live there (updated live as files change). [#740](https://github.com/JabRef/jabref-koppor/pull/740) - Directory libraries now save into their Hayagriva sidecar files: edits are written back automatically (debounced; Ctrl+S forces the write and no longer creates a `.bib`), the first edit of a PDF-only entry creates its sidecar, renaming a citation key renames the YAML key, and deleting an entry removes it from its file (the file is trashed once empty, the PDF stays). Hand-written YAML content that JabRef does not understand survives rewrites. [#739](https://github.com/JabRef/jabref-koppor/pull/739) - Directory libraries now stay in sync with external file changes: creating, editing, deleting, or renaming `.yml`/`.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) From c5876ced7f83ae58f45804215e4d87e16224791a Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Tue, 14 Jul 2026 11:16:18 +0200 Subject: [PATCH 03/15] Align lambda ternary to the IntelliJ code style Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .../logic/directorylibrary/DirectoryLibrarySynchronizer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 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 9ff221e27646..f9b1a343d88b 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -370,8 +370,8 @@ private Path applyFileNamePattern(Path file, BibEntry entry) { modelUpdateMarshaller.accept(() -> { List<LinkedFile> updated = entry.getFiles().stream() .map(linkedFile -> oldLink.equals(linkedFile.getLink()) - ? new LinkedFile(linkedFile.getDescription(), newLink, linkedFile.getFileType()) - : linkedFile) + ? new LinkedFile(linkedFile.getDescription(), newLink, linkedFile.getFileType()) + : linkedFile) .toList(); entry.setField(StandardField.FILE, FileFieldWriter.getStringRepresentation(updated), EntriesEventSource.SHARED); }); From c549614d3dc9fcfea68684a934aa950d5f13274d Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Sat, 18 Jul 2026 00:21:23 +0200 Subject: [PATCH 04/15] Update write-back requirement marker and apply OpenRewrite cleanup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- .../java/org/jabref/gui/exporter/SaveDatabaseAction.java | 2 +- .../org/jabref/logic/directorylibrary/MarkdownSidecar.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java b/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java index f1014dcf16cb..ad0eab284161 100644 --- a/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java +++ b/jabgui/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java @@ -203,7 +203,7 @@ private boolean save(BibDatabaseContext bibDatabaseContext, SaveDatabaseMode mod if (bibDatabaseContext.getLocation() == DatabaseLocation.DIRECTORY) { // A directory library persists into its sidecar files; saving means flushing the // debounced writes, never writing a .bib ("Save as" remains the explicit snapshot) - // [impl->req~directory-library.write-back~1] + // [impl->req~directory-library.write-back~2] DirectoryLibrarySynchronizer synchronizer = bibDatabaseContext.getDirectorySynchronizer(); if (synchronizer != null) { synchronizer.flush(); diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java index a63f5528aba4..9139a8efe4ff 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java @@ -12,6 +12,7 @@ import java.util.Locale; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import org.jabref.logic.exporter.HayagrivaEntryWriter; import org.jabref.logic.importer.ParserResult; @@ -26,6 +27,8 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; +import static java.util.function.Predicate.not; + /// A directory-library sidecar in Markdown form: `X.md` next to `X.pdf`. The YAML frontmatter /// (between two `---` lines) is a regular Hayagriva document carrying the bibliographic data; /// the Markdown body below carries JabRef's long-form notes. The text under the `# Notes` @@ -219,7 +222,7 @@ private static String renderBody(String existingBody, BibEntry entry) { } private static Optional<String> commentValue(BibEntry entry, Field field) { - return entry.getField(field).map(String::strip).filter(value -> !value.isEmpty()); + return entry.getField(field).map(String::strip).filter(not(String::isEmpty)); } private static boolean isCommentSection(String heading) { From b0e3eab3f5a149c1b95aeef499542cfa975b465f Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Sat, 18 Jul 2026 00:21:23 +0200 Subject: [PATCH 05/15] Ignore Markdown companions in automatic file linking An X.md sharing its base name with another found or linked file, or any Markdown sidecar (Hayagriva frontmatter), is a notes companion of the entry, not an attachment - auto-link must not pick it up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- CHANGELOG.md | 1 + docs/requirements/files.md | 4 +- .../externalfiles/AutoSetFileLinksUtil.java | 30 ++++++++++++- .../AutoSetFileLinksUtilTest.java | 42 ++++++++++++++++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 066e521d9ea0..2e2177e03f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Changed +- Automatic file linking no longer links a Markdown file that shares its base name with another found or linked file (e.g. `X.md` next to `X.pdf`) or whose frontmatter is a Hayagriva document (a directory-library sidecar), treating such files as notes companions instead of attachments. [#741](https://github.com/JabRef/jabref-koppor/pull/741) - The Hayagriva YAML exporter is now implemented programmatically instead of via a layout template: re-exporting an imported Hayagriva file preserves structured data JabRef cannot represent (short titles, person aliases, additional identifiers), `misc` entries export with a lowercase type, and journal details are written into the periodical parent. The `HayagrivaType` custom-layout formatter was removed. [#736](https://github.com/JabRef/jabref-koppor/pull/736) - Hayagriva YAML import and export now cover JabRef's "Comment" field and per-user comment fields (written as `comment`/`comment-<name>` extension keys, which the Hayagriva parser ignores), and entries carrying only BibTeX `year`/`month` fields get their `date` written. [#736](https://github.com/JabRef/jabref-koppor/pull/736) - The Hayagriva YAML exporter now writes all fields the new Hayagriva importer reads. [#16190](https://github.com/JabRef/jabref/pull/16190) diff --git a/docs/requirements/files.md b/docs/requirements/files.md index be3e971bd15c..8fdd828c53e9 100644 --- a/docs/requirements/files.md +++ b/docs/requirements/files.md @@ -23,12 +23,14 @@ As a consequence, the file is copied. Needs: impl ## Auto-link broken linked file -`req~logic.externalfiles.file-transfer.auto-link~1` +`req~logic.externalfiles.file-transfer.auto-link~2` After a file is linked to an entry, the user might move the file to another directory without JabRef, leading to broken linked file. The function `Quality -> Automatically set file links` can help user to auto-link the moved files based on the broken file name, or the entry citation key. +A Markdown file sharing its base name with another associated or linked file (e.g. `X.md` next to `X.pdf`) is treated as a notes companion of that file and is never auto-linked. The same holds for a Markdown sidecar of a directory library (a Hayagriva frontmatter block) even without such a partner: it is an entry's source, not an attachment. Any other Markdown file is still linked. + Needs: impl, utest <!-- markdownlint-disable-file MD022 --> diff --git a/jabgui/src/main/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtil.java b/jabgui/src/main/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtil.java index a850e484b841..d615220acfef 100644 --- a/jabgui/src/main/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtil.java +++ b/jabgui/src/main/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtil.java @@ -21,6 +21,7 @@ import org.jabref.gui.frame.ExternalApplicationsPreferences; import org.jabref.logic.FilePreferences; import org.jabref.logic.bibtex.FileFieldWriter; +import org.jabref.logic.directorylibrary.MarkdownSidecar; import org.jabref.logic.util.io.AutoLinkPreferences; import org.jabref.logic.util.io.FileFinder; import org.jabref.logic.util.io.FileFinders; @@ -57,6 +58,8 @@ public List<IOException> getFileExceptions() { private static final Logger LOGGER = LoggerFactory.getLogger(AutoSetFileLinksUtil.class); + private final MarkdownSidecar markdownSidecar = new MarkdownSidecar(); + private final List<Path> directories; private final AutoLinkPreferences autoLinkPreferences; private final ExternalApplicationsPreferences externalApplicationsPreferences; @@ -78,7 +81,7 @@ private AutoSetFileLinksUtil(List<Path> directories, ExternalApplicationsPrefere this.brokenLinkedFileNameBasedFileFinder = FileFinders.constructBrokenLinkedFileNameBasedFileFinder(); } - /// [impl->req~logic.externalfiles.file-transfer.auto-link~1] + /// [impl->req~logic.externalfiles.file-transfer.auto-link~2] public LinkFilesResult linkAssociatedFiles(List<BibEntry> entries, BiConsumer<List<LinkedFile>, BibEntry> onAddLinkedFile) { LinkFilesResult result = new LinkFilesResult(); @@ -236,11 +239,36 @@ public Collection<LinkedFile> findAssociatedNotLinkedFilesWithFinder( // Only keep associated files that are not linked return associatedFiles .stream() + .filter(associatedFile -> !isMarkdownCompanion(associatedFile, associatedFiles, linkedFiles)) .filter(associatedFile -> !isFileAlreadyLinked(associatedFile, linkedFiles)) .map(this::buildLinkedFileFromPath) .toList(); } + /// A Markdown file sharing its base name with another associated or linked file (e.g. `X.md` + /// next to `X.pdf`) holds notes on that file rather than being a document of its own, so it + /// must not be auto-linked. The same holds for a Markdown sidecar (Hayagriva frontmatter, + /// see [MarkdownSidecar]) even without such a partner: it is an entry's source, not an + /// attachment. Any other Markdown file is still linked. + private boolean isMarkdownCompanion(Path file, List<Path> associatedFiles, List<Path> linkedFiles) { + if (!MarkdownSidecar.hasMarkdownExtension(file)) { + return false; + } + String baseName = FileUtil.getBaseName(file); + boolean hasPartner = Stream.concat(associatedFiles.stream(), linkedFiles.stream()) + .filter(other -> !other.equals(file)) + .anyMatch(other -> baseName.equalsIgnoreCase(FileUtil.getBaseName(other))); + if (hasPartner) { + return true; + } + try { + return markdownSidecar.looksLikeSidecar(file); + } catch (IOException e) { + LOGGER.debug("Could not probe {} for a sidecar frontmatter", file, e); + return false; + } + } + private boolean isBrokenLinkedFile(LinkedFile file) { return file.findIn(directories).isEmpty(); } diff --git a/jabgui/src/test/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtilTest.java b/jabgui/src/test/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtilTest.java index 5d5b7122094e..92a39202d824 100644 --- a/jabgui/src/test/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtilTest.java +++ b/jabgui/src/test/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtilTest.java @@ -71,6 +71,46 @@ void findAssociatedNotLinkedFilesSuccess() throws IOException { assertEquals(expected, actual); } + /// [utest->req~logic.externalfiles.file-transfer.auto-link~2] + @Test + void markdownCompanionOfAssociatedFileIsIgnored() throws IOException { + Files.createFile(path.getParent().resolve("CiteKey.md")); + when(databaseContext.getFileDirectories(any())).thenReturn(List.of(path.getParent())); + AutoSetFileLinksUtil util = new AutoSetFileLinksUtil(databaseContext, externalApplicationsPreferences, filePreferences, autoLinkPrefs); + Collection<LinkedFile> actual = util.findAssociatedNotLinkedFiles(entry); + assertEquals(List.of(new LinkedFile("", Path.of("CiteKey.pdf"), "PDF")), actual); + } + + /// [utest->req~logic.externalfiles.file-transfer.auto-link~2] + @Test + void markdownFileWithoutCompanionIsLinked(@TempDir Path tempDir) throws IOException { + Files.createFile(tempDir.resolve("CiteKey.md")); + when(databaseContext.getFileDirectories(any())).thenReturn(List.of(tempDir)); + AutoSetFileLinksUtil util = new AutoSetFileLinksUtil(databaseContext, externalApplicationsPreferences, filePreferences, autoLinkPrefs); + Collection<LinkedFile> actual = util.findAssociatedNotLinkedFiles(entry); + assertEquals(List.of(new LinkedFile("", Path.of("CiteKey.md"), "Markdown")), actual); + } + + /// [utest->req~logic.externalfiles.file-transfer.auto-link~2] + @Test + void markdownSidecarWithoutPartnerIsIgnored(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("CiteKey.md"), """ + --- + CiteKey: + type: article + title: A Test Article + --- + + # Notes + + Some notes. + """); + when(databaseContext.getFileDirectories(any())).thenReturn(List.of(tempDir)); + AutoSetFileLinksUtil util = new AutoSetFileLinksUtil(databaseContext, externalApplicationsPreferences, filePreferences, autoLinkPrefs); + Collection<LinkedFile> actual = util.findAssociatedNotLinkedFiles(entry); + assertEquals(List.of(), actual); + } + @Test void findAssociatedNotLinkedFilesForEmptySearchDir() throws IOException { when(databaseContext.getFileDirectories(any())).thenReturn(List.of()); @@ -181,7 +221,7 @@ void findAllAssociatedNotLinkedFilesAndNotRepeated(@TempDir Path tempDir) throws assertEquals(expected, Set.copyOf(matchedFiles)); } - /// [utest->req~logic.externalfiles.file-transfer.auto-link~1] + /// [utest->req~logic.externalfiles.file-transfer.auto-link~2] @Nested @DisplayName("linkAssociatedFiles") class linkAssociatedFiles { From 367f7268bc13dbe9ea4eacfacfd406d06d073a94 Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Sat, 18 Jul 2026 00:43:44 +0200 Subject: [PATCH 06/15] Align stream chain to the IntelliJ code style Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- .../org/jabref/logic/directorylibrary/MarkdownSidecar.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java index 9139a8efe4ff..150254a93825 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java @@ -108,8 +108,8 @@ public ParserResult read(Path file) throws IOException { public String merge(@Nullable String existingDocument, List<HayagrivaEntryWriter.KeyedEntry> entries) { Optional<Document> existing = Optional.ofNullable(existingDocument).flatMap(MarkdownSidecar::split); List<HayagrivaEntryWriter.KeyedEntry> frontmatterEntries = entries.stream() - .map(keyed -> new HayagrivaEntryWriter.KeyedEntry(keyed.previousKey(), keyed.targetKey(), withoutCommentFields(keyed.entry()))) - .toList(); + .map(keyed -> new HayagrivaEntryWriter.KeyedEntry(keyed.previousKey(), keyed.targetKey(), withoutCommentFields(keyed.entry()))) + .toList(); String frontmatter = entryWriter.mergeIntoDocument(existing.map(Document::frontmatter).orElse(null), frontmatterEntries); String body = entries.isEmpty() ? "" : renderBody(existing.map(Document::body).orElse(""), entries.getFirst().entry()); From a43d608f29e3e91915c9758e257e325308d39eaa Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Sat, 18 Jul 2026 01:05:11 +0200 Subject: [PATCH 07/15] Remove unused Predicate import Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- .../java/org/jabref/logic/directorylibrary/MarkdownSidecar.java | 1 - 1 file changed, 1 deletion(-) diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java index 150254a93825..6b783110fcdf 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/MarkdownSidecar.java @@ -12,7 +12,6 @@ import java.util.Locale; import java.util.Optional; import java.util.Set; -import java.util.function.Predicate; import org.jabref.logic.exporter.HayagrivaEntryWriter; import org.jabref.logic.importer.ParserResult; From c7cf7553c19f4afad4d0fa74b04fa961b536e14c Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Sat, 18 Jul 2026 01:05:11 +0200 Subject: [PATCH 08/15] Enable reveal and terminal for directory libraries Both tab context menu actions were gated on a saved .bib path; a directory library's root is just as revealable and terminal-openable. NativeDesktop.openConsole now accepts a directory (opened itself) as well as a file (opened at its parent, as before). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- .../org/jabref/gui/actions/ActionHelper.java | 7 +++++++ .../jabref/gui/desktop/os/NativeDesktop.java | 12 +++++++----- .../org/jabref/gui/frame/JabRefFrame.java | 7 ++++--- .../jabref/gui/frame/OpenConsoleAction.java | 19 +++++++++++-------- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/actions/ActionHelper.java b/jabgui/src/main/java/org/jabref/gui/actions/ActionHelper.java index cb5cf431f9b9..481a1d195a36 100644 --- a/jabgui/src/main/java/org/jabref/gui/actions/ActionHelper.java +++ b/jabgui/src/main/java/org/jabref/gui/actions/ActionHelper.java @@ -35,6 +35,13 @@ public static BooleanExpression needsSavedLocalDatabase(StateManager stateManage return BooleanExpression.booleanExpression(binding); } + /// Like [#needsSavedLocalDatabase], but also accepts a directory library: both have a + /// location on disk to reveal in the file explorer or open a terminal in. + public static BooleanExpression needsDatabaseOnDisk(StateManager stateManager) { + EasyBinding<Boolean> binding = EasyBind.map(stateManager.activeDatabaseProperty(), context -> context.filter(c -> c.getDatabasePath().or(c::getDirectoryLibraryRoot).isPresent()).isPresent()); + return BooleanExpression.booleanExpression(binding); + } + public static BooleanExpression needsSharedDatabase(StateManager stateManager) { EasyBinding<Boolean> binding = EasyBind.map(stateManager.activeDatabaseProperty(), context -> context.filter(c -> c.getLocation() == DatabaseLocation.SHARED).isPresent()); return BooleanExpression.booleanExpression(binding); diff --git a/jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java b/jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java index a3f86c090f0d..19e95dee4463 100644 --- a/jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java +++ b/jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java @@ -232,15 +232,17 @@ public static void openFolderAndSelectFile(Path fileLink, executeCommand(command, absolutePath, dialogService); } - /// Opens a new console starting on the given file location + /// Opens a new console: in the given directory itself, or, given a file, in the file's + /// parent directory. /// - /// @param file Location the console should be opened at. - public static void openConsole(Path file, GuiPreferences preferences, DialogService dialogService) throws IOException { - if (file == null) { + /// @param fileOrDirectory Location the console should be opened at. + public static void openConsole(Path fileOrDirectory, GuiPreferences preferences, DialogService dialogService) throws IOException { + if (fileOrDirectory == null) { return; } - String absolutePath = file.toAbsolutePath().getParent().toString(); + Path absolute = fileOrDirectory.toAbsolutePath(); + String absolutePath = Files.isDirectory(absolute) ? absolute.toString() : absolute.getParent().toString(); boolean useCustomTerminal = preferences.getExternalApplicationsPreferences().useCustomTerminal(); if (!useCustomTerminal) { diff --git a/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java b/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java index 6cf80aa8c5ab..e588585e1e42 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java @@ -75,7 +75,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static org.jabref.gui.actions.ActionHelper.needsSavedLocalDatabase; +import static org.jabref.gui.actions.ActionHelper.needsDatabaseOnDisk; /// Represents the inner frame of the JabRef window public class JabRefFrame extends BorderPane implements LibraryTabContainer, UiMessageHandler { @@ -799,12 +799,13 @@ public OpenDatabaseFolder(DialogService dialogService, StateManager stateManager this.dialogService = dialogService; this.preferences = preferences; this.databaseContext = databaseContext; - this.executable.bind(needsSavedLocalDatabase(stateManager)); + this.executable.bind(needsDatabaseOnDisk(stateManager)); } @Override public void execute() { - Optional.of(databaseContext.get()).flatMap(BibDatabaseContext::getDatabasePath).ifPresent(path -> { + // For a directory library, reveal the library's root directory itself + Optional.of(databaseContext.get()).flatMap(context -> context.getDatabasePath().or(context::getDirectoryLibraryRoot)).ifPresent(path -> { try { NativeDesktop.openFolderAndSelectFile(path, preferences.getExternalApplicationsPreferences(), dialogService); } catch (IOException e) { diff --git a/jabgui/src/main/java/org/jabref/gui/frame/OpenConsoleAction.java b/jabgui/src/main/java/org/jabref/gui/frame/OpenConsoleAction.java index 789875729e03..2f2f1da37bb7 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/OpenConsoleAction.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/OpenConsoleAction.java @@ -33,7 +33,7 @@ public OpenConsoleAction(Supplier<BibDatabaseContext> databaseContext, StateMana this.preferences = preferences; this.dialogService = dialogService; - this.executable.bind(ActionHelper.needsSavedLocalDatabase(stateManager)); + this.executable.bind(ActionHelper.needsDatabaseOnDisk(stateManager)); } /// Using this constructor will result in executing the command on the active database. @@ -43,12 +43,15 @@ public OpenConsoleAction(StateManager stateManager, GuiPreferences preferences, @Override public void execute() { - Optional.ofNullable(databaseContext.get()).or(stateManager::getActiveDatabase).flatMap(BibDatabaseContext::getDatabasePath).ifPresent(path -> { - try { - NativeDesktop.openConsole(path, preferences, dialogService); - } catch (IOException e) { - LOGGER.info("Could not open console", e); - } - }); + Optional.ofNullable(databaseContext.get()) + .or(stateManager::getActiveDatabase) + .flatMap(context -> context.getDatabasePath().or(context::getDirectoryLibraryRoot)) + .ifPresent(path -> { + try { + NativeDesktop.openConsole(path, preferences, dialogService); + } catch (IOException e) { + LOGGER.info("Could not open console", e); + } + }); } } From c6c147f86e4af98daf7f44c74ccd1b3d26291129 Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Thu, 3 Sep 2026 04:36:13 +0200 Subject: [PATCH 09/15] Remove duplicated CHANGELOG entries left by the resync merge Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vr3E1Gg5DRU4LQDDVnhPys --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d8b199912c..0592a4a8cbc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,6 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv - Directory libraries now save into their sidecar files: edits are written back automatically (debounced until typing pauses; Ctrl+S forces the write and no longer creates a `.bib`), the first edit of a PDF-only entry creates a Markdown sidecar (`X.md` with the Hayagriva data as frontmatter and the comment fields as notes body), renaming a citation key renames the YAML key, and deleting an entry removes it from its file (the file is trashed once empty, the PDF stays). Hand-written content that JabRef does not understand survives rewrites. [#739](https://github.com/JabRef/jabref-koppor/pull/739) - 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): JabRef opens a directory as a library, filling the main table from the Hayagriva `.yml` files, the Markdown notes files with a Hayagriva YAML frontmatter (`.md`; the notes body maps to the entry's comment fields), and the PDFs found in the folder tree. A PDF next to a sidecar of the same name is linked to the sidecar's entry; PDFs without a sidecar appear immediately and are enriched in the background with metadata extracted from the PDF itself and a generated citation key; a missing DOI is looked up online and its metadata fills the remaining empty fields. Directory libraries that were open on shutdown are reopened on the next start. [#737](https://github.com/JabRef/jabref-koppor/pull/737) -- We added "Open folder as library" (File menu): JabRef opens a directory as a library, filling the main table from the Hayagriva `.yml` files and PDFs found in the folder tree. A PDF next to a `.yml` sidecar of the same name is linked to the sidecar's entry; PDFs without a sidecar appear as stub entries titled after the file. Edits are not yet written back to the files. [#737](https://github.com/JabRef/jabref-koppor/pull/737) -- We added "Open folder as library" (File menu): JabRef opens a directory as a library, filling the main table from the Hayagriva `.yml` files and PDFs found in the folder tree. A PDF next to a `.yml` sidecar of the same name is linked to the sidecar's entry; PDFs without a sidecar appear as entries with metadata extracted from the PDF itself (falling back to the file name). Edits are not yet written back to the files. [#737](https://github.com/JabRef/jabref-koppor/pull/737) -- We added "Open folder as library" (File menu): JabRef opens a directory as a library, filling the main table from the Hayagriva `.yml` files and PDFs found in the folder tree. A PDF next to a `.yml` sidecar of the same name is linked to the sidecar's entry; PDFs without a sidecar appear immediately and are enriched in the background with metadata extracted from the PDF itself and a generated citation key. Edits are not yet written back to the files. [#737](https://github.com/JabRef/jabref-koppor/pull/737) -- We added "Open folder as library" (File menu): JabRef opens a directory as a library, filling the main table from the Hayagriva `.yml` files and PDFs found in the folder tree. A PDF next to a `.yml` sidecar of the same name is linked to the sidecar's entry; PDFs without a sidecar appear immediately and are enriched in the background with metadata extracted from the PDF itself and a generated citation key; a missing DOI is looked up online and its metadata fills the remaining empty fields. Edits are not yet written back to the files. [#737](https://github.com/JabRef/jabref-koppor/pull/737) -- We added "Open folder as library" (File menu): JabRef opens a directory as a library, filling the main table from the Hayagriva `.yml` files and PDFs found in the folder tree. A PDF next to a `.yml` sidecar of the same name is linked to the sidecar's entry; PDFs without a sidecar appear immediately and are enriched in the background with metadata extracted from the PDF itself and a generated citation key; a missing DOI is looked up online and its metadata fills the remaining empty fields. Directory libraries that were open on shutdown are reopened on the next start. Edits are not yet written back to the files. [#737](https://github.com/JabRef/jabref-koppor/pull/737) -- We added "Open folder as library" (File menu): JabRef opens a directory as a library, filling the main table from the Hayagriva `.yml` files, the Markdown notes files with a Hayagriva YAML frontmatter (`.md`; the notes body maps to the entry's comment fields), and the PDFs found in the folder tree. A PDF next to a sidecar of the same name is linked to the sidecar's entry; PDFs without a sidecar appear immediately and are enriched in the background with metadata extracted from the PDF itself and a generated citation key; a missing DOI is looked up online and its metadata fills the remaining empty fields. Directory libraries that were open on shutdown are reopened on the next start. Edits are not yet written back to the files. [#737](https://github.com/JabRef/jabref-koppor/pull/737) - We added a "Commit and push" button which allows to commit and then push in one go for Git operations. [#16339](https://github.com/JabRef/jabref/issues/16339) - We added the option to close and reopen the PDF preview in the unlinked files dialog. [#16159](https://github.com/JabRef/jabref/issues/16159) - We added the ability for LibreOffice BST citations to use style-defined labels. [forum#3764]([https://github.com/JabRef/jabref/issues/16357](https://discourse.jabref.org/t/feature-request-custom-citation-styles-from-bst/3764)) From 63fdc445182c21f42b1362a3f7eb312cda939fac Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Thu, 3 Sep 2026 15:08:27 +0200 Subject: [PATCH 10/15] Auto-link skips only Markdown sidecars, not name partners Treating every Markdown file next to an equally named file as a notes companion changed automatic file linking for all libraries; the Hayagriva frontmatter is the unambiguous signal, so only sidecars are skipped now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vr3E1Gg5DRU4LQDDVnhPys --- CHANGELOG.md | 2 +- docs/requirements/files.md | 2 +- .../externalfiles/AutoSetFileLinksUtil.java | 19 +++++-------------- .../AutoSetFileLinksUtilTest.java | 10 ++++++---- 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd119190e4dd..1f38d093242d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,7 +74,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Changed -- Automatic file linking no longer links a Markdown file that shares its base name with another found or linked file (e.g. `X.md` next to `X.pdf`) or whose frontmatter is a Hayagriva document (a directory-library sidecar), treating such files as notes companions instead of attachments. [#741](https://github.com/JabRef/jabref-koppor/pull/741) +- Automatic file linking no longer links the Markdown sidecars of a directory library (Markdown files with a Hayagriva frontmatter); other Markdown files are still linked. [#741](https://github.com/JabRef/jabref-koppor/pull/741) - We changed the Hayagriva YAML export to keep data JabRef cannot edit (short titles, person aliases, additional identifiers) when re-exporting an imported file, to write comments, and to derive `date` from the BibTeX year/month fields. [#736](https://github.com/JabRef/jabref-koppor/pull/736) - We changed the default macOS shortcuts for "Search document identifier online" and "Focus group list" to not insert special characters. [#16528](https://github.com/JabRef/jabref/issues/16528) - We changed the extension of backup files from `.bak` to `.bib`, so that they can be opened in JabRef. [#11454](https://github.com/JabRef/jabref/issues/11454) diff --git a/docs/requirements/files.md b/docs/requirements/files.md index e2708225a1cc..084d3dbcbae5 100644 --- a/docs/requirements/files.md +++ b/docs/requirements/files.md @@ -29,7 +29,7 @@ After a file is linked to an entry, the user might move the file to another dire The function `Quality -> Automatically set file links` can help user to auto-link the moved files based on the broken file name, or the entry citation key. -A Markdown file sharing its base name with another associated or linked file (e.g. `X.md` next to `X.pdf`) is treated as a notes companion of that file and is never auto-linked. The same holds for a Markdown sidecar of a directory library (a Hayagriva frontmatter block) even without such a partner: it is an entry's source, not an attachment. Any other Markdown file is still linked. +A Markdown sidecar of a directory library (a Markdown file opening with a Hayagriva frontmatter block) is an entry's source, not an attachment, and is never auto-linked. Any other Markdown file is still linked. Needs: impl, utest diff --git a/jabgui/src/main/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtil.java b/jabgui/src/main/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtil.java index 139547c419b1..65a20b0ada21 100644 --- a/jabgui/src/main/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtil.java +++ b/jabgui/src/main/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtil.java @@ -242,28 +242,19 @@ public Collection<LinkedFile> findAssociatedNotLinkedFilesWithFinder( // Only keep associated files that are not linked return associatedFiles .stream() - .filter(associatedFile -> !isMarkdownCompanion(associatedFile, associatedFiles, linkedFiles)) + .filter(associatedFile -> !isMarkdownSidecar(associatedFile)) .filter(associatedFile -> !isFileAlreadyLinked(associatedFile, linkedFiles)) .map(this::buildLinkedFileFromPath) .toList(); } - /// A Markdown file sharing its base name with another associated or linked file (e.g. `X.md` - /// next to `X.pdf`) holds notes on that file rather than being a document of its own, so it - /// must not be auto-linked. The same holds for a Markdown sidecar (Hayagriva frontmatter, - /// see [MarkdownSidecar]) even without such a partner: it is an entry's source, not an - /// attachment. Any other Markdown file is still linked. - private boolean isMarkdownCompanion(Path file, List<Path> associatedFiles, List<Path> linkedFiles) { + /// A Markdown sidecar of a directory library (Hayagriva frontmatter, see [MarkdownSidecar]) + /// is an entry's source, not an attachment, so it must not be auto-linked. Any other + /// Markdown file is still linked. + private boolean isMarkdownSidecar(Path file) { if (!MarkdownSidecar.hasMarkdownExtension(file)) { return false; } - String baseName = FileUtil.getBaseName(file); - boolean hasPartner = Stream.concat(associatedFiles.stream(), linkedFiles.stream()) - .filter(other -> !other.equals(file)) - .anyMatch(other -> baseName.equalsIgnoreCase(FileUtil.getBaseName(other))); - if (hasPartner) { - return true; - } try { return markdownSidecar.looksLikeSidecar(file); } catch (IOException e) { diff --git a/jabgui/src/test/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtilTest.java b/jabgui/src/test/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtilTest.java index 29e916138c90..e2a42d548f63 100644 --- a/jabgui/src/test/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtilTest.java +++ b/jabgui/src/test/java/org/jabref/gui/externalfiles/AutoSetFileLinksUtilTest.java @@ -4,6 +4,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Set; import java.util.TreeSet; @@ -72,17 +73,18 @@ void findAssociatedNotLinkedFilesSuccess() throws IOException { /// [utest->req~logic.externalfiles.file-transfer.auto-link~2] @Test - void markdownCompanionOfAssociatedFileIsIgnored() throws IOException { + void plainMarkdownNextToPdfIsLinkedToo() throws IOException { Files.createFile(path.getParent().resolve("CiteKey.md")); when(databaseContext.getFileDirectories(any())).thenReturn(List.of(path.getParent())); AutoSetFileLinksUtil util = new AutoSetFileLinksUtil(databaseContext, externalApplicationsPreferences, filePreferences, autoLinkPrefs); Collection<LinkedFile> actual = util.findAssociatedNotLinkedFiles(entry); - assertEquals(List.of(new LinkedFile("", Path.of("CiteKey.pdf"), "PDF")), actual); + assertEquals(List.of(new LinkedFile("", Path.of("CiteKey.md"), "Markdown"), new LinkedFile("", Path.of("CiteKey.pdf"), "PDF")), + actual.stream().sorted(Comparator.comparing(LinkedFile::getLink)).toList()); } /// [utest->req~logic.externalfiles.file-transfer.auto-link~2] @Test - void markdownFileWithoutCompanionIsLinked(@TempDir Path tempDir) throws IOException { + void plainMarkdownFileIsLinked(@TempDir Path tempDir) throws IOException { Files.createFile(tempDir.resolve("CiteKey.md")); when(databaseContext.getFileDirectories(any())).thenReturn(List.of(tempDir)); AutoSetFileLinksUtil util = new AutoSetFileLinksUtil(databaseContext, externalApplicationsPreferences, filePreferences, autoLinkPrefs); @@ -92,7 +94,7 @@ void markdownFileWithoutCompanionIsLinked(@TempDir Path tempDir) throws IOExcept /// [utest->req~logic.externalfiles.file-transfer.auto-link~2] @Test - void markdownSidecarWithoutPartnerIsIgnored(@TempDir Path tempDir) throws IOException { + void markdownSidecarIsNotLinked(@TempDir Path tempDir) throws IOException { Files.writeString(tempDir.resolve("CiteKey.md"), """ --- CiteKey: From d0b8105ac9ce6203d4b560334dea43308cffd550 Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Mon, 7 Sep 2026 00:16:25 +0200 Subject: [PATCH 11/15] Apply IntelliJ formatter after upstream resync Fixes the CI format check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 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 b3e3b6024ab1..3c66d34a90b6 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -227,7 +227,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(); } /// Stops watching and writes what is still pending. Events already queued (the last 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 c16ba72b69397dc195cb0b4ed13476e4a2cb28d7 Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Mon, 7 Sep 2026 10:54:48 +0200 Subject: [PATCH 12/15] Merge directory-groups into directory-pattern-renames Update OpenConsoleActionTest to verify getPathOnDisk (the action now resolves the database path through that helper). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- .../java/org/jabref/gui/util/OpenConsoleActionTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jabgui/src/test/java/org/jabref/gui/util/OpenConsoleActionTest.java b/jabgui/src/test/java/org/jabref/gui/util/OpenConsoleActionTest.java index 28d360a2cc33..8cc426681e67 100644 --- a/jabgui/src/test/java/org/jabref/gui/util/OpenConsoleActionTest.java +++ b/jabgui/src/test/java/org/jabref/gui/util/OpenConsoleActionTest.java @@ -35,7 +35,7 @@ void newActionGetsCurrentDatabase() { OpenConsoleAction action = new OpenConsoleAction(stateManager, preferences, null); action.execute(); verify(stateManager, times(1)).getActiveDatabase(); - verify(current, times(1)).getDatabasePath(); + verify(current, times(1)).getPathOnDisk(); } @Test @@ -43,7 +43,7 @@ void newActionGetsSuppliedDatabase() { OpenConsoleAction action = new OpenConsoleAction(() -> other, stateManager, preferences, null); action.execute(); verify(stateManager, never()).getActiveDatabase(); - verify(other, times(1)).getDatabasePath(); + verify(other, times(1)).getPathOnDisk(); } @Test @@ -51,6 +51,6 @@ void actionDefaultsToCurrentDatabase() { OpenConsoleAction action = new OpenConsoleAction(() -> null, stateManager, preferences, null); action.execute(); verify(stateManager, times(1)).getActiveDatabase(); - verify(current, times(1)).getDatabasePath(); + verify(current, times(1)).getPathOnDisk(); } } From 3fb5ac73a6e70cd541518088c6a6f4144829beda Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Mon, 7 Sep 2026 11:14:14 +0200 Subject: [PATCH 13/15] 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 <noreply@anthropic.com> --- ....md => 0072-directory-as-library-with-hayagriva-sidecars.md} | 2 +- docs/requirements/directory-library.md | 2 +- 2 files changed, 2 insertions(+), 2 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 5229eadcaf1f..79ff20e89107 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 61bbf06ac01f..ed0289012085 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 From 982bb0d90a37d6cd94f3046e85d5522e01eb77aa Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Wed, 23 Sep 2026 15:10:42 +0200 Subject: [PATCH 14/15] Move directory-library CHANGELOG entries out of the released section Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd7c90897bd..bfd6edca1bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Changed +- Automatic file linking no longer links the Markdown sidecars of a directory library (Markdown files with a Hayagriva frontmatter); other Markdown files are still linked. [#741](https://github.com/JabRef/jabref-koppor/pull/741) - We changed the Hayagriva YAML export to keep data JabRef cannot edit (short titles, person aliases, additional identifiers) when re-exporting an imported file, to write comments, and to derive `date` from the BibTeX year/month fields. [#736](https://github.com/JabRef/jabref-koppor/pull/736) ### Fixed @@ -113,8 +114,6 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Changed -- Automatic file linking no longer links the Markdown sidecars of a directory library (Markdown files with a Hayagriva frontmatter); other Markdown files are still linked. [#741](https://github.com/JabRef/jabref-koppor/pull/741) -- We changed the Hayagriva YAML export to keep data JabRef cannot edit (short titles, person aliases, additional identifiers) when re-exporting an imported file, to write comments, and to derive `date` from the BibTeX year/month fields. [#736](https://github.com/JabRef/jabref-koppor/pull/736) - We improve startup performance by load citation style sources only when used. [#15962](https://github.com/JabRef/jabref/issues/15962) - We changed the default prompts for "AI-Chat" & "Chat with Groups" to better handle etiquette, metadata, citationkeys and context separation. [#16981](https://github.com/JabRef/jabref/pull/16981) - We changed the default local embedding model to `intfloat/multilingual-e5-small` for better passage retrieval. [#17120](https://github.com/JabRef/jabref/pull/17120) From 66e6fb76278d4b228f3d6401226e521f3c3213e5 Mon Sep 17 00:00:00 2001 From: Oliver Kopp <kopp.dev@gmail.com> Date: Wed, 23 Sep 2026 15:53:45 +0200 Subject: [PATCH 15/15] Use unnamed variables where the value is unused Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../directorylibrary/DirectoryLibrarySynchronizerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java index 6ecd8acb9812..e8a7815e4cda 100644 --- a/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java +++ b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java @@ -109,7 +109,7 @@ public Clock withZone(ZoneId zone) { private final List<Path> disposedFiles = new ArrayList<>(); /// Tests opt into pattern renames by replacing this; the default keeps file names as-is. - private Function<BibEntry, Optional<String>> fileNameGenerator = entry -> Optional.empty(); + private Function<BibEntry, Optional<String>> fileNameGenerator = _ -> Optional.empty(); private BibDatabaseContext context; private DirectoryLibrarySynchronizer synchronizer;