Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
167b80b
Make directory libraries reachable through the REST API
koppor Jul 22, 2026
a942728
Merge branch 'directory-convert' (with origin/main) into directory-re…
koppor Aug 22, 2026
ea464e9
Merge directory-convert into directory-rest-api
koppor Sep 3, 2026
e71ac29
Remove duplicated CHANGELOG entries left by the resync merge
koppor Sep 3, 2026
262e960
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 3, 2026
fe6cbd2
Identify directory libraries consistently across the REST API
koppor Sep 3, 2026
713b55d
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 3, 2026
82dc8e9
Fix checkstyle findings in the select command
koppor Sep 3, 2026
806d6c4
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 3, 2026
3ab0d2f
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 3, 2026
9b9391d
Apply IntelliJ formatter after upstream resync
koppor Sep 6, 2026
8fa0a97
Merge remote-tracking branch 'origin/directory-convert' into director…
koppor Sep 7, 2026
6b2ca7f
Renumber the directory-as-library ADR to 0072
koppor Sep 7, 2026
b699b24
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 7, 2026
06ac015
Merge directory-convert (with origin/main) into directory-rest-api
koppor Sep 12, 2026
8ee71de
Identify directory libraries by their root in getLibraryId
koppor Sep 13, 2026
025ad23
Merge directory-convert into directory-rest-api
koppor Sep 13, 2026
a809e98
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 13, 2026
a86beb9
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 13, 2026
ca78b3a
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 23, 2026
6713625
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 23, 2026
703a67a
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 23, 2026
e6ba715
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 28, 2026
f2e9f62
Merge branch 'directory-convert' into directory-rest-api
koppor Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/requirements/directory-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!-- markdownlint-disable-file MD022 -->
Original file line number Diff line number Diff line change
Expand Up @@ -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<Path> 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> getLibraryId() {
return getDatabasePath().map(path -> path.getFileName() + "-" + BackupFileUtil.getUniqueFilePrefix(path));
return getPathOnDisk().map(path -> path.getFileName() + "-" + BackupFileUtil.getUniqueFilePrefix(path));
}

public BibDatabase getDatabase() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@
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;

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;
Expand All @@ -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<String> 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<String> 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);
}
Expand All @@ -75,7 +102,7 @@ public static List<String> 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"));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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())));
}
}
Loading