diff --git a/.gitattributes b/.gitattributes index 18bbb30fc6d..9a7729617c8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,6 +12,8 @@ gradlew text eol=lf # .bib files have to be written using OS specific line endings to enable our tests working *.bib text !eol +# Exception: The files used for the http server test - they should have linux line endings +src/test/resources/org/jabref/http/server/*.bib text eol=lf # Citavi needs to be LF line ending # This overwrites the setting of "*.bib" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2f6c9545f3f..50eca74f0a2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,7 +57,7 @@ jobs: - name: Set up JDK uses: actions/setup-java@v3 with: - java-version: 20 + java-version: 19 distribution: 'temurin' cache: 'gradle' - name: Run tests diff --git a/build.gradle b/build.gradle index 3e199a20580..7ed9102c2ca 100644 --- a/build.gradle +++ b/build.gradle @@ -180,6 +180,8 @@ dependencies { implementation "org.tinylog:tinylog-api:2.6.1" implementation "org.tinylog:slf4j-tinylog:2.6.1" implementation "org.tinylog:tinylog-impl:2.6.1" + // route all requests to java.util.logging to SLF4J (which in turn routes to tinylog) + implementation 'org.slf4j:jul-to-slf4j:2.0.7' implementation 'de.undercouch:citeproc-java:3.0.0-beta.2' @@ -200,6 +202,28 @@ dependencies { implementation group: 'net.harawata', name: 'appdirs', version: '1.2.1' + // JAX-RS implemented by Jersey + // API + implementation 'jakarta.ws.rs:jakarta.ws.rs-api:3.1.0' + // Implementation of the API + implementation 'org.glassfish.jersey.core:jersey-server:3.1.1' + // injection framework + implementation 'org.glassfish.jersey.inject:jersey-hk2:3.1.1' + implementation 'org.glassfish.hk2:hk2-api:2.6.1' + // testImplementation 'org.glassfish.hk2:hk2-testing:3.0.4' + // implementation 'org.glassfish.hk2:hk2-testing-jersey:3.0.4' + // testImplementation 'org.glassfish.hk2:hk2-junitrunner:3.0.4' + // HTTP server + // implementation 'org.glassfish.jersey.containers:jersey-container-netty-http:3.1.1' + implementation 'org.glassfish.jersey.containers:jersey-container-grizzly2-http:3.1.1' + testImplementation 'org.glassfish.jersey.test-framework.providers:jersey-test-framework-provider-grizzly2:3.1.1' + // OpenAPI generation + implementation 'io.swagger.core.v3:swagger-jaxrs2-jakarta:2.2.9' + // Allow objects "magically" to be mapped to JSON using GSON + // implementation 'org.glassfish.jersey.media:jersey-media-json-gson:3.1.1' + // We use CUID2 instead of UUID. See ADR-0029 + // implementation 'io.github.thibaultmeyer:cuid:2.0.2' + testImplementation 'io.github.classgraph:classgraph:4.8.157' testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2' testImplementation 'org.junit.platform:junit-platform-launcher:1.9.2' @@ -381,6 +405,12 @@ run { 'javafx.base/com.sun.javafx.event' : 'com.jfoenix' ] } + + if (project.hasProperty('application')){ + if (application == 'httpserver'){ + main = 'org.jabref.http.server.Server' + } + } } javadoc { diff --git a/docs/code-howtos/http-server.md b/docs/code-howtos/http-server.md new file mode 100644 index 00000000000..f3fa5d91578 --- /dev/null +++ b/docs/code-howtos/http-server.md @@ -0,0 +1,16 @@ +--- +parent: Code Howtos +--- +# HTTP Server + +## Get SSL Working + +(Based on ) + +Howto vor Windows - other operating systems work similar: + +1. As admin `choco install mkcert` +2. As admin: `mkcert -install` +3. `cd %APPDATA%\..\local\org.jabref\jabref\ssl` +4. `mkcert -pkcs12 jabref.desktop jabref localhost 127.0.0.1 ::1` +5. Rename the file to `server.p12` diff --git a/docs/decisions/0028-http-return-bibtex-string.md b/docs/decisions/0028-http-return-bibtex-string.md new file mode 100644 index 00000000000..29658c423a3 --- /dev/null +++ b/docs/decisions/0028-http-return-bibtex-string.md @@ -0,0 +1,53 @@ +--- +nav_order: 27 +parent: Decision Records +--- + + +# Return BibTeX string and CSL Item JSON in the API + +## Context and Problem Statement + +In the context of an http server, when a http client `GETs` a JSON data structure containing BibTeX data, which format should that have? + +## Considered Options + +* Offer both, BibTeX string and CSL JSON +* Return BibTeX as is as string +* Convert BibTeX to JSON + +## Decision Outcome + +Chosen option: "Offer both, BibTeX string and CSL JSON", because there are many browser libraries out there being able to parse BibTeX. Thus, we don't need to convert it. + +## Pros and Cons of the Options + +### Offer both, BibTeX string and CSL JSON + +- Good, because this follows "Backend for Frontend" +- Good, because Word Addin works seamless with the data provided (and does not need another dependency) +- Good, because other clients can work with BibTeX data +- Bad, because two serializations have to be kept + +### Return BibTeX as is as string + +- Good, because we don't need to think about any conversion +- Bad, because it is unclear how to ship BibTeX data where the entry is dependent on +- Bad, because client needs add additional parsing logic + +### Convert BibTeX to JSON + +More thought has to be done when converting to JSON. +There seems to be a JSON format from [@citation-js/plugin-bibtex](https://www.npmjs.com/package/@citation-js/plugin-bibtex). +We could do an additional self-made JSON format, but this increases the number of available JSON serializations for BibTeX. + +- Good, because it could flatten BibTeX data (example: `author = first # " and " # second`) +- Bad, because conversion is difficult in BibTeX special cases. For instance, if Strings are used (example: `author = first # " and " # second`) and one doesn't want to flatten ("normalize") this. + +## More Information + +Existing JavaScript BibTeX libraries: + +* [bibtex-js](https://github.com/digitalheir/bibtex-js) +* [bibtexParseJS](https://github.com/ORCID/bibtexParseJs) +* [@citation-js/plugin-bibtex](https://www.npmjs.com/package/@citation-js/plugin-bibtex) diff --git a/docs/decisions/0029-use-cuid2.md b/docs/decisions/0029-use-cuid2.md new file mode 100644 index 00000000000..c79363a2ce0 --- /dev/null +++ b/docs/decisions/0029-use-cuid2.md @@ -0,0 +1,26 @@ +--- +nav_order: 29 +parent: Decision Records +--- + +# Use CUID2 as globally unique identifiers + +## Context and Problem Statement + +Each BibEntry needs to have a unique id. +See [Remote Storage - JabDrive](../code-howtos/remote-storage-jabdrive.md) for details. + +## Decision Drivers + +* "Nice and modern looking" identifiers +* Easy to generate + +## Considered Options + +* UUIDv4 +* [CUID2](https://github.com/paralleldrive/cuid2) +* [Nano ID](https://github.com/ai/nanoid) + +## Decision Outcome + +Chosen option: "CUID2", because resolves all decision drivers. diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index d1087fdb5f9..66fa2c7263e 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -16,6 +16,9 @@ requires afterburner.fx; requires com.jfoenix; requires de.saxsys.mvvmfx; + requires reactfx; + requires de.saxsys.mvvmfx.validation; + requires org.fxmisc.flowless; requires org.kordamp.ikonli.core; requires org.kordamp.ikonli.javafx; @@ -39,6 +42,7 @@ // Logging requires org.slf4j; + requires jul.to.slf4j; requires org.tinylog.api; requires org.tinylog.api.slf4j; requires org.tinylog.impl; @@ -47,47 +51,65 @@ with org.jabref.gui.logging.GuiWriter, org.jabref.gui.logging.ApplicationInsightsWriter; - // Preferences and XML requires java.prefs; + + // Annotations (@PostConstruct) + requires jakarta.annotation; + requires jakarta.inject; + + // http server and client exchange + requires java.net.http; + requires jakarta.ws.rs; + requires grizzly.framework; + // requires cuid; + + // OpenAPI generation + requires io.swagger.v3.core; + requires io.swagger.v3.oas.models; + requires io.swagger.v3.oas.integration; + requires io.swagger.v3.jaxrs2; + + // data mapping requires jakarta.xml.bind; + requires jdk.xml.dom; + requires com.google.gson; + requires com.fasterxml.jackson.databind; + requires com.fasterxml.jackson.dataformat.yaml; + requires com.fasterxml.jackson.datatype.jsr310; // needs to be loaded here as it's otherwise not found at runtime requires org.glassfish.jaxb.runtime; - requires jdk.xml.dom; - // Annotations (@PostConstruct) - requires jakarta.annotation; + // dependency injection using HK2 + requires org.glassfish.hk2.api; // Microsoft application insights requires applicationinsights.core; requires applicationinsights.logging.log4j2; - // Libre Office - requires org.libreoffice.uno; - - // Other modules - requires com.google.common; - requires jakarta.inject; - requires reactfx; - requires commons.cli; - requires com.github.tomtung.latex2unicode; - requires fastparse; - requires jbibtex; - requires citeproc.java; - requires de.saxsys.mvvmfx.validation; - requires com.google.gson; + // http clients requires unirest.java; requires org.apache.httpcomponents.httpclient; requires org.jsoup; - requires org.apache.commons.csv; - requires io.github.javadiffutils; - requires java.string.similarity; + + // SQL databases requires ojdbc10; requires org.postgresql.jdbc; requires org.mariadb.jdbc; uses org.mariadb.jdbc.credential.CredentialPlugin; + + // Apache Commons and other (similar) helper libraries + requires commons.cli; + requires org.apache.commons.csv; requires org.apache.commons.lang3; - requires org.antlr.antlr4.runtime; - requires org.fxmisc.flowless; + requires com.google.common; + requires io.github.javadiffutils; + requires java.string.similarity; + + requires com.github.tomtung.latex2unicode; + requires fastparse; + + requires jbibtex; + requires citeproc.java; requires pdfbox; requires xmpbox; @@ -96,7 +118,6 @@ requires flexmark; requires flexmark.util.ast; requires flexmark.util.data; - requires com.h2database.mvstore; // fulltext search requires org.apache.lucene.core; @@ -108,9 +129,6 @@ requires org.apache.lucene.analysis.common; requires org.apache.lucene.highlighter; - requires com.fasterxml.jackson.databind; - requires com.fasterxml.jackson.dataformat.yaml; - requires com.fasterxml.jackson.datatype.jsr310; requires net.harawata.appdirs; requires com.sun.jna; requires com.sun.jna.platform; @@ -118,4 +136,10 @@ requires org.eclipse.jgit; uses org.eclipse.jgit.transport.SshSessionFactory; uses org.eclipse.jgit.lib.GpgSigner; + + // other libraries + requires com.h2database.mvstore; + requires org.antlr.antlr4.runtime; + requires org.libreoffice.uno; + } diff --git a/src/main/java/org/jabref/cli/Launcher.java b/src/main/java/org/jabref/cli/Launcher.java index 445736ca63d..89adc9dd43b 100644 --- a/src/main/java/org/jabref/cli/Launcher.java +++ b/src/main/java/org/jabref/cli/Launcher.java @@ -32,6 +32,7 @@ import org.apache.commons.cli.ParseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.bridge.SLF4JBridgeHandler; import org.tinylog.configuration.Configuration; /** @@ -46,6 +47,7 @@ public class Launcher { private static String[] ARGUMENTS; public static void main(String[] args) { + routeLoggingToSlf4J(); ARGUMENTS = args; addLogToDisk(); try { @@ -84,6 +86,11 @@ public static void main(String[] args) { } } + private static void routeLoggingToSlf4J() { + SLF4JBridgeHandler.removeHandlersForRootLogger(); + SLF4JBridgeHandler.install(); + } + /** * This needs to be called as early as possible. After the first log write, it * is not possible to alter @@ -92,10 +99,10 @@ public static void main(String[] args) { private static void addLogToDisk() { Path directory = Path.of(AppDirsFactory.getInstance() .getUserDataDir( - OS.APP_DIR_APP_NAME, - "logs", - OS.APP_DIR_APP_AUTHOR)) - .resolve(new BuildInfo().version.toString()); + OS.APP_DIR_APP_NAME, + "logs", + OS.APP_DIR_APP_AUTHOR)) + .resolve(new BuildInfo().version.toString()); try { Files.createDirectories(directory); } catch (IOException e) { @@ -183,9 +190,9 @@ private static void clearOldSearchIndices() { && !path.equals(currentIndexPath)) { LOGGER.info("Deleting out-of-date fulltext search index at {}.", path); Files.walk(path) - .sorted(Comparator.reverseOrder()) - .map(Path::toFile) - .forEach(File::delete); + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); } } } catch (IOException e) { diff --git a/src/main/java/org/jabref/http/MediaType.java b/src/main/java/org/jabref/http/MediaType.java new file mode 100644 index 00000000000..b37297ebd71 --- /dev/null +++ b/src/main/java/org/jabref/http/MediaType.java @@ -0,0 +1,6 @@ +package org.jabref.http; + +public class MediaType { + public static final String BIBTEX = "application/x-bibtex"; + public static final String JSON_CSL_ITEM = "application/x-bibtex-library-csl+json"; +} diff --git a/src/main/java/org/jabref/http/client/SyncClient.java b/src/main/java/org/jabref/http/client/SyncClient.java new file mode 100644 index 00000000000..368f4ddc590 --- /dev/null +++ b/src/main/java/org/jabref/http/client/SyncClient.java @@ -0,0 +1,53 @@ +package org.jabref.http.client; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; + +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.http.dto.BibEntryDTO; + +public class SyncClient { + + private final BibDatabaseContext bibDatabaseContext; + private Long lastSynchronizedGlobalRevision = -1L; + + private HttpClient httpClient = HttpClient.newHttpClient(); + + /** + * Initializes a client for the given context + */ + public SyncClient(BibDatabaseContext bibDatabaseContext) throws IllegalArgumentException { + if (bibDatabaseContext.getDatabasePath().isEmpty()) { + throw new IllegalArgumentException("Unsaved libraries not yet supported."); + } + this.bibDatabaseContext = bibDatabaseContext; + } + + /** + * Client needs to store the state of Id and entry locally to be able to handle external changes. + * This is done using the "dirty" flag. + */ + private void synchronizeWithLocalView() { + } + + public List getChanges() throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/updates?lastUpdate=0")) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return null; + } + + /** + * Synchronizes the given library with the server. + *

