diff --git a/docs/requirements/directory-library.md b/docs/requirements/directory-library.md index 66311dfd577d..92a86318b104 100644 --- a/docs/requirements/directory-library.md +++ b/docs/requirements/directory-library.md @@ -129,4 +129,17 @@ linked file that is no PDF, survive solely through the mirror. Needs: impl, utest +## Directory libraries are reachable through the REST API +`req~directory-library.rest-api~1` + +An open directory library must be addressable through JabRef's HTTP API (jabsrv) exactly like a +`.bib` library. Because a directory library has no `.bib` path, it is identified by its root +directory (the same identity the session store uses): the library listing includes it, and the +per-library endpoints — reading entries, the batch existence-check query, and appending entries +— resolve it by that root-derived id. In GUI mode an append targets the open directory-library +tab (matched by its root) so the added entries flow through the normal write-back into sidecars +and the mirror; standalone-server mode still serves only `.bib` files. + +Needs: impl, utest + diff --git a/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrameViewModel.java b/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrameViewModel.java index d8473936eb2b..bd873ded6871 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrameViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrameViewModel.java @@ -284,13 +284,16 @@ private void importFromFileAndOpen(String location) { /// unchanged. If the library is not open yet, it is opened in a new (raised) tab; the actual /// loading happens in the background, so callers must run the append via /// [#waitForLoadingFinished(Runnable)] to let the new tab finish loading first. A path - /// that does not exist (or is not a .bib file) is silently ignored by + /// that does not exist (or is neither a .bib file nor a directory) is silently ignored by /// [OpenDatabaseAction#openFile(Path)] and the current tab is kept (the server side /// already rejects unknown ids with 404, so this is only a defensive fallback). private void selectLibraryTab(Optional library) { library.map(path -> path.toAbsolutePath().normalize()).ifPresent(normalized -> tabContainer.getLibraryTabs().stream() - .filter(tab -> tab.getBibDatabaseContext().getDatabasePath() + // A directory library is identified by its root directory, the same + // path the server derives its id from + // [impl->req~directory-library.rest-api~1] + .filter(tab -> tab.getBibDatabaseContext().getPathOnDisk() .map(path -> path.toAbsolutePath().normalize().equals(normalized)) .orElse(false)) .findFirst() diff --git a/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java b/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java index 770740cd991f..bf0626037f79 100644 --- a/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java +++ b/jablib/src/main/java/org/jabref/model/database/BibDatabaseContext.java @@ -134,9 +134,10 @@ public void clearDatabasePath() { /// The id used to address this library from the outside: the REST API (`/libraries/{id}/...`), /// cite-as-you-write (`libraryid=`), JabMap and in-app links (`jabref://libraries/{id}/entries/{key}`). + /// A directory library is identified by its root directory. /// Empty for libraries that have not been saved to disk yet. public Optional getLibraryId() { - return getDatabasePath().map(path -> path.getFileName() + "-" + BackupFileUtil.getUniqueFilePrefix(path)); + return getPathOnDisk().map(path -> path.getFileName() + "-" + BackupFileUtil.getUniqueFilePrefix(path)); } public BibDatabase getDatabase() { diff --git a/jabsrv/src/main/java/org/jabref/http/server/command/SelectEntriesCommand.java b/jabsrv/src/main/java/org/jabref/http/server/command/SelectEntriesCommand.java index 4db39593b773..33efdcdbc3d7 100644 --- a/jabsrv/src/main/java/org/jabref/http/server/command/SelectEntriesCommand.java +++ b/jabsrv/src/main/java/org/jabref/http/server/command/SelectEntriesCommand.java @@ -7,6 +7,7 @@ import org.jabref.http.JabRefSrvStateManager; import org.jabref.http.SrvStateManager; +import org.jabref.http.server.services.ServerUtils; import org.jabref.logic.UiCommand; import org.jabref.logic.command.CommandSelectionTab; import org.jabref.model.entry.BibEntry; @@ -59,7 +60,7 @@ public Response execute() { CommandSelectionTab commandSelectionTab = activeTab.get(); - if (!commandSelectionTab.getBibDatabaseContext().getLibraryId().orElse("").equals(libraryId)) { + if (ServerUtils.libraryId(commandSelectionTab.getBibDatabaseContext()).filter(libraryId::equals).isEmpty()) { return Response.status(Response.Status.BAD_REQUEST) .entity("This command cannot be executed because the libraryId does not match the active selection tab.") .build(); diff --git a/jabsrv/src/main/java/org/jabref/http/server/resources/LibraryResource.java b/jabsrv/src/main/java/org/jabref/http/server/resources/LibraryResource.java index 0d2784ec4340..20f1ce6d9696 100644 --- a/jabsrv/src/main/java/org/jabref/http/server/resources/LibraryResource.java +++ b/jabsrv/src/main/java/org/jabref/http/server/resources/LibraryResource.java @@ -87,7 +87,7 @@ public Response getBibtex(@PathParam("id") String id) { .build(); } - java.nio.file.Path library = ServerUtils.getLibraryPath(id, srvStateManager); + java.nio.file.Path library = ServerUtils.getLibraryFile(id, srvStateManager); String libraryAsString; try { libraryAsString = Files.readString(library); diff --git a/jabsrv/src/main/java/org/jabref/http/server/resources/MapResource.java b/jabsrv/src/main/java/org/jabref/http/server/resources/MapResource.java index b4afdea422b1..2bec79f192e5 100644 --- a/jabsrv/src/main/java/org/jabref/http/server/resources/MapResource.java +++ b/jabsrv/src/main/java/org/jabref/http/server/resources/MapResource.java @@ -86,7 +86,7 @@ public void updateJabMapJson(@PathParam("id") String id, String fileContent) thr } private java.nio.file.Path getJabMapPath(String id) { - java.nio.file.Path libraryPath = ServerUtils.getLibraryPath(id, srvStateManager); + java.nio.file.Path libraryPath = ServerUtils.getLibraryFile(id, srvStateManager); String newName = libraryPath.getFileName().toString().replaceFirst("\\.bib$", ".jmp"); return libraryPath.getParent().resolve(newName); } diff --git a/jabsrv/src/main/java/org/jabref/http/server/services/ServerUtils.java b/jabsrv/src/main/java/org/jabref/http/server/services/ServerUtils.java index b6614328a828..8a330b343ed4 100644 --- a/jabsrv/src/main/java/org/jabref/http/server/services/ServerUtils.java +++ b/jabsrv/src/main/java/org/jabref/http/server/services/ServerUtils.java @@ -5,6 +5,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -12,12 +13,14 @@ import org.jabref.http.SrvStateManager; import org.jabref.logic.ai.chatting.ChatModel; import org.jabref.logic.ai.chatting.util.ChatModelFactory; +import org.jabref.logic.directorylibrary.DirectoryLibrarySynchronizer; import org.jabref.logic.importer.FetcherException; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.fileformat.BibtexImporter; import org.jabref.logic.importer.plaincitation.PlainCitationParserChoice; import org.jabref.logic.importer.plaincitation.PlainCitationParserFactory; import org.jabref.logic.preferences.CliPreferences; +import org.jabref.logic.util.io.BackupFileUtil; import org.jabref.model.database.BibDatabase; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -29,26 +32,50 @@ public class ServerUtils { + /// The stable id used in URLs: derived from the `.bib` file of a regular library, or from + /// the root directory of a directory library (which has no `.bib` path of its own) — the + /// same identity the GUI session store uses, so a directory library keeps one id across + /// everything. Empty for unsaved libraries. + /// [impl->req~directory-library.rest-api~1] + public static Optional libraryId(BibDatabaseContext context) { + return context.getLibraryId(); + } + + private static String libraryId(Path path) { + return path.getFileName() + "-" + BackupFileUtil.getUniqueFilePrefix(path); + } + + /// The file holding the library's BibTeX for endpoints that read or derive files: the + /// `.bib` itself, or, for a directory library, its `.bib` mirror inside the root. + /// + /// @throws NotFoundException if no library with the given id is found + public static @NonNull Path getLibraryFile(String id, SrvStateManager srvStateManager) { + Path path = getLibraryPath(id, srvStateManager); + return Files.isDirectory(path) ? path.resolve(DirectoryLibrarySynchronizer.mirrorFileName(path)) : path; + } + /// Returns ids of all libraries the state manager currently considers /// open. Used by every resource that operates across the open /// collection (libraries listing, batch query, ...). public static List openLibraryIds(SrvStateManager srvStateManager) { return srvStateManager.getOpenDatabases().stream() - .map(BibDatabaseContext::getLibraryId) + .map(ServerUtils::libraryId) .flatMap(Optional::stream) .toList(); } /// Returns the on-disk path of the library with the given id, looking it up in the - /// state manager's open databases (the same source as [#getBibDatabaseContext]). + /// state manager's open databases (the same source as [#getBibDatabaseContext]). For a + /// directory library this is its root directory, which the GUI append command routes back + /// to the open directory-library tab. /// /// @throws NotFoundException if no library with the given id is found public static @NonNull Path getLibraryPath(String id, SrvStateManager srvStateManager) { return srvStateManager.getOpenDatabases() .stream() - .filter(context -> context.getLibraryId().filter(id::equals).isPresent()) - .map(BibDatabaseContext::getDatabasePath) + .map(BibDatabaseContext::getPathOnDisk) .flatMap(Optional::stream) + .filter(p -> libraryId(p).equals(id)) .findAny() .orElseThrow(NotFoundException::new); } @@ -75,7 +102,7 @@ public static List openLibraryIds(SrvStateManager srvStateManager) { return srvStateManager.getActiveDatabase().orElseThrow(NotFoundException::new); } return srvStateManager.getOpenDatabases().stream() - .filter(context -> context.getLibraryId().filter(id::equals).isPresent()) + .filter(context -> libraryId(context).filter(id::equals).isPresent()) .findFirst() .orElseThrow(() -> new NotFoundException("No library with id " + HtmlEscapers.htmlEscaper().escape(id) + " found")); } diff --git a/jabsrv/src/test/java/org/jabref/http/server/services/ServerUtilsTest.java b/jabsrv/src/test/java/org/jabref/http/server/services/ServerUtilsTest.java new file mode 100644 index 000000000000..31f62e205354 --- /dev/null +++ b/jabsrv/src/test/java/org/jabref/http/server/services/ServerUtilsTest.java @@ -0,0 +1,79 @@ +package org.jabref.http.server.services; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import javafx.collections.FXCollections; + +import org.jabref.http.SrvStateManager; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.model.database.BibDatabaseContext; + +import jakarta.ws.rs.NotFoundException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/// A directory library carries no `.bib` path, only a root directory. These tests pin that +/// jabsrv identifies it by that root — so the library listing, the existence-check query, and +/// the add endpoints can all address it. +/// [utest->req~directory-library.rest-api~1] +class ServerUtilsTest { + + @TempDir + Path root; + + private BibDatabaseContext directoryLibrary() { + BibDatabaseContext context = new BibDatabaseContext(); + context.convertToDirectoryLibrary(root); + return context; + } + + private String rootId() { + return ServerUtils.libraryId(directoryLibrary()).orElseThrow(); + } + + private SrvStateManager stateManagerWith(BibDatabaseContext context) { + SrvStateManager stateManager = mock(SrvStateManager.class); + when(stateManager.getOpenDatabases()).thenReturn(FXCollections.observableArrayList(context)); + return stateManager; + } + + @Test + void directoryLibraryIsListedByItsRootDerivedId() { + assertEquals(List.of(rootId()), ServerUtils.openLibraryIds(stateManagerWith(directoryLibrary()))); + } + + @Test + void directoryLibraryResolvesByIdToItsRoot() { + assertEquals(root, ServerUtils.getLibraryPath(rootId(), stateManagerWith(directoryLibrary()))); + } + + @Test + void directoryLibraryContextResolvesById() throws IOException { + BibDatabaseContext context = directoryLibrary(); + assertSame(context, ServerUtils.getBibDatabaseContext(rootId(), stateManagerWith(context), mock(ImportFormatPreferences.class))); + } + + @Test + void directoryLibraryFileIsItsMirror() { + assertEquals(root.resolve(root.getFileName() + ".bib"), ServerUtils.getLibraryFile(rootId(), stateManagerWith(directoryLibrary()))); + } + + @Test + void unsavedLibraryHasNoId() { + assertEquals(Optional.empty(), ServerUtils.libraryId(new BibDatabaseContext())); + } + + @Test + void unknownIdIsNotFound() { + assertThrows(NotFoundException.class, () -> ServerUtils.getLibraryPath("does-not-exist", stateManagerWith(directoryLibrary()))); + } +}