diff --git a/docs/requirements/directory-library.md b/docs/requirements/directory-library.md index cbb9116b27d2..66311dfd577d 100644 --- a/docs/requirements/directory-library.md +++ b/docs/requirements/directory-library.md @@ -94,7 +94,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 @@ -107,6 +107,25 @@ 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) together with its merge base, and the root is reopened as a directory library without +a merge. Sidecars pair with a PDF of the same base name only: further linked files, and a first +linked file that is no PDF, survive solely through the mirror. 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 3df2e42bd591..da3025e2f912 100644 --- a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -81,6 +81,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 bfba51f6b952..e2fa2ee21d48 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; @@ -166,14 +167,16 @@ private void createMenu() { Menu tools = new Menu(Localization.lang("Tools")); Menu help = new Menu(Localization.lang("Help")); + OpenDirectoryLibraryAction openDirectoryLibraryAction = new OpenDirectoryLibraryAction(frame, dialogService, preferences, aiService, stateManager, fileUpdateMonitor, entryTypesManager, gitHandlerRegistry, clipBoardManager, taskExecutor); file.getItems().addAll( factory.createMenuItem(StandardActions.NEW_LIBRARY, new NewDatabaseAction(frame, preferences)), factory.createMenuItem(StandardActions.OPEN_LIBRARY, openDatabaseActionSupplier.get()), - factory.createMenuItem(StandardActions.OPEN_FOLDER_AS_LIBRARY, new OpenDirectoryLibraryAction(frame, dialogService, preferences, aiService, stateManager, fileUpdateMonitor, entryTypesManager, gitHandlerRegistry, clipBoardManager, taskExecutor)), + factory.createMenuItem(StandardActions.OPEN_FOLDER_AS_LIBRARY, openDirectoryLibraryAction), fileHistoryMenu, factory.createMenuItem(StandardActions.SAVE_LIBRARY, new SaveAction(SaveAction.SaveMethod.SAVE, frame::getCurrentLibraryTab, dialogService, preferences, stateManager, entryTypesManager, journalAbbreviationRepository)), factory.createMenuItem(StandardActions.SAVE_LIBRARY_AS, new SaveAction(SaveAction.SaveMethod.SAVE_AS, frame::getCurrentLibraryTab, dialogService, preferences, stateManager, entryTypesManager, journalAbbreviationRepository)), factory.createMenuItem(StandardActions.SAVE_ALL, new SaveAllAction(frame::getLibraryTabs, preferences, dialogService, stateManager, entryTypesManager, journalAbbreviationRepository)), + factory.createMenuItem(StandardActions.CONVERT_TO_FOLDER_LIBRARY, new ConvertToDirectoryLibraryAction(frame, dialogService, preferences, stateManager, entryTypesManager, journalAbbreviationRepository, openDirectoryLibraryAction, 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..6126af938e26 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/importer/actions/ConvertToDirectoryLibraryAction.java @@ -0,0 +1,125 @@ +package org.jabref.gui.importer.actions; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +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.exporter.SaveDatabaseAction; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.directorylibrary.DirectoryLibraryConverter; +import org.jabref.logic.directorylibrary.DirectoryLibrarySynchronizer; +import org.jabref.logic.journals.JournalAbbreviationRepository; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.BackgroundTask; +import org.jabref.logic.util.TaskExecutor; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntryTypesManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static java.util.function.Predicate.not; + +/// Converts the current `.bib` library into a directory library (see +/// [DirectoryLibraryConverter]) and reopens its root as such. Only offered for saved local +/// libraries; aborts with an explanation when the library does not fit under one 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 JournalAbbreviationRepository journalAbbreviationRepository; + private final OpenDirectoryLibraryAction openDirectoryLibraryAction; + private final TaskExecutor taskExecutor; + private final DirectoryLibraryConverter converter = new DirectoryLibraryConverter(); + + public ConvertToDirectoryLibraryAction(LibraryTabContainer tabContainer, + DialogService dialogService, + GuiPreferences preferences, + StateManager stateManager, + BibEntryTypesManager entryTypesManager, + JournalAbbreviationRepository journalAbbreviationRepository, + OpenDirectoryLibraryAction openDirectoryLibraryAction, + TaskExecutor taskExecutor) { + this.tabContainer = tabContainer; + this.dialogService = dialogService; + this.preferences = preferences; + this.stateManager = stateManager; + this.entryTypesManager = entryTypesManager; + this.journalAbbreviationRepository = journalAbbreviationRepository; + this.openDirectoryLibraryAction = openDirectoryLibraryAction; + this.taskExecutor = taskExecutor; + + this.executable.bind(ActionHelper.needsSavedLocalDatabase(stateManager)); + } + + @Override + public void execute() { + LibraryTab libraryTab = tabContainer.getCurrentLibraryTab(); + BibDatabaseContext context = libraryTab.getBibDatabaseContext(); + DirectoryLibraryConverter.determineRoot(context, preferences.getFilePreferences()) + .ifPresent(root -> convert(libraryTab, context, root)); + } + + private void convert(LibraryTab libraryTab, BibDatabaseContext context, Path root) { + String title = Localization.lang("Convert to folder library"); + List obstacles = converter.obstacles(context, root, preferences.getFilePreferences()); + if (!obstacles.isEmpty()) { + dialogService.showErrorDialogAndWait(title, + Localization.lang("The library cannot be converted.") + "\n\n" + reportedObstacles(obstacles)); + return; + } + + Path mirrorTarget = root.resolve(DirectoryLibrarySynchronizer.mirrorFileName(root)); + boolean overwritesForeignFile = context.getDatabasePath().filter(not(mirrorTarget::equals)).isPresent() && Files.exists(mirrorTarget); + if (overwritesForeignFile) { + dialogService.showErrorDialogAndWait(title, + Localization.lang("'%0' already exists and would be overwritten.", mirrorTarget.toString())); + return; + } + + boolean confirmed = dialogService.showConfirmationDialogAndWait(title, + 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, journalAbbreviationRepository).save() != SaveDatabaseAction.SaveResult.SUCCESS) { + return; + } + + BackgroundTask.wrap(() -> converter.convert(context, root, preferences.getFilePreferences())) + .onSuccess(_ -> { + if (!tabContainer.closeTab(libraryTab)) { + LOGGER.warn("The converted library's tab stays open although its file moved to {}", mirrorTarget); + } + openDirectoryLibraryAction.openDirectory(root); + }) + .onFailure(exception -> { + LOGGER.error("Could not convert the library to a folder library at {}", root, exception); + dialogService.showErrorDialogAndWait(title, exception); + }) + .executeWith(taskExecutor); + } + + private static String reportedObstacles(List obstacles) { + String reported = obstacles.stream() + .limit(MAX_REPORTED_OBSTACLES) + .collect(Collectors.joining("\n")); + if (obstacles.size() > MAX_REPORTED_OBSTACLES) { + return reported + "\n" + Localization.lang("... and %0 more", Integer.toString(obstacles.size() - MAX_REPORTED_OBSTACLES)); + } + return reported; + } +} 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..b7252b5ee00b --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverter.java @@ -0,0 +1,139 @@ +package org.jabref.logic.directorylibrary; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +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.FileNameCleaner; +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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static java.util.function.Predicate.not; + +/// 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 static final Logger LOGGER = LoggerFactory.getLogger(DirectoryLibraryConverter.class); + + 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, FilePreferences filePreferences) { + return context.getAllFileDirectories(filePreferences) + .getLibraryDirectoryOpt() + .or(context::getDatabaseDirectory) + .map(Path::normalize); + } + + /// 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; + } + linkedFile.findIn(fileDirectories).ifPresentOrElse(resolved -> { + if (!resolved.toAbsolutePath().normalize().startsWith(normalizedRoot)) { + obstacles.add(Localization.lang("Linked file '%0' of entry '%1' is outside of '%2'.", linkedFile.getLink(), label, root.toString())); + } + }, () -> obstacles.add(Localization.lang("Linked file '%0' of entry '%1' was not found.", linkedFile.getLink(), label))); + } + } + return obstacles; + } + + /// Runs once the library is saved: sidecars first, then the `.bib` moves into the root as + /// the mirror, and a copy becomes the merge base — so reopening the root finds mirror and + /// base identical instead of merging the library against itself. + /// + /// @return the mirror file + public Path convert(BibDatabaseContext context, Path root, FilePreferences filePreferences) throws IOException { + Path bibFile = context.getDatabasePath().orElseThrow(); + Path mirror = root.resolve(DirectoryLibrarySynchronizer.mirrorFileName(root)); + writeSidecars(context, root, filePreferences); + if (!mirror.equals(bibFile)) { + Files.move(bibFile, mirror); + } + Path base = DirectoryLibrarySynchronizer.mirrorBaseFile(root); + Files.createDirectories(base.getParent()); + Files.copy(mirror, base, StandardCopyOption.REPLACE_EXISTING); + return mirror; + } + + /// 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. A failure removes + /// the sidecars written so far, so a retry does not produce duplicates. + public void writeSidecars(BibDatabaseContext context, Path root, FilePreferences filePreferences) throws IOException { + List fileDirectories = context.getFileDirectories(filePreferences); + List written = new ArrayList<>(); + try { + for (BibEntry entry : context.getDatabase().getEntries()) { + Path sidecar = sidecarFor(entry, root, fileDirectories); + String key = entry.getCitationKey().filter(not(String::isBlank)).orElse("entry"); + String document = markdownSidecar.merge("", List.of(new HayagrivaEntryWriter.KeyedEntry("", key, entry))); + Files.writeString(sidecar, document); + written.add(sidecar); + } + } catch (IOException e) { + written.forEach(DirectoryLibraryConverter::deleteQuietly); + throw e; + } + } + + private static void deleteQuietly(Path file) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + LOGGER.warn("Could not remove partially written sidecar {}", file, e); + } + } + + private static Path sidecarFor(BibEntry entry, Path root, List fileDirectories) { + Path baseNamePath = entry.getFiles().stream() + .filter(linkedFile -> !linkedFile.isOnlineLink()) + .findFirst() + .flatMap(linkedFile -> linkedFile.findIn(fileDirectories)) + .map(paired -> paired.resolveSibling(FileUtil.getBaseName(paired))) + .orElseGet(() -> root.resolve(entry.getCitationKey() + .map(FileNameCleaner::cleanFileName) + .filter(not(String::isBlank)) + .orElse("entry"))); + Path sidecar = baseNamePath.resolveSibling(baseNamePath.getFileName() + "." + MarkdownSidecar.MARKDOWN_EXTENSION); + for (int counter = 1; Files.exists(sidecar); counter++) { + sidecar = baseNamePath.resolveSibling(baseNamePath.getFileName() + "-" + 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 9c8442a7ee4c..e0a5ba9f0d49 100644 --- a/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java +++ b/jablib/src/main/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizer.java @@ -59,6 +59,10 @@ import org.jabref.model.entry.event.EntryChangedEvent; import org.jabref.model.entry.field.Field; import org.jabref.model.entry.field.StandardField; +import org.jabref.model.groups.DirectoryStructureGroup; +import org.jabref.model.groups.GroupTreeNode; +import org.jabref.model.groups.event.GroupUpdatedEvent; +import org.jabref.model.metadata.event.MetaDataChangedEvent; import com.google.common.eventbus.Subscribe; import org.apache.commons.io.IOCase; @@ -114,7 +118,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 { @@ -366,11 +370,45 @@ public void listen(EntriesRemovedEvent event) { syncExecutor.execute(() -> handleLocalRemoval(entries)); } + /// Groups (and other library settings) live only in the mirror's metadata block. + @Subscribe + public void listen(MetaDataChangedEvent event) { + markMirrorDirty(); + } + + /// Group tree edits (add, rename, remove) are posted as group events, not metadata events. + @Subscribe + public void listen(GroupUpdatedEvent event) { + 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) { scheduleWrite(catalog.sourceOf(entry) .map(DirectoryLibraryCatalog.EntrySource::yamlFile) @@ -430,6 +468,10 @@ public static String mirrorFileName(Path root) { /// The snapshot of the mirror as this application last wrote it — the base of the /// three-way merge when the mirror is changed externally. private Path mirrorBaseFile() { + return mirrorBaseFile(root); + } + + static Path mirrorBaseFile(Path root) { return root.resolve(".jabref").resolve("mirror-base.bib"); } @@ -457,6 +499,9 @@ synchronized void doInitializeMirror() { scheduleWrite(mirror); return; } + // The mirror's metadata is the only place user-defined groups of a directory library + // survive a restart — the sidecars carry entries, not library metadata + readBibContext(mirror).ifPresent(this::adoptUserGroups); try { if (Files.exists(mirrorBaseFile()) && Files.mismatch(mirror, mirrorBaseFile()) == -1L) { return; diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index 3df9ca3931d1..15f2a59de503 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -3720,6 +3720,17 @@ Specify\ a\ subcommand\ (reset,\ import,\ export).=Specify a subcommand (reset, Specify\ a\ subcommand\ (update,\ extract-references).=Specify a subcommand (update, extract-references). 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. Could\ not\ write\ the\ changes\ to\ the\ following\ files\:\ %0=Could not write the changes to the following files: %0 Close\ anyway=Close anyway Normalize\ keyword\ delimiters=Normalize keyword delimiters 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..ebd51dac9c1c --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibraryConverterTest.java @@ -0,0 +1,164 @@ +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 java.util.stream.Stream; + +import org.jabref.logic.FilePreferences; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.database.BibDatabase; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.BibtexString; +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.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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, filePreferences)); + } + + @Test + void determineRootFallsBackToBibDirectory() { + BibDatabaseContext context = new BibDatabaseContext(); + context.setDatabasePath(root.resolve("library.bib")); + + assertEquals(Optional.of(root), DirectoryLibraryConverter.determineRoot(context, filePreferences)); + } + + @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 + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "obstacle messages embed OS-specific path separators") + 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"))); + + assertEquals(List.of( + Localization.lang("Linked file '%0' of entry '%1' was not found.", "gone.pdf", "missing2020"), + Localization.lang("Linked file '%0' of entry '%1' is outside of '%2'.", elsewhere.resolve("outside.pdf").toString(), "outside2020", root.toString())), + converter.obstacles(contextWith(missing, outside), root, filePreferences)); + } + + @Test + void preambleAndStringsAreObstacles() { + BibDatabaseContext context = contextWith(); + context.getDatabase().setPreamble("preamble"); + context.getDatabase().addString(new BibtexString("acm", "Association for Computing Machinery")); + + assertEquals(List.of( + Localization.lang("The library contains a preamble, which a folder library cannot represent."), + Localization.lang("The library contains BibTeX strings, which a folder library cannot represent.")), + converter.obstacles(context, root, filePreferences)); + } + + @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); + + assertEquals(""" + --- + smith2020: + type: article + title: A Paired Article + --- + """, Files.readString(root.resolve("sub/paper.md"))); + List readBack = new MarkdownSidecar().read(root.resolve("doe2021.md")).getDatabase().getEntries(); + assertEquals(Optional.of("An Unpaired Article"), readBack.getFirst().getField(StandardField.TITLE)); + } + + @Test + void convertMovesBibIntoRootAsMirrorWithBase() throws IOException { + Files.createFile(root.resolve("paper.pdf")); + Path bibFile = root.resolve("library.bib"); + Files.writeString(bibFile, "@Article{smith2020, title = {A Paired Article}, file = {:paper.pdf:PDF}}\n"); + BibEntry entry = new BibEntry(StandardEntryType.Article) + .withCitationKey("smith2020") + .withField(StandardField.TITLE, "A Paired Article") + .withFiles(List.of(new LinkedFile("", "paper.pdf", "PDF"))); + + Path mirror = converter.convert(contextWith(entry), root, filePreferences); + + assertEquals(root.resolve(root.getFileName() + ".bib"), mirror); + try (Stream files = Files.list(root)) { + assertEquals(Stream.of(".jabref", "paper.md", "paper.pdf", root.getFileName() + ".bib").sorted().toList(), + files.map(file -> file.getFileName().toString()).sorted().toList()); + } + assertEquals(Files.readString(mirror), Files.readString(root.resolve(".jabref").resolve("mirror-base.bib"))); + } + + @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); + + try (Stream files = Files.list(root)) { + assertEquals(List.of("shared-1.md", "shared.md", "shared.pdf"), + files.map(file -> file.getFileName().toString()).sorted().toList()); + } + } +} 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 8c821f0750c6..b7734ad2cb40 100644 --- a/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java +++ b/jablib/src/test/java/org/jabref/logic/directorylibrary/DirectoryLibrarySynchronizerTest.java @@ -30,6 +30,7 @@ import org.jabref.logic.exporter.BibWriter; import org.jabref.logic.exporter.SelfContainedSaveConfiguration; import org.jabref.logic.git.conflicts.GitConflictResolverStrategy; +import org.jabref.logic.git.conflicts.ThreeWayEntryConflict; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.ParserResult; import org.jabref.logic.importer.fetcher.CrossRef; @@ -44,6 +45,11 @@ import org.jabref.model.entry.field.StandardField; import org.jabref.model.entry.field.UserSpecificCommentField; import org.jabref.model.entry.types.StandardEntryType; +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.groups.event.GroupUpdatedEvent; import org.jabref.model.metadata.SaveOrder; import org.junit.jupiter.api.AfterEach; @@ -860,7 +866,7 @@ void multiEntryFilesKeepTheirNameDespitePattern() throws IOException { assertEquals(List.of("collection.yml"), fileNames()); } - /// [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); @@ -874,7 +880,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, InterruptedException, ExecutionException { Path sidecar = root.resolve("smith2020.yml"); @@ -896,7 +902,7 @@ void externalMirrorEditUpdatesEntryAndSidecar() throws IOException, InterruptedE 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, InterruptedException, ExecutionException { Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); @@ -925,7 +931,7 @@ void externalMirrorAdditionCreatesEntryAndSidecar() throws IOException, Interrup 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, InterruptedException, ExecutionException { Path sidecar = root.resolve("smith2020.yml"); @@ -947,7 +953,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, InterruptedException, ExecutionException { Path sidecar = root.resolve("smith2020.yml"); @@ -970,7 +976,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, InterruptedException, ExecutionException { Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); @@ -990,4 +996,61 @@ 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, InterruptedException, ExecutionException { + Files.writeString(root.resolve("smith2020.yml"), ARTICLE_YAML); + openLibrary(); + synchronizer.doInitializeMirror(); + synchronizer.flush(); + context.getMetaData().getGroups().orElseThrow() + .addSubgroup(new ExplicitGroup("My group", GroupHierarchyType.INDEPENDENT, ',')); + // Delivered by the context's change filter in the application + synchronizer.listen(new GroupUpdatedEvent(context.getMetaData())); + synchronizer.awaitPendingEvents(); + synchronizer.shutdown(); + + openLibrary(); + synchronizer.doInitializeMirror(); + synchronizer.flush(); + + List children = context.getMetaData().getGroups().orElseThrow().getChildren(); + assertEquals(List.of(DirectoryStructureGroup.class, ExplicitGroup.class), + children.stream().map(child -> child.getGroup().getClass()).toList()); + assertEquals("My group", children.getLast().getName()); + } + + /// [utest->req~directory-library.convert~1] + @Test + void convertedLibraryReopensWithoutMerge() throws IOException, InterruptedException, ExecutionException { + Files.createFile(root.resolve("paper.pdf")); + String bib = """ + @Article{smith2020, + title = {A Test Article}, + file = {:paper.pdf:PDF}, + } + """; + Path bibFile = root.resolve("library.bib"); + Files.writeString(bibFile, bib); + BibDatabaseContext bibContext = parseBib(bib).orElseThrow(); + bibContext.setDatabasePath(bibFile); + bibContext.getMetaData().setLibrarySpecificFileDirectory(root.toString()); + FilePreferences filePreferences = mock(FilePreferences.class, Answers.RETURNS_DEEP_STUBS); + new DirectoryLibraryConverter().convert(bibContext, root, filePreferences); + List askedConflicts = new ArrayList<>(); + conflictResolver = conflicts -> { + askedConflicts.addAll(conflicts); + return List.of(); + }; + + openLibrary(); + synchronizer.doInitializeMirror(); + synchronizer.awaitPendingEvents(); + synchronizer.flush(); + + assertEquals(List.of(), askedConflicts); + assertEquals(List.of(Optional.of("smith2020")), entries().stream().map(BibEntry::getCitationKey).toList()); + assertEquals(Stream.of(".jabref", "paper.md", "paper.pdf", root.getFileName() + ".bib").sorted().toList(), fileNames()); + } }