+ * Pre-condition: Connection with server works + */ + public void synchronize(BibDatabaseContext bibDatabaseContext) { + } +} diff --git a/src/main/java/org/jabref/http/dto/BibEntryDTO.java b/src/main/java/org/jabref/http/dto/BibEntryDTO.java new file mode 100644 index 00000000000..7fc89afc348 --- /dev/null +++ b/src/main/java/org/jabref/http/dto/BibEntryDTO.java @@ -0,0 +1,86 @@ +package org.jabref.http.dto; + +import java.io.IOException; +import java.io.StringWriter; + +import org.jabref.gui.Globals; +import org.jabref.http.server.ServerPreferences; +import org.jabref.logic.bibtex.BibEntryWriter; +import org.jabref.logic.bibtex.FieldWriter; +import org.jabref.logic.bibtex.FieldWriterPreferences; +import org.jabref.logic.exporter.BibWriter; +import org.jabref.model.database.BibDatabaseMode; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.BibEntryTypesManager; +import org.jabref.model.entry.SharedBibEntryData; + +import com.google.common.base.MoreObjects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The data transfer object (DTO) for an BibEntry + * + * @param sharingMetadata the data used for sharing + * @param userComments the comments before the BibTeX entry + * @param citationKey the citation key (duplicated from BibEntry to ease processing by the client) + * @param bibtex the BibEntry as BibTeX string (see ADR-0027 for more information, why we don't use a HashMap / JSON) + */ +public record BibEntryDTO(SharedBibEntryData sharingMetadata, String userComments, String citationKey, String bibtex) implements Comparable { + + public static final Logger LOGGER = LoggerFactory.getLogger(BibEntryDTO.class); + + public BibEntryDTO(BibEntry bibEntry, BibDatabaseMode bibDatabaseMode, FieldWriterPreferences fieldWriterPreferences, BibEntryTypesManager bibEntryTypesManager) { + this(bibEntry.getSharedBibEntryData(), + bibEntry.getUserComments(), + bibEntry.getCitationKey().orElse(""), + convertToString(bibEntry, bibDatabaseMode, fieldWriterPreferences, bibEntryTypesManager) + ); + } + + public BibEntryDTO(BibEntry entry, BibDatabaseMode mode) { + this(entry, mode, ServerPreferences.fieldWriterPreferences(), Globals.entryTypesManager); + } + + /** + * Creates a DTO based on Bibtex and default field writer preferences + * + * TODO: We should check how the BibLaTeX mode influences serialization (it should not?!) + */ + public BibEntryDTO(BibEntry entry) { + this(entry, BibDatabaseMode.BIBTEX); + } + + static String convertToString(BibEntry entry, BibDatabaseMode bibDatabaseMode, FieldWriterPreferences fieldWriterPreferences, BibEntryTypesManager bibEntryTypesManager) { + StringWriter rawEntry = new StringWriter(); + BibWriter bibWriter = new BibWriter(rawEntry, "\n"); + BibEntryWriter bibtexEntryWriter = new BibEntryWriter(new FieldWriter(fieldWriterPreferences), bibEntryTypesManager); + try { + bibtexEntryWriter.write(entry, bibWriter, bibDatabaseMode); + } catch (IOException e) { + LOGGER.warn("Problem creating BibTeX entry.", e); + return "error"; + } + return rawEntry.toString(); + } + + @Override + public int compareTo(BibEntryDTO o) { + int sharingComparison = sharingMetadata.compareTo(o.sharingMetadata); + if (sharingComparison != 0) { + return sharingComparison; + } + LOGGER.error("Comparing equal DTOs"); + return 0; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("sharingMetadata", sharingMetadata) + .add("userComments", userComments) + .add("citationkey", citationKey) + .add("bibtex", bibtex) + .toString(); + } +} diff --git a/src/main/java/org/jabref/http/dto/GsonFactory.java b/src/main/java/org/jabref/http/dto/GsonFactory.java new file mode 100644 index 00000000000..77da67f9ba4 --- /dev/null +++ b/src/main/java/org/jabref/http/dto/GsonFactory.java @@ -0,0 +1,18 @@ +package org.jabref.http.dto; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.glassfish.hk2.api.Factory; + +public class GsonFactory implements Factory { + @Override + public Gson provide() { + return new GsonBuilder() + .setPrettyPrinting() + .create(); + } + + @Override + public void dispose(Gson instance) { + } +} diff --git a/src/main/java/org/jabref/http/server/Application.java b/src/main/java/org/jabref/http/server/Application.java new file mode 100644 index 00000000000..1f9b7e98448 --- /dev/null +++ b/src/main/java/org/jabref/http/server/Application.java @@ -0,0 +1,79 @@ +package org.jabref.http.server; + +import java.util.Set; + +import org.jabref.http.dto.GsonFactory; +import org.jabref.preferences.PreferenceServiceFactory; + +import jakarta.inject.Inject; +import jakarta.ws.rs.ApplicationPath; +import org.glassfish.hk2.api.ServiceLocator; +import org.glassfish.hk2.utilities.ServiceLocatorUtilities; + +import io.swagger.v3.jaxrs2.Reader; +import io.swagger.v3.jaxrs2.integration.JaxrsApplicationScanner; +import io.swagger.v3.oas.integration.GenericOpenApiContextBuilder; +import io.swagger.v3.oas.integration.OpenApiConfigurationException; +import io.swagger.v3.oas.integration.SwaggerConfiguration; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationPath("/") +public class Application extends jakarta.ws.rs.core.Application { + + private static final Logger LOGGER = LoggerFactory.getLogger(Application.class); + + @Inject + ServiceLocator serviceLocator; + + public Application() { + super(); + JaxrsApplicationScanner scanner = new JaxrsApplicationScanner(); + scanner.setApplication(this); + Reader reader = new Reader(new OpenAPI()); + OpenAPI openAPI = reader.read(scanner.classes()); + Info info = new Info() + .title("JabRef http API") + .description("This is a sample JabDrive synchronization server.") + .version("0.1.0") + // .termsOfService("http://swagger.io/terms/") + .contact(new Contact() + .email("jabdrive@jabref.org")) + .license(new License() + .name("MIT") + .url("https://github.com/JabRef/jabref/blob/main/LICENSE.md")); + openAPI.info(info); + + SwaggerConfiguration oasConfig = new SwaggerConfiguration() + .openAPI(openAPI) + .prettyPrint(true); + + try { + new GenericOpenApiContextBuilder() + .resourceClasses(Set.of("org.jabref.testutils.interactive.sync.server.MweSyncRootResource")) + .openApiConfiguration(oasConfig) + .ctxId("org.jabref.sync") + .buildContext(true); + } catch (OpenApiConfigurationException e) { + LOGGER.error("Error in OpenAPI configuration", e); + } + } + + @Override + public Set> getClasses() { + initialize(); + return Set.of(RootResource.class, LibrariesResource.class, LibraryResource.class, UpdatesResource.class, CORSFilter.class); + } + + /** + * Separate initialization method, because @Inject does not support injection at the constructor + */ + private void initialize() { + ServiceLocatorUtilities.addFactoryConstants(serviceLocator, new GsonFactory()); + ServiceLocatorUtilities.addFactoryConstants(serviceLocator, new PreferenceServiceFactory()); + } +} diff --git a/src/main/java/org/jabref/http/server/CORSFilter.java b/src/main/java/org/jabref/http/server/CORSFilter.java new file mode 100644 index 00000000000..7489305808f --- /dev/null +++ b/src/main/java/org/jabref/http/server/CORSFilter.java @@ -0,0 +1,23 @@ +package org.jabref.http.server; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class CORSFilter implements ContainerResponseFilter { + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) { + String requestOrigin = requestContext.getHeaderString("Origin"); + if (requestOrigin == null) { + // IntelliJ's rest client is calling + responseContext.getHeaders().add("Access-Control-Allow-Origin", "*"); + } else if (requestOrigin.contains("://localhost")) { + responseContext.getHeaders().add("Access-Control-Allow-Origin", requestOrigin); + } + responseContext.getHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + responseContext.getHeaders().add("Access-Control-Allow-Headers", "origin, content-type, accept"); + responseContext.getHeaders().add("Access-Control-Allow-Credentials", "false"); + } +} diff --git a/src/main/java/org/jabref/http/server/LibrariesResource.java b/src/main/java/org/jabref/http/server/LibrariesResource.java new file mode 100644 index 00000000000..a5ea28c0ee1 --- /dev/null +++ b/src/main/java/org/jabref/http/server/LibrariesResource.java @@ -0,0 +1,29 @@ +package org.jabref.http.server; + +import java.util.List; + +import org.jabref.logic.util.io.BackupFileUtil; +import org.jabref.preferences.PreferencesService; + +import com.google.gson.Gson; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("libraries") +public class LibrariesResource { + @Inject + PreferencesService preferences; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String get() { + List fileNamesWithUniqueSuffix = preferences.getGuiPreferences().getLastFilesOpened().stream() + .map(java.nio.file.Path::of) + .map(p -> p.getFileName() + "-" + BackupFileUtil.getUniqueFilePrefix(p)) + .toList(); + return new Gson().toJson(fileNamesWithUniqueSuffix); + } +} diff --git a/src/main/java/org/jabref/http/server/LibraryResource.java b/src/main/java/org/jabref/http/server/LibraryResource.java new file mode 100644 index 00000000000..94e3e5a7505 --- /dev/null +++ b/src/main/java/org/jabref/http/server/LibraryResource.java @@ -0,0 +1,101 @@ +package org.jabref.http.server; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; + +import org.jabref.gui.Globals; +import org.jabref.http.dto.BibEntryDTO; +import org.jabref.logic.citationstyle.JabRefItemDataProvider; +import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.importer.fileformat.BibtexImporter; +import org.jabref.logic.util.io.BackupFileUtil; +import org.jabref.model.entry.BibEntryTypesManager; +import org.jabref.model.util.DummyFileUpdateMonitor; +import org.jabref.preferences.PreferencesService; + +import com.google.gson.Gson; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Path("libraries/{id}") +public class LibraryResource { + public static final Logger LOGGER = LoggerFactory.getLogger(LibraryResource.class); + + @Inject + PreferencesService preferences; + + @Inject + Gson gson; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String getJson(@PathParam("id") String id) { + ParserResult parserResult = getParserResult(preferences, id); + List list = parserResult.getDatabase().getEntries().stream() + .map(bibEntry -> { + bibEntry.getSharedBibEntryData().setSharedID(Objects.hash(bibEntry)); + return bibEntry; + }) + .map(entry -> new BibEntryDTO(entry, parserResult.getDatabaseContext().getMode())) + .toList(); + return gson.toJson(list); + } + + @GET + @Produces(org.jabref.http.MediaType.JSON_CSL_ITEM) + public String getClsItemJson(@PathParam("id") String id) { + ParserResult parserResult = getParserResult(preferences, id); + JabRefItemDataProvider jabRefItemDataProvider = new JabRefItemDataProvider(); + jabRefItemDataProvider.setData(parserResult.getDatabaseContext(), new BibEntryTypesManager()); + return jabRefItemDataProvider.toJson(); + } + + static ParserResult getParserResult(PreferencesService preferences, String id) { + java.nio.file.Path library = getLibraryPath(preferences, id); + ParserResult parserResult; + try { + parserResult = new BibtexImporter(preferences.getImportFormatPreferences(), new DummyFileUpdateMonitor()).importDatabase(library); + } catch (IOException e) { + LOGGER.warn("Could not find open library file {}", library, e); + throw new InternalServerErrorException("Could not parse library", e); + } + return parserResult; + } + + @GET + @Produces(org.jabref.http.MediaType.BIBTEX) + public Response getBibtex(@PathParam("id") String id) { + java.nio.file.Path library = getLibraryPath(preferences, id); + String libraryAsString; + try { + libraryAsString = Files.readString(library); + } catch (IOException e) { + LOGGER.error("Could not read library {}", library, e); + throw new InternalServerErrorException("Could not read library " + library, e); + } + return Response.ok() + .entity(libraryAsString) + .build(); + } + + private static java.nio.file.Path getLibraryPath(PreferencesService preferences, String id) { + java.nio.file.Path library = preferences.getGuiPreferences().getLastFilesOpened() + .stream() + .map(java.nio.file.Path::of) + .filter(p -> (p.getFileName() + "-" + BackupFileUtil.getUniqueFilePrefix(p)).equals(id)) + .findAny() + .orElseThrow(() -> new NotFoundException()); + return library; + } +} diff --git a/src/main/java/org/jabref/http/server/RootResource.java b/src/main/java/org/jabref/http/server/RootResource.java new file mode 100644 index 00000000000..3545a0d5438 --- /dev/null +++ b/src/main/java/org/jabref/http/server/RootResource.java @@ -0,0 +1,33 @@ +package org.jabref.http.server; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.integration.OpenApiContextLocator; +import io.swagger.v3.oas.models.OpenAPI; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/") +public class RootResource { + @GET + @Produces(MediaType.TEXT_HTML) + public String get() { + return """ + + +

