From f2d9099068ebd020aa86515f418ac40876c5dcfc Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Mon, 20 Jul 2026 01:29:57 +0200 Subject: [PATCH 1/5] Convert .bib libraries into directory libraries File > "Convert to folder library" turns a saved local library into a directory library: the root is the library-specific file directory (else the .bib's directory), and the conversion only proceeds when every linked file resolves under that root and the library carries no BibTeX strings or preamble - otherwise the obstacles are reported and nothing changes. Every entry gets a single-entry Markdown sidecar next to its linked file, the .bib moves into the root as the library's mirror, and the root is reopened as a directory library. User-defined groups now survive directory-library restarts: they are written with every mirror update and restored from the mirror's metadata at open (the automatic directory-structure group is not duplicated), which also preserves groups across the conversion. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- docs/requirements/directory-library.md | 19 ++- .../jabref/gui/actions/StandardActions.java | 1 + .../java/org/jabref/gui/frame/MainMenu.java | 2 + .../ConvertToDirectoryLibraryAction.java | 127 +++++++++++++++++ .../DirectoryLibraryConverter.java | 107 +++++++++++++++ .../DirectoryLibrarySynchronizer.java | 36 ++++- .../main/resources/l10n/JabRef_en.properties | 11 ++ .../DirectoryLibraryConverterTest.java | 129 ++++++++++++++++++ .../DirectoryLibrarySynchronizerTest.java | 37 ++++- 9 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 jabgui/src/main/java/org/jabref/gui/importer/actions/ConvertToDirectoryLibraryAction.java create mode 100644 jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java create mode 100644 jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverterTest.java diff --git a/docs/requirements/directory-library.md b/docs/requirements/directory-library.md index a6afdb00a2fc..e79d5ae9326d 100644 --- a/docs/requirements/directory-library.md +++ b/docs/requirements/directory-library.md @@ -92,7 +92,7 @@ the renamed files. Needs: impl ## The library is mirrored into a single .bib file -`req~directory-library.bib-mirror~1` +`req~directory-library.bib-mirror~2` A directory library is continuously mirrored into `/.bib` (debounced with the sidecar write-back), so plain BibTeX consumers and collaborators can read and edit the library @@ -105,6 +105,23 @@ cancelled resolution keeps the library's state. A pre-existing `.bib` without a adopted against an empty base, which can only add entries or raise conflicts, never delete library content. Entries are matched across the mirror by citation key; entries without one are not matched. The mirror itself is recreated when deleted and never imported as a sidecar. +User-defined groups are carried by the mirror's metadata block: they are written with every +mirror update and restored into the library when it is opened (the automatic directory-structure +group is not duplicated). + +Needs: impl, utest + +## A .bib library converts into a directory library +`req~directory-library.convert~1` + +A saved local `.bib` library can be converted into a directory library. The root is the +library-specific file directory when configured, otherwise the `.bib` file's directory. The +conversion only proceeds when every linked local file resolves to a location under that root and +the library carries no BibTeX strings or preamble; otherwise the obstacles are reported and +nothing is changed. On conversion, every entry gets a single-entry Markdown sidecar next to its +first linked file (or named after its citation key in the root), the `.bib` file moves to the +root as `.bib` and becomes the library's mirror (preserving groups via the mirror +metadata), and the root is reopened as a directory library. Needs: impl, utest diff --git a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java index 28403c55fc3f..2011ecd5ff7d 100644 --- a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -79,6 +79,7 @@ public enum StandardActions implements Action { NEW_LIBRARY(Localization.lang("New empty library"), IconTheme.JabRefIcons.NEW), OPEN_LIBRARY(Localization.lang("Open library..."), IconTheme.JabRefIcons.OPEN, KeyBinding.OPEN_LIBRARY), OPEN_FOLDER_AS_LIBRARY(Localization.lang("Open folder as library..."), IconTheme.JabRefIcons.OPEN), + CONVERT_TO_FOLDER_LIBRARY(Localization.lang("Convert to folder library..."), IconTheme.JabRefIcons.FOLDER), MERGE_LIBRARY(Localization.lang("Merge..."), IconTheme.JabRefIcons.MERGE_ENTRIES), IMPORT(Localization.lang("Import"), IconTheme.JabRefIcons.IMPORT), EXPORT(Localization.lang("Export"), IconTheme.JabRefIcons.EXPORT, KeyBinding.EXPORT), diff --git a/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java b/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java index 0d70fddb6772..31ff21eb19f1 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java @@ -48,6 +48,7 @@ import org.jabref.gui.help.SearchForUpdateAction; import org.jabref.gui.importer.NewDatabaseAction; import org.jabref.gui.importer.NewEntryAction; +import org.jabref.gui.importer.actions.ConvertToDirectoryLibraryAction; import org.jabref.gui.importer.actions.ImportCommand; import org.jabref.gui.importer.actions.OpenDatabaseAction; import org.jabref.gui.importer.actions.OpenDirectoryLibraryAction; @@ -179,6 +180,7 @@ private void createMenu() { factory.createMenuItem(StandardActions.SAVE_LIBRARY, new SaveAction(SaveAction.SaveMethod.SAVE, frame::getCurrentLibraryTab, dialogService, preferences, stateManager)), factory.createMenuItem(StandardActions.SAVE_LIBRARY_AS, new SaveAction(SaveAction.SaveMethod.SAVE_AS, frame::getCurrentLibraryTab, dialogService, preferences, stateManager)), factory.createMenuItem(StandardActions.SAVE_ALL, new SaveAllAction(frame::getLibraryTabs, preferences, dialogService, stateManager)), + factory.createMenuItem(StandardActions.CONVERT_TO_FOLDER_LIBRARY, new ConvertToDirectoryLibraryAction(frame, dialogService, preferences, aiService, stateManager, fileUpdateMonitor, entryTypesManager, undoManager, clipBoardManager, taskExecutor)), factory.createMenuItem(StandardActions.CLOSE_LIBRARY, new JabRefFrame.CloseDatabaseAction(frame, stateManager)), new SeparatorMenuItem(), 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 new file mode 100644 index 000000000000..b48fc5d369a0 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/importer/actions/ConvertToDirectoryLibraryAction.java @@ -0,0 +1,127 @@ +package org.jabref.gui.importer.actions; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.swing.undo.UndoManager; + +import org.jabref.gui.DialogService; +import org.jabref.gui.LibraryTab; +import org.jabref.gui.LibraryTabContainer; +import org.jabref.gui.StateManager; +import org.jabref.gui.actions.ActionHelper; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.clipboard.ClipBoardManager; +import org.jabref.gui.exporter.SaveDatabaseAction; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.ai.AiService; +import org.jabref.logic.directorylibrary.DirectoryLibraryConverter; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.TaskExecutor; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntryTypesManager; +import org.jabref.model.util.FileUpdateMonitor; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/// Converts the current `.bib` library into a directory library (see +/// [DirectoryLibraryConverter]): sidecars are written next to the linked files, the `.bib` +/// moves into the root as the library's mirror, and the root is reopened as a directory +/// library. Only offered for saved local libraries; aborts with an explanation when linked +/// files do not all live under the designated root. +public class ConvertToDirectoryLibraryAction extends SimpleCommand { + + private static final Logger LOGGER = LoggerFactory.getLogger(ConvertToDirectoryLibraryAction.class); + private static final int MAX_REPORTED_OBSTACLES = 10; + + private final LibraryTabContainer tabContainer; + private final DialogService dialogService; + private final GuiPreferences preferences; + private final StateManager stateManager; + private final BibEntryTypesManager entryTypesManager; + private final OpenDirectoryLibraryAction openDirectoryLibraryAction; + + public ConvertToDirectoryLibraryAction(LibraryTabContainer tabContainer, + DialogService dialogService, + GuiPreferences preferences, + AiService aiService, + StateManager stateManager, + FileUpdateMonitor fileUpdateMonitor, + BibEntryTypesManager entryTypesManager, + UndoManager undoManager, + ClipBoardManager clipBoardManager, + TaskExecutor taskExecutor) { + this.tabContainer = tabContainer; + this.dialogService = dialogService; + this.preferences = preferences; + this.stateManager = stateManager; + this.entryTypesManager = entryTypesManager; + this.openDirectoryLibraryAction = new OpenDirectoryLibraryAction(tabContainer, dialogService, preferences, + aiService, stateManager, fileUpdateMonitor, entryTypesManager, undoManager, clipBoardManager, taskExecutor); + + this.executable.bind(ActionHelper.needsSavedLocalDatabase(stateManager)); + } + + @Override + public void execute() { + LibraryTab libraryTab = tabContainer.getCurrentLibraryTab(); + BibDatabaseContext context = libraryTab.getBibDatabaseContext(); + Optional bibPath = context.getDatabasePath(); + Optional root = DirectoryLibraryConverter.determineRoot(context); + if (bibPath.isEmpty() || root.isEmpty()) { + return; + } + + DirectoryLibraryConverter converter = new DirectoryLibraryConverter(); + List obstacles = converter.obstacles(context, root.get(), preferences.getFilePreferences()); + if (!obstacles.isEmpty()) { + String reported = obstacles.stream() + .limit(MAX_REPORTED_OBSTACLES) + .collect(Collectors.joining("\n")); + if (obstacles.size() > MAX_REPORTED_OBSTACLES) { + reported += "\n" + Localization.lang("... and %0 more", obstacles.size() - MAX_REPORTED_OBSTACLES); + } + dialogService.showErrorDialogAndWait( + Localization.lang("Convert to folder library"), + Localization.lang("The library cannot be converted:") + "\n\n" + reported); + return; + } + + Path rootName = root.get().getFileName(); + Path mirrorTarget = root.get().resolve((rootName == null ? "library" : rootName.toString()) + ".bib"); + if (!mirrorTarget.equals(bibPath.get()) && Files.exists(mirrorTarget)) { + dialogService.showErrorDialogAndWait( + Localization.lang("Convert to folder library"), + Localization.lang("'%0' already exists and would be overwritten.", mirrorTarget.toString())); + return; + } + + boolean confirmed = dialogService.showConfirmationDialogAndWait( + Localization.lang("Convert to folder library"), + Localization.lang("Every entry gets a Markdown sidecar next to its linked file, and the library file moves to '%0', staying in sync with the folder from now on.", mirrorTarget.toString())); + if (!confirmed) { + return; + } + + if (!new SaveDatabaseAction(libraryTab, dialogService, preferences, entryTypesManager, stateManager).save()) { + return; + } + try { + converter.writeSidecars(context, root.get(), preferences.getFilePreferences()); + if (!mirrorTarget.equals(bibPath.get())) { + Files.move(bibPath.get(), mirrorTarget); + } + } catch (IOException e) { + LOGGER.error("Could not convert {} to a folder library", bibPath.get(), e); + dialogService.showErrorDialogAndWait(Localization.lang("Convert to folder library"), e); + return; + } + tabContainer.closeTab(libraryTab); + openDirectoryLibraryAction.openDirectory(root.get()); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java new file mode 100644 index 000000000000..32c7f6184166 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java @@ -0,0 +1,107 @@ +package org.jabref.logic.directorylibrary; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.FilePreferences; +import org.jabref.logic.exporter.HayagrivaEntryWriter; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.LinkedFile; + +import org.jspecify.annotations.NullMarked; + +/// Converts a regular `.bib` library into a directory library: every entry gets a Markdown +/// sidecar (see [MarkdownSidecar]) next to its linked file, and the `.bib` itself becomes the +/// library's mirror. The conversion is only offered when the whole library fits under one +/// root — [#obstacles] lists everything that prevents it. +// [impl->req~directory-library.convert~1] +@NullMarked +public class DirectoryLibraryConverter { + + private final MarkdownSidecar markdownSidecar = new MarkdownSidecar(); + + /// The directory that becomes the library root: the library-specific file directory when + /// one is configured, otherwise the `.bib` file's directory. + public static Optional determineRoot(BibDatabaseContext context) { + Optional bibDirectory = context.getDatabasePath().map(Path::getParent); + return context.getMetaData().getLibrarySpecificFileDirectory() + .map(Path::of) + .map(directory -> directory.isAbsolute() || bibDirectory.isEmpty() + ? directory + : bibDirectory.get().resolve(directory)) + .map(Path::normalize) + .or(() -> bibDirectory); + } + + /// Everything that prevents the conversion: linked files that cannot be found or do not + /// live under the root, and library content sidecars cannot represent (BibTeX strings, + /// preamble). An empty result means the library converts losslessly file-wise. + public List obstacles(BibDatabaseContext context, Path root, FilePreferences filePreferences) { + List obstacles = new ArrayList<>(); + if (context.getDatabase().getPreamble().isPresent()) { + obstacles.add(Localization.lang("The library contains a preamble, which a folder library cannot represent.")); + } + if (!context.getDatabase().getStringValues().isEmpty()) { + obstacles.add(Localization.lang("The library contains BibTeX strings, which a folder library cannot represent.")); + } + List fileDirectories = context.getFileDirectories(filePreferences); + Path normalizedRoot = root.toAbsolutePath().normalize(); + for (BibEntry entry : context.getDatabase().getEntries()) { + String label = entry.getCitationKey().orElseGet(() -> entry.getAuthorTitleYear(40)); + for (LinkedFile linkedFile : entry.getFiles()) { + if (linkedFile.isOnlineLink()) { + continue; + } + Optional resolved = linkedFile.findIn(fileDirectories); + if (resolved.isEmpty()) { + obstacles.add(Localization.lang("Linked file '%0' of entry '%1' was not found.", linkedFile.getLink(), label)); + } else if (!resolved.get().toAbsolutePath().normalize().startsWith(normalizedRoot)) { + obstacles.add(Localization.lang("Linked file '%0' of entry '%1' is outside of '%2'.", linkedFile.getLink(), label, root.toString())); + } + } + } + return obstacles; + } + + /// Writes one single-entry Markdown sidecar per entry: next to the entry's first linked + /// file (sharing its base name, per the pairing convention), or named after the citation + /// key in the root. Occupied names are uniquified with a numeric suffix. + public void writeSidecars(BibDatabaseContext context, Path root, FilePreferences filePreferences) throws IOException { + List fileDirectories = context.getFileDirectories(filePreferences); + for (BibEntry entry : context.getDatabase().getEntries()) { + Path sidecar = sidecarFor(entry, root, fileDirectories); + String key = entry.getCitationKey().filter(citationKey -> !citationKey.isBlank()).orElse("entry"); + String document = markdownSidecar.merge(null, List.of(new HayagrivaEntryWriter.KeyedEntry("", key, entry))); + Files.writeString(sidecar, document); + } + } + + private static Path sidecarFor(BibEntry entry, Path root, List fileDirectories) { + Optional pairedFile = entry.getFiles().stream() + .filter(linkedFile -> !linkedFile.isOnlineLink()) + .findFirst() + .flatMap(linkedFile -> linkedFile.findIn(fileDirectories)); + Path directory; + String baseName; + if (pairedFile.isPresent()) { + directory = pairedFile.get().getParent(); + baseName = FileUtil.getBaseName(pairedFile.get()); + } else { + directory = root; + baseName = entry.getCitationKey().filter(key -> !key.isBlank()).orElse("entry"); + } + Path sidecar = directory.resolve(baseName + "." + MarkdownSidecar.MARKDOWN_EXTENSION); + int counter = 1; + while (Files.exists(sidecar)) { + sidecar = directory.resolve(baseName + "-" + counter++ + "." + MarkdownSidecar.MARKDOWN_EXTENSION); + } + return sidecar; + } +} 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 b4bd7f0798db..5dfc1a1c9a20 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -52,6 +52,9 @@ import org.jabref.model.entry.event.EntriesEventSource; import org.jabref.model.entry.event.EntryChangedEvent; import org.jabref.model.entry.field.StandardField; +import org.jabref.model.groups.DirectoryStructureGroup; +import org.jabref.model.groups.GroupTreeNode; +import org.jabref.model.metadata.event.MetaDataChangedEvent; import com.google.common.eventbus.Subscribe; import org.apache.commons.io.IOCase; @@ -105,7 +108,7 @@ /// [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~1] +// [impl->req~directory-library.bib-mirror~2] @NullMarked public class DirectoryLibrarySynchronizer implements FileAlterationListener { @@ -307,11 +310,39 @@ public void listen(EntriesRemovedEvent event) { syncExecutor.execute(() -> handleLocalRemoval(entries)); } + @Subscribe + public void listen(MetaDataChangedEvent event) { + // Groups (and other library settings) live only in the mirror's metadata block + markMirrorDirty(); + } + private static boolean isUserChange(EntriesEvent event) { return event.getEntriesEventSource() == EntriesEventSource.LOCAL || 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) { Path file = catalog.sourceOf(entry) .map(DirectoryLibraryCatalog.EntrySource::yamlFile) @@ -420,6 +451,9 @@ synchronized void doInitializeMirror() { writeDirtyFiles(); 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()) && hash(Files.readAllBytes(mirror)).equals(hash(Files.readAllBytes(mirrorBaseFile())))) { diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index 7b2911fdabf2..230a50c0af19 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -3587,3 +3587,14 @@ Specify\ a\ subcommand\ (reset,\ import,\ export).=Specify a subcommand (reset, Specify\ a\ subcommand\ (update).=Specify a subcommand (update). The\ format\ option\ must\ contain\ either\ 'xmp'\ or\ 'bibtex-attachment'.=The format option must contain either 'xmp' or 'bibtex-attachment'. Importer\ for\ the\ Hayagriva\ YAML\ format.=Importer for the Hayagriva YAML format. + +'%0'\ already\ exists\ and\ would\ be\ overwritten.='%0' already exists and would be overwritten. +...\ and\ %0\ more=... and %0 more +Convert\ to\ folder\ library...=Convert to folder library... +Convert\ to\ folder\ library=Convert to folder library +Every\ entry\ gets\ a\ Markdown\ sidecar\ next\ to\ its\ linked\ file,\ and\ the\ library\ file\ moves\ to\ '%0',\ staying\ in\ sync\ with\ the\ folder\ from\ now\ on.=Every entry gets a Markdown sidecar next to its linked file, and the library file moves to '%0', staying in sync with the folder from now on. +Linked\ file\ '%0'\ of\ entry\ '%1'\ is\ outside\ of\ '%2'.=Linked file '%0' of entry '%1' is outside of '%2'. +Linked\ file\ '%0'\ of\ entry\ '%1'\ was\ not\ found.=Linked file '%0' of entry '%1' was not found. +The\ library\ cannot\ be\ converted\:=The library cannot be converted: +The\ library\ contains\ a\ preamble,\ which\ a\ folder\ library\ cannot\ represent.=The library contains a preamble, which a folder library cannot represent. +The\ library\ contains\ BibTeX\ strings,\ which\ a\ folder\ library\ cannot\ represent.=The library contains BibTeX strings, which a folder library cannot represent. diff --git a/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverterTest.java b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverterTest.java new file mode 100644 index 000000000000..fe87b7f2b160 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverterTest.java @@ -0,0 +1,129 @@ +package org.jabref.logic.directorylibrary; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.FilePreferences; +import org.jabref.model.database.BibDatabase; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.LinkedFile; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.entry.types.StandardEntryType; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/// [utest->req~directory-library.convert~1] +class DirectoryLibraryConverterTest { + + @TempDir + Path root; + + private final FilePreferences filePreferences = mock(FilePreferences.class, Answers.RETURNS_DEEP_STUBS); + private final DirectoryLibraryConverter converter = new DirectoryLibraryConverter(); + + private BibDatabaseContext contextWith(BibEntry... entries) { + BibDatabaseContext context = new BibDatabaseContext(new BibDatabase(List.of(entries))); + context.setDatabasePath(root.resolve("library.bib")); + context.getMetaData().setLibrarySpecificFileDirectory(root.toString()); + return context; + } + + @Test + void determineRootPrefersLibrarySpecificFileDirectory(@TempDir Path elsewhere) { + BibDatabaseContext context = new BibDatabaseContext(); + context.setDatabasePath(elsewhere.resolve("library.bib")); + context.getMetaData().setLibrarySpecificFileDirectory(root.toString()); + + assertEquals(Optional.of(root), DirectoryLibraryConverter.determineRoot(context)); + } + + @Test + void determineRootFallsBackToBibDirectory() { + BibDatabaseContext context = new BibDatabaseContext(); + context.setDatabasePath(root.resolve("library.bib")); + + assertEquals(Optional.of(root), DirectoryLibraryConverter.determineRoot(context)); + } + + @Test + void noObstaclesWhenAllFilesLiveUnderRoot() throws IOException { + Files.createDirectories(root.resolve("sub")); + Files.createFile(root.resolve("sub/paper.pdf")); + BibEntry entry = new BibEntry(StandardEntryType.Article) + .withCitationKey("smith2020") + .withFiles(List.of(new LinkedFile("", "sub/paper.pdf", "PDF"))); + + assertEquals(List.of(), converter.obstacles(contextWith(entry), root, filePreferences)); + } + + @Test + void missingAndOutsideFilesAreObstacles(@TempDir Path elsewhere) throws IOException { + Files.createFile(elsewhere.resolve("outside.pdf")); + BibEntry missing = new BibEntry(StandardEntryType.Article) + .withCitationKey("missing2020") + .withFiles(List.of(new LinkedFile("", "gone.pdf", "PDF"))); + BibEntry outside = new BibEntry(StandardEntryType.Article) + .withCitationKey("outside2020") + .withFiles(List.of(new LinkedFile("", elsewhere.resolve("outside.pdf").toString(), "PDF"))); + + List obstacles = converter.obstacles(contextWith(missing, outside), root, filePreferences); + + assertEquals(2, obstacles.size()); + assertTrue(obstacles.getFirst().contains("gone.pdf")); + assertTrue(obstacles.getLast().contains("outside.pdf")); + } + + @Test + void preambleAndStringsAreObstacles() { + BibDatabaseContext context = contextWith(); + context.getDatabase().setPreamble("preamble"); + + assertEquals(1, converter.obstacles(context, root, filePreferences).size()); + } + + @Test + void sidecarsAreWrittenNextToLinkedFilesAndReadBack() throws IOException { + Files.createDirectories(root.resolve("sub")); + Files.createFile(root.resolve("sub/paper.pdf")); + BibEntry paired = new BibEntry(StandardEntryType.Article) + .withCitationKey("smith2020") + .withField(StandardField.TITLE, "A Paired Article") + .withFiles(List.of(new LinkedFile("", "sub/paper.pdf", "PDF"))); + BibEntry unpaired = new BibEntry(StandardEntryType.Article) + .withCitationKey("doe2021") + .withField(StandardField.TITLE, "An Unpaired Article"); + + converter.writeSidecars(contextWith(paired, unpaired), root, filePreferences); + + Path pairedSidecar = root.resolve("sub/paper.md"); + assertTrue(Files.readString(pairedSidecar).contains("A Paired Article")); + List readBack = new MarkdownSidecar().read(root.resolve("doe2021.md")).getDatabase().getEntries(); + assertEquals(Optional.of("An Unpaired Article"), readBack.getFirst().getField(StandardField.TITLE)); + } + + @Test + void entriesSharingAFileGetUniquifiedSidecarNames() throws IOException { + Files.createFile(root.resolve("shared.pdf")); + BibEntry first = new BibEntry(StandardEntryType.Article) + .withCitationKey("first2020") + .withFiles(List.of(new LinkedFile("", "shared.pdf", "PDF"))); + BibEntry second = new BibEntry(StandardEntryType.Article) + .withCitationKey("second2020") + .withFiles(List.of(new LinkedFile("", "shared.pdf", "PDF"))); + + converter.writeSidecars(contextWith(first, second), root, filePreferences); + + assertTrue(Files.exists(root.resolve("shared.md"))); + assertTrue(Files.exists(root.resolve("shared-1.md"))); + } +} 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 b0344ea2298e..da57fc415934 100644 --- a/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java +++ b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java @@ -39,6 +39,10 @@ import org.jabref.model.entry.event.FieldChangedEvent; import org.jabref.model.entry.field.StandardField; import org.jabref.model.entry.field.UserSpecificCommentField; +import org.jabref.model.groups.DirectoryStructureGroup; +import org.jabref.model.groups.ExplicitGroup; +import org.jabref.model.groups.GroupHierarchyType; +import org.jabref.model.groups.GroupTreeNode; import org.jabref.model.metadata.SaveOrder; import org.junit.jupiter.api.AfterEach; @@ -636,7 +640,7 @@ void multiEntryFilesKeepTheirNameDespitePattern() throws IOException { assertFalse(Files.exists(root.resolve("wrong.yml"))); } - /// [utest->req~directory-library.bib-mirror~1] + /// [utest->req~directory-library.bib-mirror~2] @Test void initializeMirrorCreatesBibMirrorWithBase() throws IOException { Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); @@ -649,7 +653,7 @@ void initializeMirrorCreatesBibMirrorWithBase() throws IOException { assertEquals(Files.readString(mirror), Files.readString(root.resolve(".jabref").resolve("mirror-base.bib"))); } - /// [utest->req~directory-library.bib-mirror~1] + /// [utest->req~directory-library.bib-mirror~2] @Test void externalMirrorEditUpdatesEntryAndSidecar() throws IOException { Path sidecar = root.resolve("smith2020.yml"); @@ -669,7 +673,7 @@ void externalMirrorEditUpdatesEntryAndSidecar() throws IOException { assertTrue(Files.readString(mirror).contains("An Edited Title")); } - /// [utest->req~directory-library.bib-mirror~1] + /// [utest->req~directory-library.bib-mirror~2] @Test void externalMirrorAdditionCreatesEntryAndSidecar() throws IOException { Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); @@ -696,7 +700,7 @@ void externalMirrorAdditionCreatesEntryAndSidecar() throws IOException { assertTrue(Files.readString(root.resolve("doe2021.md")).contains("A Second Article")); } - /// [utest->req~directory-library.bib-mirror~1] + /// [utest->req~directory-library.bib-mirror~2] @Test void externalMirrorDeletionRemovesEntryAndDisposesSidecar() throws IOException { Path sidecar = root.resolve("smith2020.yml"); @@ -716,7 +720,7 @@ void externalMirrorDeletionRemovesEntryAndDisposesSidecar() throws IOException { assertEquals(List.of(sidecar), disposedFiles); } - /// [utest->req~directory-library.bib-mirror~1] + /// [utest->req~directory-library.bib-mirror~2] @Test void conflictingMirrorEditKeepsLibraryStateWhenResolutionIsCancelled() throws IOException { Path sidecar = root.resolve("smith2020.yml"); @@ -737,7 +741,7 @@ void conflictingMirrorEditKeepsLibraryStateWhenResolutionIsCancelled() throws IO /// A pre-existing `.bib` named like the directory, without a recorded base, is adopted by /// importing against an empty base — its entries appear, nothing is deleted. - /// [utest->req~directory-library.bib-mirror~1] + /// [utest->req~directory-library.bib-mirror~2] @Test void preExistingBibIsAdoptedWithoutDeletingLibraryContent() throws IOException { Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); @@ -756,4 +760,25 @@ void preExistingBibIsAdoptedWithoutDeletingLibraryContent() throws IOException { assertTrue(Files.readString(mirror).contains("smith2020")); assertTrue(Files.readString(mirror).contains("doe2021")); } + + /// [utest->req~directory-library.bib-mirror~2] + @Test + void userGroupsFromMirrorMetadataAreRestoredAtOpen() throws IOException { + Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); + openLibrary(); + synchronizer.doInitializeMirror(); + context.getMetaData().getGroups().orElseThrow() + .addSubgroup(new ExplicitGroup("My group", GroupHierarchyType.INDEPENDENT, ',')); + // The mirror is derived state: reporting it deleted forces a rewrite, now with the group + synchronizer.handleFileDeleted(synchronizer.getMirrorFile()); + synchronizer.flush(); + synchronizer.shutdown(); + + openLibrary(); + synchronizer.doInitializeMirror(); + + List children = context.getMetaData().getGroups().orElseThrow().getChildren(); + assertTrue(children.stream().anyMatch(child -> "My group".equals(child.getName()))); + assertEquals(1, children.stream().filter(child -> child.getGroup() instanceof DirectoryStructureGroup).count()); + } } From c98ba1d4abcc5ddf9a3e69787909afdcc6471ed3 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Thu, 3 Sep 2026 03:28:41 +0200 Subject: [PATCH 2/5] Merge directory-bib-mirror into directory-convert Resync layer 8. Semantic fixes to ConvertToDirectoryLibraryAction from upstream: retyped its undo manager to GuiUndoManager, and adapted to SaveDatabaseAction.save() now returning SaveResult instead of boolean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DDcHNMt9fPWnpYaHheFvry --- .../importer/actions/ConvertToDirectoryLibraryAction.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 7009b0afbb97..f6a45cec2dc4 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 @@ -7,8 +7,6 @@ import java.util.Optional; import java.util.stream.Collectors; -import javax.swing.undo.UndoManager; - import org.jabref.gui.DialogService; import org.jabref.gui.LibraryTab; import org.jabref.gui.LibraryTabContainer; @@ -18,6 +16,7 @@ import org.jabref.gui.clipboard.ClipBoardManager; import org.jabref.gui.exporter.SaveDatabaseAction; import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.undo.GuiUndoManager; import org.jabref.logic.ai.AiService; import org.jabref.logic.directorylibrary.DirectoryLibraryConverter; import org.jabref.logic.journals.JournalAbbreviationRepository; @@ -56,7 +55,7 @@ public ConvertToDirectoryLibraryAction(LibraryTabContainer tabContainer, FileUpdateMonitor fileUpdateMonitor, BibEntryTypesManager entryTypesManager, JournalAbbreviationRepository journalAbbreviationRepository, - UndoManager undoManager, + GuiUndoManager undoManager, ClipBoardManager clipBoardManager, TaskExecutor taskExecutor) { this.tabContainer = tabContainer; @@ -112,7 +111,7 @@ public void execute() { return; } - if (!new SaveDatabaseAction(libraryTab, dialogService, preferences, entryTypesManager, stateManager, journalAbbreviationRepository).save()) { + if (new SaveDatabaseAction(libraryTab, dialogService, preferences, entryTypesManager, stateManager, journalAbbreviationRepository).save() != SaveDatabaseAction.SaveResult.SUCCESS) { return; } try { From 65de9374aaac7e948017b3c75bccfc0e76bf7787 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Thu, 3 Sep 2026 04:44:53 +0200 Subject: [PATCH 3/5] Remove duplicated CHANGELOG entries left by the resync merge Co-Authored-By: Claude Fable 5.1 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 d10ced216d153effb361df695f70a2717fa2c95e Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Mon, 7 Sep 2026 00:16:48 +0200 Subject: [PATCH 4/5] 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 4fc4d5ed313c..48b947d061a8 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -259,7 +259,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 ecf8394f0992b9aa34dc6342679e21f87c1cf42e Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Mon, 7 Sep 2026 11:14:29 +0200 Subject: [PATCH 5/5] 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 +- 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 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 29c3d64abca5..b2bb4a1314f2 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