+ JabRef http API runs. Please navigate to libraries. +

+ + """; + } + + @GET + @Path("openapi.json") + @Produces(MediaType.APPLICATION_JSON) + public String getOpenApiJson() { + OpenAPI openAPI = OpenApiContextLocator.getInstance().getOpenApiContext("org.jabref.sync").read(); + return Json.pretty(openAPI); + } +} diff --git a/src/main/java/org/jabref/http/server/Server.java b/src/main/java/org/jabref/http/server/Server.java new file mode 100644 index 00000000000..05f48aa111d --- /dev/null +++ b/src/main/java/org/jabref/http/server/Server.java @@ -0,0 +1,122 @@ +package org.jabref.http.server; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.net.ssl.SSLContext; + +import javafx.collections.ObservableList; + +import org.jabref.architecture.AllowedToUseStandardStreams; +import org.jabref.logic.util.OS; +import org.jabref.preferences.JabRefPreferences; + +import jakarta.ws.rs.SeBootstrap; +import net.harawata.appdirs.AppDirsFactory; +import org.glassfish.grizzly.ssl.SSLContextConfigurator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.bridge.SLF4JBridgeHandler; + +public class Server { + private static final Logger LOGGER = LoggerFactory.getLogger(Server.class); + + private static SeBootstrap.Instance serverInstance; + + /** + * Starts an http server serving the last files opened in JabRef
+ * More files can be provided as args. + */ + public static void main(final String[] args) throws InterruptedException, URISyntaxException { + SLF4JBridgeHandler.removeHandlersForRootLogger(); + SLF4JBridgeHandler.install(); + + final ObservableList lastFilesOpened = JabRefPreferences.getInstance().getGuiPreferences().getLastFilesOpened(); + + // The server serves the last opened files (see org.jabref.http.server.LibraryResource.getLibraryPath) + // In a testing environment, this might be difficult to handle + // This is a quick solution. The architectural fine solution would use some http context or other @Inject_ed variables in org.jabref.http.server.LibraryResource + if (args.length > 0) { + LOGGER.debug("Command line parameters passed"); + List filesToAdd = Arrays.stream(args) + .map(Path::of) + .filter(Files::exists) + .map(Path::toString) + .filter(path -> !lastFilesOpened.contains(path)) + .toList(); + + LOGGER.debug("Adding following files to the list of opened libraries: {}", filesToAdd); + + // add the files in the front of the last opened libraries + Collections.reverse(filesToAdd); + for (String path : filesToAdd) { + lastFilesOpened.add(0, path); + } + } + + if (lastFilesOpened.isEmpty()) { + LOGGER.debug("still no library available to serve, serve the demo library"); + // Server.class.getResource("...") is always null here, thus trying relative path + // Path bibPath = Path.of(Server.class.getResource("http-server-demo.bib").toURI()); + Path bibPath = Path.of("src/main/resources/org/jabref/http/server/http-server-demo.bib").toAbsolutePath(); + LOGGER.debug("Location of demo library: {}", bibPath); + lastFilesOpened.add(bibPath.toString()); + } + + LOGGER.debug("Libraries served: {}", lastFilesOpened); + + Server.startServer(); + + // Keep the http server running until user kills the process (e.g., presses Ctrl+C) + Thread.currentThread().join(); + } + + private static void startServer() { + SSLContext sslContext = getSslContext(); + SeBootstrap.Configuration configuration = SeBootstrap.Configuration + .builder() + .sslContext(sslContext) + .protocol("HTTPS") + .port(6051) + .build(); + LOGGER.debug("Starting server..."); + SeBootstrap.start(Application.class, configuration).thenAccept(instance -> { + LOGGER.debug("Server started."); + instance.stopOnShutdown(stopResult -> + LOGGER.info("Stop result: {} [Native stop result: {}}].", stopResult, + stopResult.unwrap(Object.class))); + final URI uri = instance.configuration().baseUri(); + LOGGER.info("Instance {} running at {} [Native handle: {}].", instance, uri, + instance.unwrap(Object.class)); + LOGGER.info("Send SIGKILL to shutdown."); + serverInstance = instance; + }); + } + + private static SSLContext getSslContext() { + SSLContextConfigurator sslContextConfig = new SSLContextConfigurator(); + Path serverKeyStore = Path.of(AppDirsFactory.getInstance() + .getUserDataDir( + OS.APP_DIR_APP_NAME, + "ssl", + OS.APP_DIR_APP_AUTHOR)) + .resolve("server.p12"); + if (Files.exists(serverKeyStore)) { + sslContextConfig.setKeyStoreFile(serverKeyStore.toString()); + sslContextConfig.setKeyStorePass("changeit"); + } else { + LOGGER.error("Could not find server key store {}.", serverKeyStore); + LOGGER.error("One create one by following the steps described in [http-server.md](/docs/code-howtos/http-server.md), which is rendered at "); + } + return sslContextConfig.createSSLContext(); + } + + static void stopServer() { + serverInstance.stop(); + } +} diff --git a/src/main/java/org/jabref/http/server/ServerPreferences.java b/src/main/java/org/jabref/http/server/ServerPreferences.java new file mode 100644 index 00000000000..938353c0f7c --- /dev/null +++ b/src/main/java/org/jabref/http/server/ServerPreferences.java @@ -0,0 +1,15 @@ +package org.jabref.http.server; + +import java.util.List; + +import org.jabref.logic.bibtex.FieldContentFormatterPreferences; +import org.jabref.logic.bibtex.FieldWriterPreferences; + +public class ServerPreferences { + + public static FieldWriterPreferences fieldWriterPreferences() { + FieldContentFormatterPreferences fieldContentFormatterPreferences = new FieldContentFormatterPreferences(List.of()); + FieldWriterPreferences fieldWriterPreferences = new FieldWriterPreferences(false, List.of(), fieldContentFormatterPreferences); + return fieldWriterPreferences; + } +} diff --git a/src/main/java/org/jabref/http/server/UpdatesResource.java b/src/main/java/org/jabref/http/server/UpdatesResource.java new file mode 100644 index 00000000000..25733425b4d --- /dev/null +++ b/src/main/java/org/jabref/http/server/UpdatesResource.java @@ -0,0 +1,46 @@ +package org.jabref.http.server; + +import java.lang.reflect.Type; +import java.util.List; + +import org.jabref.http.dto.BibEntryDTO; +import org.jabref.http.sync.state.ChangesAndServerView; +import org.jabref.http.sync.state.SyncState; +import org.jabref.preferences.PreferencesService; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +@Path("libraries/{id}/updates") +public class UpdatesResource { + @Inject + Gson gson; + + @Inject + PreferencesService preferences; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String get(@PathParam("id") String id, @QueryParam("since") int since) { + SyncState syncState = new SyncState(LibraryResource.getParserResult(preferences, id).getDatabaseContext()); + ChangesAndServerView changes = syncState.changesAndServerView(since); + return gson.toJson(changes); + } + + @POST + @Produces(MediaType.APPLICATION_JSON) + public void acceptChanges(String changes) { + Type listType = new TypeToken>() { + }.getType(); + List result = gson.fromJson(changes, listType); + System.out.println(result); + } +} diff --git a/src/main/java/org/jabref/http/sync/package-info.java b/src/main/java/org/jabref/http/sync/package-info.java new file mode 100644 index 00000000000..b635841a76e --- /dev/null +++ b/src/main/java/org/jabref/http/sync/package-info.java @@ -0,0 +1,5 @@ +/** + * This package is responsible for the synchronization with a server. + * General documentation available at docs/code-howtos/remote-storage-jabdrive.md + */ +package org.jabref.http.sync; diff --git a/src/main/java/org/jabref/http/sync/state/ChangesAndServerView.java b/src/main/java/org/jabref/http/sync/state/ChangesAndServerView.java new file mode 100644 index 00000000000..4c58722601f --- /dev/null +++ b/src/main/java/org/jabref/http/sync/state/ChangesAndServerView.java @@ -0,0 +1,8 @@ +package org.jabref.http.sync.state; + +import org.jabref.http.dto.BibEntryDTO; + +import java.util.List; + +public record ChangesAndServerView(List changes, List hashes) { +} diff --git a/src/main/java/org/jabref/http/sync/state/HashInfo.java b/src/main/java/org/jabref/http/sync/state/HashInfo.java new file mode 100644 index 00000000000..ddef8c105ad --- /dev/null +++ b/src/main/java/org/jabref/http/sync/state/HashInfo.java @@ -0,0 +1,20 @@ +package org.jabref.http.sync.state; + +import com.google.common.base.Strings; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.SharedBibEntryData; +import org.jabref.model.strings.StringUtil; + +public record HashInfo(String id, Integer hash) { + /** + * Converts the {@link SharedBibEntryData#sharedID} to a string following CUID2 for the structure. + * We need to convert from int (covering 64k entries) to CUID2 to be able to serve endless numbers of entries + */ + public HashInfo(int id, Integer hash) { + this(Strings.padStart(Integer.toString(id), 10, '0'), hash); + } + + public HashInfo(BibEntry entry) { + this(entry.getSharedBibEntryData().getSharedID(), entry.hashCode()); + } +} diff --git a/src/main/java/org/jabref/http/sync/state/SyncState.java b/src/main/java/org/jabref/http/sync/state/SyncState.java new file mode 100644 index 00000000000..1aba356ba2b --- /dev/null +++ b/src/main/java/org/jabref/http/sync/state/SyncState.java @@ -0,0 +1,64 @@ +package org.jabref.http.sync.state; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.http.dto.BibEntryDTO; + +public class SyncState { + private final BibDatabaseContext context; + // mapping from the shared ID to the DTO + private Map lastStateOfEntries = new HashMap<>(); + + // globalRevisionId -> set of IDs + private Map> idsUpdated = new HashMap<>(); + + public SyncState(BibDatabaseContext context) { + this.context = context; + } + + /** + * Adds or updates an entry. Caller has to ensure consistent state with BibDatabaseContext + */ + public void putEntry(Integer globalRevision, BibEntry entry) { + int sharedID = entry.getSharedBibEntryData().getSharedID(); + assert sharedID >= 0; + lastStateOfEntries.put(sharedID, new BibEntryDTO(entry)); + idsUpdated.computeIfAbsent(globalRevision, k -> new HashSet<>()).add(sharedID); + } + + /** + * Returns all changes between the given revisions. + * It also contains the hash values of all BibEntries of the server to enable a client to flag its view as dirty. + * + * @param fromRevision the revision to start from (exclusive) + */ + public ChangesAndServerView changesAndServerView(Integer fromRevision) { + List changes = idsUpdated.entrySet().stream() + .filter(entry -> entry.getKey() > fromRevision) + .flatMap(entry -> entry.getValue().stream()) + .distinct() + .sorted() + .map(sharedId -> lastStateOfEntries.get(sharedId)) + .collect(Collectors.toList()); + List hashInfos = context.getEntries().stream() + .map(entry -> new HashInfo(entry)) + .toList(); + return new ChangesAndServerView(changes, hashInfos); + } + + /** + * Required at testing to work around the single instance. + * May be used at testing only. + */ + public void reset() { + lastStateOfEntries = new HashMap<>(); + idsUpdated = new HashMap<>(); + } +} diff --git a/src/main/java/org/jabref/logic/git/GitHandler.java b/src/main/java/org/jabref/logic/git/GitHandler.java index 498e5c5f3df..e865e9b9299 100644 --- a/src/main/java/org/jabref/logic/git/GitHandler.java +++ b/src/main/java/org/jabref/logic/git/GitHandler.java @@ -71,7 +71,7 @@ void setupGitIgnore() { FileUtil.copyFile(Path.of(this.getClass().getResource("git.gitignore").toURI()), gitignore, false); } } catch (URISyntaxException e) { - LOGGER.error("Error occurred during copying of the gitignore file into the git repository."); + LOGGER.error("Error occurred during copying of the gitignore file into the git repository.", e); } } diff --git a/src/main/java/org/jabref/model/entry/BibEntry.java b/src/main/java/org/jabref/model/entry/BibEntry.java index 5f5301d633a..fa472858de0 100644 --- a/src/main/java/org/jabref/model/entry/BibEntry.java +++ b/src/main/java/org/jabref/model/entry/BibEntry.java @@ -748,6 +748,16 @@ public void setChanged(boolean changed) { this.changed = changed; } + + /** + * Required for our "builder". + * Each with... method sets changed to false. + */ + public BibEntry withChanged(boolean changed) { + this.changed = changed; + return this; + } + public Optional putKeywords(List keywords, Character delimiter) { Objects.requireNonNull(delimiter); return putKeywords(new KeywordList(keywords), delimiter); @@ -915,6 +925,7 @@ public BibEntry withField(Field field, String value) { */ public BibEntry withFields(Map content) { this.fields = FXCollections.observableMap(new HashMap<>(content)); + this.setChanged(false); return this; } @@ -1157,4 +1168,5 @@ public void mergeWith(BibEntry other, Set otherPrioritizedFields) { } } } + } diff --git a/src/main/java/org/jabref/model/entry/SharedBibEntryData.java b/src/main/java/org/jabref/model/entry/SharedBibEntryData.java index 69c3c6bf976..c3be22bb6aa 100644 --- a/src/main/java/org/jabref/model/entry/SharedBibEntryData.java +++ b/src/main/java/org/jabref/model/entry/SharedBibEntryData.java @@ -11,6 +11,8 @@ public class SharedBibEntryData implements Comparable { // It has to be unique on remote DBS for all connected JabRef instances. // The old id above does not satisfy this requirement. // This is "ID" in JabDrive sync + // TODO: Migrate to CUID - see ADR0029, why we chose CUID over UUIDs + // We can even limit the length to 10: https://github.com/paralleldrive/cuid2#configuration private int sharedID; // Needed for version controlling if used on shared database diff --git a/src/main/java/org/jabref/preferences/JabRefPreferences.java b/src/main/java/org/jabref/preferences/JabRefPreferences.java index bbc182f9497..f068e1921b0 100644 --- a/src/main/java/org/jabref/preferences/JabRefPreferences.java +++ b/src/main/java/org/jabref/preferences/JabRefPreferences.java @@ -118,7 +118,9 @@ import org.jabref.model.strings.StringUtil; import com.tobiasdiez.easybind.EasyBind; +import jakarta.inject.Singleton; import net.harawata.appdirs.AppDirsFactory; +import org.jvnet.hk2.annotations.Service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -132,6 +134,8 @@ * There are still some similar preferences classes (OpenOfficePreferences and SharedDatabasePreferences) which also use * the {@code java.util.prefs} API. */ +@Singleton +@Service public class JabRefPreferences implements PreferencesService { // Push to application preferences diff --git a/src/main/java/org/jabref/preferences/PreferenceServiceFactory.java b/src/main/java/org/jabref/preferences/PreferenceServiceFactory.java new file mode 100644 index 00000000000..15d323c4caf --- /dev/null +++ b/src/main/java/org/jabref/preferences/PreferenceServiceFactory.java @@ -0,0 +1,14 @@ +package org.jabref.preferences; + +import org.glassfish.hk2.api.Factory; + +public class PreferenceServiceFactory implements Factory { + @Override + public PreferencesService provide() { + return JabRefPreferences.getInstance(); + } + + @Override + public void dispose(PreferencesService instance) { + } +} diff --git a/src/main/java/org/jabref/preferences/PreferencesService.java b/src/main/java/org/jabref/preferences/PreferencesService.java index e131f8a2ead..ea255959a91 100644 --- a/src/main/java/org/jabref/preferences/PreferencesService.java +++ b/src/main/java/org/jabref/preferences/PreferencesService.java @@ -41,6 +41,9 @@ import org.jabref.model.entry.field.Field; import org.jabref.model.metadata.SaveOrderConfig; +import org.jvnet.hk2.annotations.Contract; + +@Contract public interface PreferencesService { InternalPreferences getInternalPreferences(); diff --git a/src/main/resources/org/jabref/http/server/http-server-demo.bib b/src/main/resources/org/jabref/http/server/http-server-demo.bib new file mode 100644 index 00000000000..f5374fef3ad --- /dev/null +++ b/src/main/resources/org/jabref/http/server/http-server-demo.bib @@ -0,0 +1,14 @@ +@InProceedings{Kopp2018, + author = {Kopp, Oliver and Armbruster, Anita and Zimmermann, Olaf}, + booktitle = {Proceedings of the 10th Central European Workshop on Services and their Composition ({ZEUS} 2018)}, + title = {Markdown Architectural Decision Records: Format and Tool Support}, + year = {2018}, + editor = {Nico Herzberg and Christoph Hochreiner and Oliver Kopp and J{\"{o}}rg Lenhard}, + pages = {55--62}, + publisher = {CEUR-WS.org}, + series = {{CEUR} Workshop Proceedings}, + volume = {2072}, + keywords = {ADR, MADR, architecture decision records, architectural decision records, Nygard}, +} + +@Comment{jabref-meta: databaseType:bibtex;} diff --git a/src/main/resources/tinylog.properties b/src/main/resources/tinylog.properties index b4340fa32e9..5347cea4ca5 100644 --- a/src/main/resources/tinylog.properties +++ b/src/main/resources/tinylog.properties @@ -7,3 +7,5 @@ writerAzure = application insights exception = strip: jdk.internal #level@org.jabref.model.entry.BibEntry = debug + +level@org.jabref.http.server.Server = debug diff --git a/src/test/java/org/jabref/architecture/MainArchitectureTest.java b/src/test/java/org/jabref/architecture/MainArchitectureTest.java index b7aff2a46f9..142e1e1a635 100644 --- a/src/test/java/org/jabref/architecture/MainArchitectureTest.java +++ b/src/test/java/org/jabref/architecture/MainArchitectureTest.java @@ -7,6 +7,7 @@ import com.tngtech.archunit.junit.ArchIgnore; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.library.GeneralCodingRules; +import org.jabref.http.sync.state.SyncState; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.library.Architectures.layeredArchitecture; @@ -133,4 +134,11 @@ public static void restrictStandardStreams(JavaClasses classes) { .because("logging framework should be used instead or the class be marked explicitly as @AllowedToUseStandardStreams") .check(classes); } + + @ArchTest + public static void SyncStateResetMayOnlyBeCalledAtTests(JavaClasses classes) { + noClasses() + .that().doNotHaveFullyQualifiedName("org.jabref.http.server.UpdatesResourceTest") + .should().callMethod(SyncState.class, "reset"); + } } diff --git a/src/test/java/org/jabref/http/dto/BibEntryDTOTest.java b/src/test/java/org/jabref/http/dto/BibEntryDTOTest.java new file mode 100644 index 00000000000..9fdd7f0915c --- /dev/null +++ b/src/test/java/org/jabref/http/dto/BibEntryDTOTest.java @@ -0,0 +1,100 @@ +package org.jabref.http.dto; + +import com.google.gson.Gson; +import org.jabref.gui.Globals; +import org.jabref.http.server.ServerPreferences; +import org.jabref.model.database.BibDatabaseMode; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class BibEntryDTOTest { + + private static GsonFactory gsonFactory = new GsonFactory(); + + public static Stream checkSerialization() { + return Stream.of( + Arguments.of(""" + { + "sharingMetadata": { + "sharedID": -1, + "version": 1 + }, + "userComments": "", + "citationKey": "", + "bibtex": "@Misc{,\\n}\\n" + }""", new BibEntry().withChanged(true)), + Arguments.of(""" + { + "sharingMetadata": { + "sharedID": -1, + "version": 1 + }, + "userComments": "", + "citationKey": "key", + "bibtex": "@Misc{key,\\n}\\n" + }""", new BibEntry().withCitationKey("key").withChanged(true)), + Arguments.of(""" + { + "sharingMetadata": { + "sharedID": -1, + "version": 1 + }, + "userComments": "", + "citationKey": "key", + "bibtex": "@Misc{key,\\n author \\u003d {Author},\\n}\\n" + }""", new BibEntry().withCitationKey("key").withField(StandardField.AUTHOR, "Author").withChanged(true)), + Arguments.of(""" + { + "sharingMetadata": { + "sharedID": 1, + "version": 1 + }, + "userComments": "", + "citationKey": "e1.v1", + "bibtex": "@Misc{e1.v1,\\n}\\n" + }""", new BibEntry().withCitationKey("e1.v1").withSharedBibEntryData(1, 1).withChanged(true)) + ); + } + + @ParameterizedTest + @MethodSource + public void checkSerialization(String expected, BibEntry entry) { + Gson gson = gsonFactory.provide(); + assertEquals(expected, gson.toJson(new BibEntryDTO(entry))); + } + + public static Stream convertToString() { + return Stream.of( + Arguments.of(""" + @Misc{, + } + """, new BibEntry().withChanged(true)), + Arguments.of(""" + @Misc{key, + } + """, new BibEntry().withCitationKey("key").withChanged(true)), + Arguments.of(""" + @Misc{key, + author = {Author}, + } + """, new BibEntry().withCitationKey("key").withField(StandardField.AUTHOR, "Author").withChanged(true)), + Arguments.of(""" + @Misc{e1.v1, + } + """, new BibEntry().withCitationKey("e1.v1").withSharedBibEntryData(1, 1).withChanged(true)) + ); + } + + @ParameterizedTest + @MethodSource + public void convertToString(String expected, BibEntry entry) { + assertEquals(expected, BibEntryDTO.convertToString(entry, BibDatabaseMode.BIBTEX, ServerPreferences.fieldWriterPreferences(), Globals.entryTypesManager)); + } +} diff --git a/src/test/java/org/jabref/http/server/LibrariesResourceTest.java b/src/test/java/org/jabref/http/server/LibrariesResourceTest.java new file mode 100644 index 00000000000..df7d7784bae --- /dev/null +++ b/src/test/java/org/jabref/http/server/LibrariesResourceTest.java @@ -0,0 +1,33 @@ +package org.jabref.http.server; + +import java.util.EnumSet; +import java.util.stream.Collectors; + +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LibrariesResourceTest extends ServerTest { + + @Override + protected jakarta.ws.rs.core.Application configure() { + ResourceConfig resourceConfig = new ResourceConfig(LibrariesResource.class); + addPreferencesToResourceConfig(resourceConfig); + return resourceConfig.getApplication(); + } + + @Test + void defaultOneTestLibrary() throws Exception { + assertEquals("[\"" + TestBibFile.GENERAL_SERVER_TEST.id + "\"]", target("/libraries").request().get(String.class)); + } + + @Test + void twoTestLibraries() { + EnumSet availableLibraries = EnumSet.of(TestBibFile.GENERAL_SERVER_TEST, TestBibFile.JABREF_AUTHORS); + setAvailableLibraries(availableLibraries); + // We cannot use a string constant as the path changes from OS to OS. Therefore, we need to dynamically create the expected result. + String expected = availableLibraries.stream().map(file -> file.id).collect(Collectors.joining("\",\"", "[\"", "\"]")); + assertEquals(expected, target("/libraries").request().get(String.class)); + } +} diff --git a/src/test/java/org/jabref/http/server/LibraryResourceTest.java b/src/test/java/org/jabref/http/server/LibraryResourceTest.java new file mode 100644 index 00000000000..650999edab2 --- /dev/null +++ b/src/test/java/org/jabref/http/server/LibraryResourceTest.java @@ -0,0 +1,38 @@ +package org.jabref.http.server; + +import org.jabref.http.MediaType; + +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LibraryResourceTest extends ServerTest { + + @Override + protected jakarta.ws.rs.core.Application configure() { + ResourceConfig resourceConfig = new ResourceConfig(LibraryResource.class, LibrariesResource.class); + addPreferencesToResourceConfig(resourceConfig); + addGsonToResourceConfig(resourceConfig); + return resourceConfig.getApplication(); + } + + @Test + void getJson() { + assertEquals(""" + @Misc{Author2023test, + author = {Demo Author}, + title = {Demo Title}, + year = {2023}, + } + + @Comment{jabref-meta: databaseType:bibtex;} + """, target("/libraries/" + TestBibFile.GENERAL_SERVER_TEST.id).request(MediaType.BIBTEX).get(String.class)); + } + + @Test + void getClsItemJson() { + assertEquals(""" + [{"id":"Author2023test","type":"article","author":[{"family":"Author","given":"Demo"}],"event-date":{"date-parts":[[2023]]},"issued":{"date-parts":[[2023]]},"title":"Demo Title"}]""", target("/libraries/" + TestBibFile.GENERAL_SERVER_TEST.id).request(MediaType.JSON_CSL_ITEM).get(String.class)); + } +} diff --git a/src/test/java/org/jabref/http/server/ServerTest.java b/src/test/java/org/jabref/http/server/ServerTest.java new file mode 100644 index 00000000000..1736d7f248f --- /dev/null +++ b/src/test/java/org/jabref/http/server/ServerTest.java @@ -0,0 +1,99 @@ +package org.jabref.http.server; + +import java.util.EnumSet; +import java.util.List; +import java.util.stream.Collectors; + +import javafx.collections.FXCollections; + +import org.jabref.http.dto.GsonFactory; +import org.jabref.logic.bibtex.FieldContentFormatterPreferences; +import org.jabref.logic.bibtex.FieldWriterPreferences; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.preferences.BibEntryPreferences; +import org.jabref.preferences.GuiPreferences; +import org.jabref.preferences.PreferencesService; + +import com.google.gson.Gson; +import org.glassfish.hk2.utilities.binding.AbstractBinder; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.test.JerseyTest; +import org.junit.jupiter.api.BeforeAll; +import org.slf4j.bridge.SLF4JBridgeHandler; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Abstract test class to + *
    + *
  • Initialize the JCL to SLF4J bridge
  • + *
  • Provide injection capabilities of JabRef's preferences and Gson<./li> + *
+ *

More information on testing with Jersey is available at the Jersey's testing documentation

. + */ +abstract class ServerTest extends JerseyTest { + + private static PreferencesService preferencesService; + private static GuiPreferences guiPreferences; + + @BeforeAll + static void installLoggingBridge() { + // Grizzly uses java.commons.logging, but we use TinyLog + SLF4JBridgeHandler.removeHandlersForRootLogger(); + SLF4JBridgeHandler.install(); + + initializePreferencesService(); + } + + protected void addGsonToResourceConfig(ResourceConfig resourceConfig) { + resourceConfig.register(new AbstractBinder() { + @Override + protected void configure() { + bind(new GsonFactory().provide()).to(Gson.class).ranked(2); + } + }); + } + + protected void addPreferencesToResourceConfig(ResourceConfig resourceConfig) { + resourceConfig.register(new AbstractBinder() { + @Override + protected void configure() { + bind(preferencesService).to(PreferencesService.class).ranked(2); + } + }); + } + + protected void setAvailableLibraries(EnumSet files) { + when(guiPreferences.getLastFilesOpened()).thenReturn( + FXCollections.observableArrayList( + files.stream() + .map(file -> file.path.toString()) + .collect(Collectors.toList()))); + } + + private static void initializePreferencesService() { + preferencesService = mock(PreferencesService.class); + + ImportFormatPreferences importFormatPreferences = mock(ImportFormatPreferences.class); + when(preferencesService.getImportFormatPreferences()).thenReturn(importFormatPreferences); + + BibEntryPreferences bibEntryPreferences = mock(BibEntryPreferences.class); + when(importFormatPreferences.bibEntryPreferences()).thenReturn(bibEntryPreferences); + when(bibEntryPreferences.getKeywordSeparator()).thenReturn(','); + + when(preferencesService.getFieldWriterPreferences()).thenReturn(ServerPreferences.fieldWriterPreferences()); + + // defaults are in {@link org.jabref.preferences.JabRefPreferences.NON_WRAPPABLE_FIELDS} + FieldContentFormatterPreferences fieldContentFormatterPreferences = new FieldContentFormatterPreferences(List.of()); + // used twice, once for reading and once for writing + when(importFormatPreferences.fieldContentFormatterPreferences()).thenReturn(fieldContentFormatterPreferences); + // for writing, we use the "real" Server Preferences + // when(preferencesService.getFieldWriterPreferences().getFieldContentFormatterPreferences()).thenReturn(fieldContentFormatterPreferences); + + guiPreferences = mock(GuiPreferences.class); + when(preferencesService.getGuiPreferences()).thenReturn(guiPreferences); + + when(guiPreferences.getLastFilesOpened()).thenReturn(FXCollections.observableArrayList(TestBibFile.GENERAL_SERVER_TEST.path.toString())); + } +} diff --git a/src/test/java/org/jabref/http/server/TestBibFile.java b/src/test/java/org/jabref/http/server/TestBibFile.java new file mode 100644 index 00000000000..4897defde32 --- /dev/null +++ b/src/test/java/org/jabref/http/server/TestBibFile.java @@ -0,0 +1,18 @@ +package org.jabref.http.server; + +import java.nio.file.Path; + +import org.jabref.logic.util.io.BackupFileUtil; + +public enum TestBibFile { + GENERAL_SERVER_TEST("src/test/resources/org/jabref/http/server/general-server-test.bib"), + JABREF_AUTHORS("src/test/resources/testbib/jabref-authors.bib"); + + public final Path path; + public final String id; + + TestBibFile(String locationInSource) { + this.path = Path.of(locationInSource).toAbsolutePath(); + this.id = path.getFileName() + "-" + BackupFileUtil.getUniqueFilePrefix(path); + } +} diff --git a/src/test/java/org/jabref/http/server/UpdatesResourceTest.java b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java new file mode 100644 index 00000000000..18a2cbaea92 --- /dev/null +++ b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java @@ -0,0 +1,75 @@ +package org.jabref.http.server; + +import org.glassfish.jersey.server.ResourceConfig; +import org.jabref.http.sync.state.SyncState; +import org.jabref.model.database.BibDatabase; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.metadata.MetaData; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class UpdatesResourceTest extends ServerTest { + + private final String path = "/li