From 7c1df88f789051e85c156e161647ca60f36e604c Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Sun, 9 Apr 2023 00:28:47 +0200 Subject: [PATCH 01/10] Introduces test http server - Resources - /libraries - /libraries/{id} (both (embedded) BibTeX as string and CSL data) - Documentation / code style - Refined BibEntry class (JavaDoc, builder) - Comments to InternalField - move StyleTester to "test" module - package ...testutils/interactive... - Makes use of Jersey, Grizzly - Makes use of HK2 as dependency injection framework - Introduces "application/x-bibtex-library-csl+json" mimetype - Preparation for client/server sync (BibEntryDTO) - Minor - Made class "JabRefItemDataProvider" more visible - Encoding of a .bib file can now be asked for externally - Resorts modle-info.java - Fixes typo in NetworkTabViewModel - Installs SLF4J logging router: If a tool uses java commons logging, tinylog now also handles these logs --- .gitattributes | 2 + build.gradle | 20 ++ .../0027-http-return-bibtex-string.md | 53 +++++ src/main/java/module-info.java | 72 +++--- src/main/java/org/jabref/cli/Launcher.java | 21 +- .../network/NetworkTabViewModel.java | 2 +- src/main/java/org/jabref/http/MediaType.java | 6 + .../java/org/jabref/http/dto/BibEntryDTO.java | 71 ++++++ .../java/org/jabref/http/dto/GsonFactory.java | 18 ++ .../org/jabref/http/server/Application.java | 32 +++ .../org/jabref/http/server/CORSFilter.java | 23 ++ .../jabref/http/server/LibrariesResource.java | 29 +++ .../jabref/http/server/LibraryResource.java | 101 +++++++++ .../org/jabref/http/server/RootResource.java | 22 ++ .../java/org/jabref/http/server/Server.java | 65 ++++++ .../logic/citationstyle/CSLAdapter.java | 177 --------------- .../citationstyle/JabRefItemDataProvider.java | 212 ++++++++++++++++++ .../importer/fileformat/BibtexImporter.java | 53 +++-- .../importer/fileformat/BibtexParser.java | 1 - .../jabref/logic/net/ssl/SSLCertificate.java | 3 +- .../logic/net/ssl/TrustStoreManager.java | 2 +- .../java/org/jabref/model/entry/BibEntry.java | 77 ++++++- .../model/entry/SharedBibEntryData.java | 23 +- .../model/entry/field/InternalField.java | 41 +++- .../jabref/preferences/JabRefPreferences.java | 4 + .../preferences/PreferenceServiceFactory.java | 14 ++ .../preferences/PreferencesService.java | 2 + .../jabref/gui/edit/CopyMoreActionTest.java | 6 +- .../http/server/LibrariesResourceTest.java | 33 +++ .../http/server/LibraryResourceTest.java | 38 ++++ .../org/jabref/http/server/ServerTest.java | 92 ++++++++ .../org/jabref/http/server/TestBibFile.java | 18 ++ .../org/jabref/http/server/TestServer.java | 14 ++ .../jabref/http/server/mwessl/GServer.java | 66 ++++++ .../http/server/mwessl/SimpleHttpHandler.java | 16 ++ .../http/server/mwessl/package-info.java | 8 + .../JabRefItemDataProviderTest.java | 49 ++++ .../testutils/interactive/http/rest-api.http | 35 +++ .../interactive}/styletester/StyleTester.fxml | 2 +- .../styletester/StyleTesterMain.java | 2 +- .../styletester/StyleTesterView.java | 2 +- .../http/server/general-server-test.bib | 7 + 42 files changed, 1284 insertions(+), 250 deletions(-) create mode 100644 docs/decisions/0027-http-return-bibtex-string.md create mode 100644 src/main/java/org/jabref/http/MediaType.java create mode 100644 src/main/java/org/jabref/http/dto/BibEntryDTO.java create mode 100644 src/main/java/org/jabref/http/dto/GsonFactory.java create mode 100644 src/main/java/org/jabref/http/server/Application.java create mode 100644 src/main/java/org/jabref/http/server/CORSFilter.java create mode 100644 src/main/java/org/jabref/http/server/LibrariesResource.java create mode 100644 src/main/java/org/jabref/http/server/LibraryResource.java create mode 100644 src/main/java/org/jabref/http/server/RootResource.java create mode 100644 src/main/java/org/jabref/http/server/Server.java create mode 100644 src/main/java/org/jabref/logic/citationstyle/JabRefItemDataProvider.java create mode 100644 src/main/java/org/jabref/preferences/PreferenceServiceFactory.java create mode 100644 src/test/java/org/jabref/http/server/LibrariesResourceTest.java create mode 100644 src/test/java/org/jabref/http/server/LibraryResourceTest.java create mode 100644 src/test/java/org/jabref/http/server/ServerTest.java create mode 100644 src/test/java/org/jabref/http/server/TestBibFile.java create mode 100644 src/test/java/org/jabref/http/server/TestServer.java create mode 100644 src/test/java/org/jabref/http/server/mwessl/GServer.java create mode 100644 src/test/java/org/jabref/http/server/mwessl/SimpleHttpHandler.java create mode 100644 src/test/java/org/jabref/http/server/mwessl/package-info.java create mode 100644 src/test/java/org/jabref/logic/citationstyle/JabRefItemDataProviderTest.java create mode 100644 src/test/java/org/jabref/testutils/interactive/http/rest-api.http rename src/{main/java/org/jabref => test/java/org/jabref/testutils/interactive}/styletester/StyleTester.fxml (99%) rename src/{main/java/org/jabref => test/java/org/jabref/testutils/interactive}/styletester/StyleTesterMain.java (96%) rename src/{main/java/org/jabref => test/java/org/jabref/testutils/interactive}/styletester/StyleTesterView.java (96%) create mode 100644 src/test/resources/org/jabref/http/server/general-server-test.bib 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/build.gradle b/build.gradle index 9d8f1e7bff2..bbc02475859 100644 --- a/build.gradle +++ b/build.gradle @@ -179,6 +179,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' @@ -199,6 +201,24 @@ 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' + // Allow objects "magically" to be mapped to JSON using GSON + // implementation 'org.glassfish.jersey.media:jersey-media-json-gson:3.1.1' + 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' diff --git a/docs/decisions/0027-http-return-bibtex-string.md b/docs/decisions/0027-http-return-bibtex-string.md new file mode 100644 index 00000000000..29658c423a3 --- /dev/null +++ b/docs/decisions/0027-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/src/main/java/module-info.java b/src/main/java/module-info.java index 475aa85f61d..fd13ffaaf17 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -15,6 +15,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; @@ -38,6 +41,7 @@ // Logging requires org.slf4j; + requires jul.to.slf4j; requires org.tinylog.api; requires org.tinylog.api.slf4j; requires org.tinylog.impl; @@ -46,47 +50,58 @@ 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; + + // 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; @@ -95,21 +110,16 @@ requires flexmark; requires flexmark.util.ast; requires flexmark.util.data; - requires com.h2database.mvstore; // fulltext search requires org.apache.lucene.core; // In case the version is updated, please also adapt SearchFieldConstants#VERSION to the newly used version uses org.apache.lucene.codecs.lucene94.Lucene94Codec; - requires org.apache.lucene.queryparser; uses org.apache.lucene.queryparser.classic.MultiFieldQueryParser; 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; @@ -117,4 +127,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 bc9dd85d26e..0a87bfe5178 100644 --- a/src/main/java/org/jabref/cli/Launcher.java +++ b/src/main/java/org/jabref/cli/Launcher.java @@ -33,6 +33,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; /** @@ -47,6 +48,7 @@ public class Launcher { private static String[] ARGUMENTS; public static void main(String[] args) { + routeLoggingToSlf4J(); ARGUMENTS = args; addLogToDisk(); try { @@ -85,6 +87,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 @@ -93,10 +100,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) { @@ -187,9 +194,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/gui/preferences/network/NetworkTabViewModel.java b/src/main/java/org/jabref/gui/preferences/network/NetworkTabViewModel.java index 8af629de4cf..9e74c55722b 100644 --- a/src/main/java/org/jabref/gui/preferences/network/NetworkTabViewModel.java +++ b/src/main/java/org/jabref/gui/preferences/network/NetworkTabViewModel.java @@ -376,7 +376,7 @@ public void addCertificateFile() { .build(); dialogService.showFileOpenDialog(fileDialogConfiguration).ifPresent(certPath -> SSLCertificate.fromPath(certPath).ifPresent(sslCertificate -> { - if (!trustStoreManager.isCertificateExist(formatCustomAlias(sslCertificate.getSHA256Thumbprint()))) { + if (!trustStoreManager.certificateExists(formatCustomAlias(sslCertificate.getSHA256Thumbprint()))) { customCertificateListProperty.add(CustomCertificateViewModel.fromSSLCertificate(sslCertificate) .setPath(certPath.toAbsolutePath().toString())); } else { 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/dto/BibEntryDTO.java b/src/main/java/org/jabref/http/dto/BibEntryDTO.java new file mode 100644 index 00000000000..a0d20d4353e --- /dev/null +++ b/src/main/java/org/jabref/http/dto/BibEntryDTO.java @@ -0,0 +1,71 @@ +package org.jabref.http.dto; + +import java.io.IOException; +import java.io.StringWriter; + +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) + ); + } + + private 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..00567335edb --- /dev/null +++ b/src/main/java/org/jabref/http/server/Application.java @@ -0,0 +1,32 @@ +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; + +@ApplicationPath("/") +public class Application extends jakarta.ws.rs.core.Application { + + @Inject + ServiceLocator serviceLocator; + + @Override + public Set> getClasses() { + initialize(); + return Set.of(RootResource.class, LibrariesResource.class, LibraryResource.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..9e10afa7fa1 --- /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(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(), preferences.getFieldWriterPreferences(), Globals.entryTypesManager)) + .toList(); + return gson.toJson(list); + } + + @GET + @Produces(org.jabref.http.MediaType.JSON_CSL_ITEM) + public String getClsItemJson(@PathParam("id") String id) { + ParserResult parserResult = getParserResult(id); + JabRefItemDataProvider jabRefItemDataProvider = new JabRefItemDataProvider(); + jabRefItemDataProvider.setData(parserResult.getDatabaseContext(), new BibEntryTypesManager()); + return jabRefItemDataProvider.toJson(); + } + + private ParserResult getParserResult(String id) { + java.nio.file.Path library = getLibraryPath(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(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 java.nio.file.Path getLibraryPath(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..c55f2583db3 --- /dev/null +++ b/src/main/java/org/jabref/http/server/RootResource.java @@ -0,0 +1,22 @@ +package org.jabref.http.server; + +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. +

+ +"""; + } +} 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..92ed926ba6e --- /dev/null +++ b/src/main/java/org/jabref/http/server/Server.java @@ -0,0 +1,65 @@ +package org.jabref.http.server; + +import java.net.URI; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; + +import javax.net.ssl.SSLContext; + +import org.jabref.logic.util.OS; + +import jakarta.ws.rs.SeBootstrap; +import net.harawata.appdirs.AppDirsFactory; +import org.glassfish.grizzly.ssl.SSLContextConfigurator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Server { + private static final Logger LOGGER = LoggerFactory.getLogger(Server.class); + + private static SeBootstrap.Instance serverInstance; + + static void startServer(CountDownLatch latch) { + SSLContext sslContext = getSslContext(); + SeBootstrap.Configuration configuration = SeBootstrap.Configuration + .builder() + .sslContext(sslContext) + .protocol("HTTPS") + .port(6051) + .build(); + SeBootstrap.start(Application.class, configuration).thenAccept(instance -> { + instance.stopOnShutdown(stopResult -> + System.out.printf("Stop result: %s [Native stop result: %s].%n", stopResult, + stopResult.unwrap(Object.class))); + final URI uri = instance.configuration().baseUri(); + System.out.printf("Instance %s running at %s [Native handle: %s].%n", instance, uri, + instance.unwrap(Object.class)); + System.out.println("Send SIGKILL to shutdown."); + serverInstance = instance; + latch.countDown(); + }); + } + + private static SSLContext getSslContext() { + SSLContextConfigurator sslContextConfig = new SSLContextConfigurator(); + + // "server.jks" Needs to be generated using following command inside that directory: + // keytool -genkey -keyalg RSA -alias selfsigned -keystore server.jks -storepass changeit -validity 365 -keysize 2048 -dname "CN=localhost, OU=YourOrganizationUnit, O=YourOrganization, L=YourCity, S=YourState, C=YourCountry" + + String keystorePath = Path.of(AppDirsFactory.getInstance() + .getUserDataDir( + OS.APP_DIR_APP_NAME, + "ssl", + OS.APP_DIR_APP_AUTHOR)) + .resolve("server.p12").toString(); + + sslContextConfig.setKeyStoreFile(keystorePath); + sslContextConfig.setKeyStorePass("changeit"); + + return sslContextConfig.createSSLContext(); + } + + static void stopServer() { + serverInstance.stop(); + } +} diff --git a/src/main/java/org/jabref/logic/citationstyle/CSLAdapter.java b/src/main/java/org/jabref/logic/citationstyle/CSLAdapter.java index 6bdd2149b52..215c9a14719 100644 --- a/src/main/java/org/jabref/logic/citationstyle/CSLAdapter.java +++ b/src/main/java/org/jabref/logic/citationstyle/CSLAdapter.java @@ -1,37 +1,17 @@ package org.jabref.logic.citationstyle; import java.io.IOException; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.List; -import java.util.Locale; import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import org.jabref.logic.formatter.bibtexfields.RemoveNewlinesFormatter; -import org.jabref.logic.integrity.PagesChecker; import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.database.BibDatabaseMode; import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.BibEntryType; import org.jabref.model.entry.BibEntryTypesManager; -import org.jabref.model.entry.Month; -import org.jabref.model.entry.field.Field; -import org.jabref.model.entry.field.StandardField; -import org.jabref.model.entry.types.StandardEntryType; -import org.jabref.model.strings.LatexToUnicodeAdapter; import de.undercouch.citeproc.CSL; import de.undercouch.citeproc.DefaultAbbreviationProvider; -import de.undercouch.citeproc.ItemDataProvider; -import de.undercouch.citeproc.bibtex.BibTeXConverter; -import de.undercouch.citeproc.csl.CSLItemData; import de.undercouch.citeproc.output.Bibliography; -import org.jbibtex.BibTeXEntry; -import org.jbibtex.DigitStringValue; -import org.jbibtex.Key; /** * Provides an adapter class to CSL. It holds a CSL instance under the hood that is only recreated when @@ -49,7 +29,6 @@ */ public class CSLAdapter { - private static final BibTeXConverter BIBTEX_CONVERTER = new BibTeXConverter(); private final JabRefItemDataProvider dataProvider = new JabRefItemDataProvider(); private String style; private CitationStyleOutputFormat format; @@ -90,160 +69,4 @@ private void initialize(String newStyle, CitationStyleOutputFormat newFormat) th format = newFormat; } } - - /** - * Custom ItemDataProvider that allows to set the data so that we don't have to instantiate a new CSL object - * every time. - */ - private static class JabRefItemDataProvider implements ItemDataProvider { - - private final List data = new ArrayList<>(); - private BibDatabaseContext bibDatabaseContext; - private BibEntryTypesManager entryTypesManager; - private PagesChecker pagesChecker; - - /** - * Converts the {@link BibEntry} into {@link CSLItemData}. - * - *
- * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
BibTeXBibLaTeXEntryPreview/CSLproposed logic, conditions and info
volumevolumevolume
numberissueissueFor conversion to CSL or BibTeX: BibLaTeX number takes priority and supersedes BibLaTeX issue
numbernumberissuesame as above
pageseidnumberSome journals put the article-number (= eid) into the pages field. If BibLaTeX eid exists, provide csl number to the style. If pages exists, provide csl page. If eid WITHIN the pages field exists, detect the eid and provide csl number. If both eid and pages exists, ideally provide both csl number and csl page. Ideally the citationstyle should be able to flexibly choose the rendering.
pagespagespagesame as above
- */ - private CSLItemData bibEntryToCSLItemData(BibEntry originalBibEntry, BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager) { - // We need to make a deep copy, because we modify the entry according to the logic presented at - // https://github.com/JabRef/jabref/issues/8372#issuecomment-1014941935 - BibEntry bibEntry = (BibEntry) originalBibEntry.clone(); - String citeKey = bibEntry.getCitationKey().orElse(""); - BibTeXEntry bibTeXEntry = new BibTeXEntry(new Key(bibEntry.getType().getName()), new Key(citeKey)); - - // Not every field is already generated into latex free fields - RemoveNewlinesFormatter removeNewlinesFormatter = new RemoveNewlinesFormatter(); - - Optional entryType = entryTypesManager.enrich(bibEntry.getType(), bibDatabaseContext.getMode()); - - if (bibEntry.getType().equals(StandardEntryType.Article)) { - // Patch bibEntry to contain the right BibTeX (not BibLaTeX) fields - // Note that we do not need to convert from "pages" to "page", because CiteProc already handles it - // See BibTeXConverter - if (bibDatabaseContext.isBiblatexMode()) { - // Map "number" to CSL "issue", unless no number exists - Optional numberField = bibEntry.getField(StandardField.NUMBER); - numberField.ifPresent(number -> { - bibEntry.setField(StandardField.ISSUE, number); - bibEntry.clearField(StandardField.NUMBER); - } - ); - - bibEntry.getField(StandardField.EID).ifPresent(eid -> { - if (!bibEntry.hasField(StandardField.NUMBER)) { - bibEntry.setField(StandardField.NUMBER, eid); - bibEntry.clearField(StandardField.EID); - } - }); - } else { - // BibTeX mode - bibEntry.getField(StandardField.NUMBER).ifPresent(number -> { - bibEntry.setField(StandardField.ISSUE, number); - bibEntry.clearField(StandardField.NUMBER); - }); - bibEntry.getField(StandardField.PAGES).ifPresent(pages -> { - if (pages.toLowerCase(Locale.ROOT).startsWith("article ")) { - pages = pages.substring("Article ".length()); - bibEntry.setField(StandardField.NUMBER, pages); - } - }); - bibEntry.getField(StandardField.EID).ifPresent(eid -> { - if (!bibEntry.hasField(StandardField.PAGES)) { - bibEntry.setField(StandardField.PAGES, eid); - bibEntry.clearField(StandardField.EID); - } - }); - } - } - - Set fields = entryType.map(BibEntryType::getAllFields).orElse(bibEntry.getFields()); - fields.addAll(bibEntry.getFields()); - for (Field key : fields) { - bibEntry.getResolvedFieldOrAlias(key, bibDatabaseContext.getDatabase()) - .map(removeNewlinesFormatter::format) - .map(LatexToUnicodeAdapter::format) - .ifPresent(value -> { - if (StandardField.MONTH.equals(key)) { - // Change month from #mon# to mon because CSL does not support the former format - value = bibEntry.getMonth().map(Month::getShortName).orElse(value); - } - bibTeXEntry.addField(new Key(key.getName()), new DigitStringValue(value)); - }); - } - return BIBTEX_CONVERTER.toItemData(bibTeXEntry); - } - - public void setData(List data, BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager) { - this.data.clear(); - this.data.addAll(data); - this.bibDatabaseContext = bibDatabaseContext; - this.entryTypesManager = entryTypesManager; - - // Quick solution to always use BibLaTeX mode at the checker to allow pages ranges with single dash, too - // Example: pages = {1-2} - BibDatabaseContext ctx = new BibDatabaseContext(); - ctx.setMode(BibDatabaseMode.BIBLATEX); - this.pagesChecker = new PagesChecker(ctx); - } - - @Override - public CSLItemData retrieveItem(String id) { - return data.stream() - .filter(entry -> entry.getCitationKey().orElse("").equals(id)) - .map(entry -> bibEntryToCSLItemData(entry, bibDatabaseContext, entryTypesManager)) - .findFirst().orElse(null); - } - - @Override - public Collection getIds() { - return data.stream() - .map(entry -> entry.getCitationKey().orElse("")) - .toList(); - } - } } diff --git a/src/main/java/org/jabref/logic/citationstyle/JabRefItemDataProvider.java b/src/main/java/org/jabref/logic/citationstyle/JabRefItemDataProvider.java new file mode 100644 index 00000000000..32c3d95ef6e --- /dev/null +++ b/src/main/java/org/jabref/logic/citationstyle/JabRefItemDataProvider.java @@ -0,0 +1,212 @@ +package org.jabref.logic.citationstyle; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jabref.logic.formatter.bibtexfields.RemoveNewlinesFormatter; +import org.jabref.logic.integrity.PagesChecker; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.database.BibDatabaseMode; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.BibEntryType; +import org.jabref.model.entry.BibEntryTypesManager; +import org.jabref.model.entry.Month; +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.entry.types.StandardEntryType; +import org.jabref.model.strings.LatexToUnicodeAdapter; + +import de.undercouch.citeproc.ItemDataProvider; +import de.undercouch.citeproc.bibtex.BibTeXConverter; +import de.undercouch.citeproc.csl.CSLItemData; +import de.undercouch.citeproc.helper.json.StringJsonBuilderFactory; +import org.jbibtex.BibTeXEntry; +import org.jbibtex.DigitStringValue; +import org.jbibtex.Key; + +/** + * Custom {@link ItemDataProvider} that allows to set the data so that we don't have to instantiate a new CSL object + * every time. + */ +public class JabRefItemDataProvider implements ItemDataProvider { + + private static final BibTeXConverter BIBTEX_CONVERTER = new BibTeXConverter(); + + private final StringJsonBuilderFactory stringJsonBuilderFactory; + + private final List data = new ArrayList<>(); + + private BibDatabaseContext bibDatabaseContext; + private BibEntryTypesManager entryTypesManager; + private PagesChecker pagesChecker; + + public JabRefItemDataProvider() { + stringJsonBuilderFactory = new StringJsonBuilderFactory(); + } + + /** + * Converts the {@link BibEntry} into {@link CSLItemData}. + * + *
+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
BibTeXBibLaTeXEntryPreview/CSLproposed logic, conditions and info
volumevolumevolume
numberissueissueFor conversion to CSL or BibTeX: BibLaTeX number takes priority and supersedes BibLaTeX issue
numbernumberissuesame as above
pageseidnumberSome journals put the article-number (= eid) into the pages field. If BibLaTeX eid exists, provide csl number to the style. If pages exists, provide csl page. If eid WITHIN the pages field exists, detect the eid and provide csl number. If both eid and pages exists, ideally provide both csl number and csl page. Ideally the citationstyle should be able to flexibly choose the rendering.
pagespagespagesame as above
+ */ + private CSLItemData bibEntryToCSLItemData(BibEntry originalBibEntry, BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager) { + // We need to make a deep copy, because we modify the entry according to the logic presented at + // https://github.com/JabRef/jabref/issues/8372#issuecomment-1014941935 + BibEntry bibEntry = (BibEntry) originalBibEntry.clone(); + String citeKey = bibEntry.getCitationKey().orElse(""); + BibTeXEntry bibTeXEntry = new BibTeXEntry(new Key(bibEntry.getType().getName()), new Key(citeKey)); + + // Not every field is already generated into latex free fields + RemoveNewlinesFormatter removeNewlinesFormatter = new RemoveNewlinesFormatter(); + + Optional entryType = entryTypesManager.enrich(bibEntry.getType(), bibDatabaseContext.getMode()); + + if (bibEntry.getType().equals(StandardEntryType.Article)) { + // Patch bibEntry to contain the right BibTeX (not BibLaTeX) fields + // Note that we do not need to convert from "pages" to "page", because CiteProc already handles it + // See BibTeXConverter + if (bibDatabaseContext.isBiblatexMode()) { + // Map "number" to CSL "issue", unless no number exists + Optional numberField = bibEntry.getField(StandardField.NUMBER); + numberField.ifPresent(number -> { + bibEntry.setField(StandardField.ISSUE, number); + bibEntry.clearField(StandardField.NUMBER); + } + ); + + bibEntry.getField(StandardField.EID).ifPresent(eid -> { + if (!bibEntry.hasField(StandardField.NUMBER)) { + bibEntry.setField(StandardField.NUMBER, eid); + bibEntry.clearField(StandardField.EID); + } + }); + } else { + // BibTeX mode + bibEntry.getField(StandardField.NUMBER).ifPresent(number -> { + bibEntry.setField(StandardField.ISSUE, number); + bibEntry.clearField(StandardField.NUMBER); + }); + bibEntry.getField(StandardField.PAGES).ifPresent(pages -> { + if (pages.toLowerCase(Locale.ROOT).startsWith("article ")) { + pages = pages.substring("Article ".length()); + bibEntry.setField(StandardField.NUMBER, pages); + } + }); + bibEntry.getField(StandardField.EID).ifPresent(eid -> { + if (!bibEntry.hasField(StandardField.PAGES)) { + bibEntry.setField(StandardField.PAGES, eid); + bibEntry.clearField(StandardField.EID); + } + }); + } + } + + Set fields = entryType.map(BibEntryType::getAllFields).orElse(bibEntry.getFields()); + fields.addAll(bibEntry.getFields()); + for (Field key : fields) { + bibEntry.getResolvedFieldOrAlias(key, bibDatabaseContext.getDatabase()) + .map(removeNewlinesFormatter::format) + .map(LatexToUnicodeAdapter::format) + .ifPresent(value -> { + if (StandardField.MONTH.equals(key)) { + // Change month from #mon# to mon because CSL does not support the former format + value = bibEntry.getMonth().map(Month::getShortName).orElse(value); + } + bibTeXEntry.addField(new Key(key.getName()), new DigitStringValue(value)); + }); + } + return BIBTEX_CONVERTER.toItemData(bibTeXEntry); + } + + /** + * Fills the data with all entries in given bibDatabaseContext + */ + public void setData(BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager) { + this.setData(bibDatabaseContext.getEntries(), bibDatabaseContext, entryTypesManager); + } + + public void setData(List data, BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager) { + this.data.clear(); + this.data.addAll(data); + this.bibDatabaseContext = bibDatabaseContext; + this.entryTypesManager = entryTypesManager; + + // Quick solution to always use BibLaTeX mode at the checker to allow pages ranges with single dash, too + // Example: pages = {1-2} + BibDatabaseContext ctx = new BibDatabaseContext(); + ctx.setMode(BibDatabaseMode.BIBLATEX); + this.pagesChecker = new PagesChecker(ctx); + } + + public String toJson() { + List entries = bibDatabaseContext.getEntries(); + this.setData(entries, bibDatabaseContext, entryTypesManager); + return entries.stream() + .map(entry -> bibEntryToCSLItemData(entry, bibDatabaseContext, entryTypesManager)) + .map(item -> item.toJson(stringJsonBuilderFactory.createJsonBuilder())) + .map(item -> (String) item) + .collect(Collectors.joining(",", "[", "]")); + } + + @Override + public CSLItemData retrieveItem(String id) { + return data.stream() + .filter(entry -> entry.getCitationKey().orElse("").equals(id)) + .map(entry -> bibEntryToCSLItemData(entry, bibDatabaseContext, entryTypesManager)) + .findFirst().orElse(null); + } + + @Override + public Collection getIds() { + return data.stream() + .map(entry -> entry.getCitationKey().orElse("")) + .toList(); + } +} diff --git a/src/main/java/org/jabref/logic/importer/fileformat/BibtexImporter.java b/src/main/java/org/jabref/logic/importer/fileformat/BibtexImporter.java index 1b67e2df572..500a14a69c9 100644 --- a/src/main/java/org/jabref/logic/importer/fileformat/BibtexImporter.java +++ b/src/main/java/org/jabref/logic/importer/fileformat/BibtexImporter.java @@ -54,6 +54,36 @@ public boolean isRecognizedFormat(BufferedReader reader) { @Override public ParserResult importDatabase(Path filePath) throws IOException { + EncodingResult result = getEncodingResult(filePath); + + // We replace unreadable characters + // Unfortunately, no warning will be issued to the user + // As this is a very seldom case, we accept that + CharsetDecoder decoder = result.encoding().newDecoder(); + decoder.onMalformedInput(CodingErrorAction.REPLACE); + + try (InputStreamReader inputStreamReader = new InputStreamReader(Files.newInputStream(filePath), decoder); + BufferedReader reader = new BufferedReader(inputStreamReader)) { + ParserResult parserResult = this.importDatabase(reader); + parserResult.getMetaData().setEncoding(result.encoding()); + parserResult.getMetaData().setEncodingExplicitlySupplied(result.encodingExplicitlySupplied()); + parserResult.setPath(filePath); + if (parserResult.getMetaData().getMode().isEmpty()) { + parserResult.getMetaData().setMode(BibDatabaseModeDetection.inferMode(parserResult.getDatabase())); + } + return parserResult; + } + } + + public static Charset getEncoding(Path filePath) throws IOException { + return getEncodingResult(filePath).encoding(); + } + + /** + * Determines the encoding of the supplied BibTeX file. If a JabRef encoding information is present, this information is used. + * If there is none present, {@link com.ibm.icu.text.CharsetDetector#CharsetDetector()} is used. + */ + private static EncodingResult getEncodingResult(Path filePath) throws IOException { // We want to check if there is a JabRef encoding heading in the file, because that would tell us // which character encoding is used. @@ -81,29 +111,16 @@ public ParserResult importDatabase(Path filePath) throws IOException { encoding = suppliedEncoding.orElse(detectedCharset); LOGGER.debug("Encoding used to read the file: {}", encoding); } + EncodingResult result = new EncodingResult(encoding, encodingExplicitlySupplied); + return result; + } - // We replace unreadable characters - // Unfortunately, no warning will be issued to the user - // As this is a very seldom case, we accept that - CharsetDecoder decoder = encoding.newDecoder(); - decoder.onMalformedInput(CodingErrorAction.REPLACE); - - try (InputStreamReader inputStreamReader = new InputStreamReader(Files.newInputStream(filePath), decoder); - BufferedReader reader = new BufferedReader(inputStreamReader)) { - ParserResult parserResult = this.importDatabase(reader); - parserResult.getMetaData().setEncoding(encoding); - parserResult.getMetaData().setEncodingExplicitlySupplied(encodingExplicitlySupplied); - parserResult.setPath(filePath); - if (parserResult.getMetaData().getMode().isEmpty()) { - parserResult.getMetaData().setMode(BibDatabaseModeDetection.inferMode(parserResult.getDatabase())); - } - return parserResult; - } + private record EncodingResult(Charset encoding, boolean encodingExplicitlySupplied) { } /** * This method does not set the metadata encoding information. The caller needs to set the encoding of the supplied - * reader manually to the meta data + * reader manually to the metadata */ @Override public ParserResult importDatabase(BufferedReader reader) throws IOException { diff --git a/src/main/java/org/jabref/logic/importer/fileformat/BibtexParser.java b/src/main/java/org/jabref/logic/importer/fileformat/BibtexParser.java index 5d24472dba0..3be461ab02c 100644 --- a/src/main/java/org/jabref/logic/importer/fileformat/BibtexParser.java +++ b/src/main/java/org/jabref/logic/importer/fileformat/BibtexParser.java @@ -226,7 +226,6 @@ private ParserResult parseFileContent() throws IOException { skipWhitespace(); } - // Instantiate meta data try { parserResult.setMetaData(metaDataParser.parse(meta, importFormatPreferences.bibEntryPreferences().getKeywordSeparator())); } catch (ParseException exception) { diff --git a/src/main/java/org/jabref/logic/net/ssl/SSLCertificate.java b/src/main/java/org/jabref/logic/net/ssl/SSLCertificate.java index deeca70f806..d2229b87850 100644 --- a/src/main/java/org/jabref/logic/net/ssl/SSLCertificate.java +++ b/src/main/java/org/jabref/logic/net/ssl/SSLCertificate.java @@ -63,8 +63,7 @@ public Integer getVersion() { /** * @return the SHA-256 of the DER encoding - * - * */ + */ public String getSHA256Thumbprint() { return sha256Thumbprint; } diff --git a/src/main/java/org/jabref/logic/net/ssl/TrustStoreManager.java b/src/main/java/org/jabref/logic/net/ssl/TrustStoreManager.java index e5faac98bf2..70c0bed0659 100644 --- a/src/main/java/org/jabref/logic/net/ssl/TrustStoreManager.java +++ b/src/main/java/org/jabref/logic/net/ssl/TrustStoreManager.java @@ -61,7 +61,7 @@ public void deleteCertificate(String alias) { } } - public boolean isCertificateExist(String alias) { + public boolean certificateExists(String alias) { Objects.requireNonNull(alias); try { return store.isCertificateEntry(alias); diff --git a/src/main/java/org/jabref/model/entry/BibEntry.java b/src/main/java/org/jabref/model/entry/BibEntry.java index 38e37c40db7..3ec0822ec3e 100644 --- a/src/main/java/org/jabref/model/entry/BibEntry.java +++ b/src/main/java/org/jabref/model/entry/BibEntry.java @@ -50,9 +50,45 @@ import org.slf4j.LoggerFactory; /** - * Represents a BibTex / BibLaTeX entry. + * Represents a Bib(La)TeX entry, which can be BibTeX or BibLaTeX. + *

+ * Example: + * + *

{@code
+ * Some commment
+ * @misc{key,
+ *   fieldName = {fieldValue},
+ *   otherFieldName = {otherVieldValue}
+ * }
+ *     }
+ * + * Then, + *
    + *
  • "Some comment" is the comment before the entry,
  • + *
  • "misc" is the entry type
  • + *
  • "key" the citation key
  • + *
  • "fieldName" and "otherFieldName" the fields of the BibEntry
  • + *
+ *

+ *

+ * A BibTeX entry has following properties: + *

    + *
  • comments before entry
  • + *
  • entry type
  • + *
  • citation key
  • + *
  • fields
  • + *
+ * In JabRef, this is modeled the following way: + *
    + *
  • comments before entry --> {@link BibEntry#commentsBeforeEntry}
  • + *
  • entry type --> {@link BibEntry#type}
  • + *
  • citation key --> contained in {@link BibEntry#fields} using they hashmap key {@link InternalField#KEY_FIELD}
  • + *
  • fields --> contained in {@link BibEntry#fields}
  • + *
+ *

*

* In case you search for a builder as described in Item 2 of the book "Effective Java", you won't find one. Please use the methods {@link #withCitationKey(String)} and {@link #withField(Field, String)}. + *

*/ @AllowedToUseLogic("because it needs access to parser and writers") public class BibEntry implements Cloneable { @@ -814,6 +850,17 @@ public SharedBibEntryData getSharedBibEntryData() { return sharedBibEntryData; } + public BibEntry withSharedBibEntryData(int sharedId, int version) { + sharedBibEntryData.setSharedID(sharedId); + sharedBibEntryData.setVersion(version); + return this; + } + + public BibEntry withSharedBibEntryData(SharedBibEntryData sharedBibEntryData) { + sharedBibEntryData = sharedBibEntryData; + return this; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -828,9 +875,20 @@ public boolean equals(Object o) { && Objects.equals(commentsBeforeEntry, entry.commentsBeforeEntry); } + /** + * On purpose, this hashes the "content" of the BibEntry, not the {@link #sharedBibEntryData}. + * + * The content is + * + *
    + *
  • comments before entry
  • + *
  • entry type
  • + *
  • fields (including the citation key {@link InternalField#KEY_FIELD}
  • + *
+ */ @Override public int hashCode() { - return Objects.hash(type.getValue(), fields); + return Objects.hash(commentsBeforeEntry, type.getValue(), fields); } public void registerListener(Object object) { @@ -852,6 +910,15 @@ public BibEntry withField(Field field, String value) { return this; } + /** + * A copy is made of the parameter + */ + public BibEntry withFields(Map content) { + this.fields = FXCollections.observableMap(new HashMap<>(content)); + return this; + } + + public BibEntry withDate(Date date) { setDate(date); this.setChanged(false); @@ -871,6 +938,11 @@ public String getUserComments() { return commentsBeforeEntry; } + public BibEntry withUserComments(String commentsBeforeEntry) { + this.commentsBeforeEntry = commentsBeforeEntry; + return this; + } + public List getEntryLinkList(Field field, BibDatabase database) { return getField(field).map(fieldValue -> EntryLinkList.parse(fieldValue, database)) .orElse(Collections.emptyList()); @@ -1086,4 +1158,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 c323a9bc3fe..69c3c6bf976 100644 --- a/src/main/java/org/jabref/model/entry/SharedBibEntryData.java +++ b/src/main/java/org/jabref/model/entry/SharedBibEntryData.java @@ -1,16 +1,20 @@ package org.jabref.model.entry; +import com.google.common.base.MoreObjects; + /** * Stores all information needed to manage entries on a shared (SQL) database. */ -public class SharedBibEntryData { +public class SharedBibEntryData implements Comparable { // This id is set by the remote database system (DBS). // 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 private int sharedID; // Needed for version controlling if used on shared database + // This is "Revision" in JabDrive sync private int version; public SharedBibEntryData() { @@ -33,4 +37,21 @@ public int getVersion() { public void setVersion(int version) { this.version = version; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("sharedID", sharedID) + .add("version", version) + .toString(); + } + + @Override + public int compareTo(SharedBibEntryData o) { + if (this.sharedID == o.sharedID) { + return Integer.compare(this.version, o.version); + } else { + return Integer.compare(this.sharedID, o.sharedID); + } + } } diff --git a/src/main/java/org/jabref/model/entry/field/InternalField.java b/src/main/java/org/jabref/model/entry/field/InternalField.java index 75b1573fcdb..d284af643c4 100644 --- a/src/main/java/org/jabref/model/entry/field/InternalField.java +++ b/src/main/java/org/jabref/model/entry/field/InternalField.java @@ -6,20 +6,49 @@ import java.util.Set; /** - * JabRef internal fields. These are not normal fields but mostly place holders with special functions. + * JabRef internal fields. These are not normal fields but mostly placeholders with special functions. */ public enum InternalField implements Field { + /** + * The BibTeX key (which is used at \cite{key} in LaTeX + */ KEY_FIELD("citationkey"), + /** * field which indicates the entrytype + * + * Example: @misc{key} */ TYPE_HEADER("entrytype"), + + /** + * Used in old layout files + */ OBSOLETE_TYPE_HEADER("bibtextype"), - MARKED_INTERNAL("__markedentry"), // used in old versions of JabRef. Currently used for conversion only - // all field names starting with "Jabref-internal-" are not appearing in .bib files - BIBTEX_STRING("__string"), // marker that the content is just a BibTeX string - INTERNAL_ALL_FIELD("all"), // virtual field to denote "all fields". Used in the meta data serialiization for save actions. - INTERNAL_ALL_TEXT_FIELDS_FIELD("all-text-fields"), // virtual field to denote "all text fields". Used in the meta data serialiization for save actions. + + /** + * used in old versions of JabRef. Currently used for conversion only + */ + MARKED_INTERNAL("__markedentry"), + + /** + * Marker that the content is just a BibTeX string + */ + BIBTEX_STRING("__string"), + + /** + * virtual field to denote "all fields". Used in the metadata serialization for save actions. + */ + INTERNAL_ALL_FIELD("all"), + + /** + * virtual field to denote "all text fields". Used in the metadata serialization for save actions. + */ + INTERNAL_ALL_TEXT_FIELDS_FIELD("all-text-fields"), + + /** + * all field names starting with "Jabref-internal-" are not appearing in .bib files + */ INTERNAL_ID_FIELD("JabRef-internal-id"); private final String name; diff --git a/src/main/java/org/jabref/preferences/JabRefPreferences.java b/src/main/java/org/jabref/preferences/JabRefPreferences.java index 4b6f3c30fe9..ccf0d3fba60 100644 --- a/src/main/java/org/jabref/preferences/JabRefPreferences.java +++ b/src/main/java/org/jabref/preferences/JabRefPreferences.java @@ -116,7 +116,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; @@ -130,6 +132,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 5018e68d966..fba07fb2006 100644 --- a/src/main/java/org/jabref/preferences/PreferencesService.java +++ b/src/main/java/org/jabref/preferences/PreferencesService.java @@ -42,7 +42,9 @@ import org.jabref.model.entry.BibEntryTypesManager; 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/test/java/org/jabref/gui/edit/CopyMoreActionTest.java b/src/test/java/org/jabref/gui/edit/CopyMoreActionTest.java index 594664f4f47..cd6db4c3716 100644 --- a/src/test/java/org/jabref/gui/edit/CopyMoreActionTest.java +++ b/src/test/java/org/jabref/gui/edit/CopyMoreActionTest.java @@ -38,9 +38,9 @@ public class CopyMoreActionTest { private PreferencesService preferencesService = mock(PreferencesService.class); private StateManager stateManager = mock(StateManager.class); private BibEntry entry; - private List titles = new ArrayList(); - private List keys = new ArrayList(); - private List dois = new ArrayList(); + private List titles = new ArrayList<>(); + private List keys = new ArrayList<>(); + private List dois = new ArrayList<>(); @BeforeEach public void setUp() { 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..9395cc93171 --- /dev/null +++ b/src/test/java/org/jabref/http/server/ServerTest.java @@ -0,0 +1,92 @@ +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 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(','); + + FieldWriterPreferences fieldWriterPreferences = mock(FieldWriterPreferences.class); + when(preferencesService.getFieldWriterPreferences()).thenReturn(fieldWriterPreferences); + when(fieldWriterPreferences.isResolveStrings()).thenReturn(false); + + // 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); + 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/TestServer.java b/src/test/java/org/jabref/http/server/TestServer.java new file mode 100644 index 00000000000..6ad1edcd970 --- /dev/null +++ b/src/test/java/org/jabref/http/server/TestServer.java @@ -0,0 +1,14 @@ +package org.jabref.http.server; + +import java.util.concurrent.CountDownLatch; + +import org.slf4j.bridge.SLF4JBridgeHandler; + +public class TestServer { + public static void main(final String[] args) throws InterruptedException { + SLF4JBridgeHandler.removeHandlersForRootLogger(); + SLF4JBridgeHandler.install(); + Server.startServer(new CountDownLatch(1)); + Thread.currentThread().join(); + } +} diff --git a/src/test/java/org/jabref/http/server/mwessl/GServer.java b/src/test/java/org/jabref/http/server/mwessl/GServer.java new file mode 100644 index 00000000000..c5b028f316a --- /dev/null +++ b/src/test/java/org/jabref/http/server/mwessl/GServer.java @@ -0,0 +1,66 @@ +package org.jabref.http.server.mwessl; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.jabref.http.server.Server; + +import org.glassfish.grizzly.Grizzly; +import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.grizzly.http.server.NetworkListener; +import org.glassfish.grizzly.http.server.ServerConfiguration; +import org.glassfish.grizzly.ssl.SSLContextConfigurator; +import org.glassfish.grizzly.ssl.SSLEngineConfigurator; + +/** + * Secured standalone Java HTTP server. + */ +public class GServer { + private static final Logger LOGGER = Grizzly.logger(Server.class); + + public static void main(String[] args) { + final HttpServer server = new HttpServer(); + final ServerConfiguration config = server.getServerConfiguration(); + + // Register simple HttpHandler + config.addHttpHandler(new SimpleHttpHandler(), "/"); + + // create a network listener that listens on port 8080. + final NetworkListener networkListener = new NetworkListener("secured-listener", NetworkListener.DEFAULT_NETWORK_HOST, + NetworkListener.DEFAULT_NETWORK_PORT); + + // Enable SSL on the listener + networkListener.setSecure(true); + networkListener.setSSLEngineConfig(createSslConfiguration()); + + server.addListener(networkListener); + try { + // Start the server + server.start(); + System.out.println("The secured server is running.\nhttps://localhost:" + NetworkListener.DEFAULT_NETWORK_PORT + "\nPress enter to stop..."); + System.in.read(); + } catch (IOException ioe) { + LOGGER.log(Level.SEVERE, ioe.toString(), ioe); + } finally { + server.shutdownNow(); + } + } + + /** + * Initialize server side SSL configuration. + * + * @return server side {@link SSLEngineConfigurator}. + */ + private static SSLEngineConfigurator createSslConfiguration() { + // Initialize SSLContext configuration + SSLContextConfigurator sslContextConfig = new SSLContextConfigurator(); + + sslContextConfig.setKeyStoreFile(Path.of("C:\\users\\koppor\\.keystore").toString()); + sslContextConfig.setKeyStorePass("changeit"); + + // Create SSLEngine configurator + return new SSLEngineConfigurator(sslContextConfig.createSSLContext(), false, false, false); + } +} diff --git a/src/test/java/org/jabref/http/server/mwessl/SimpleHttpHandler.java b/src/test/java/org/jabref/http/server/mwessl/SimpleHttpHandler.java new file mode 100644 index 00000000000..58722b8cd1f --- /dev/null +++ b/src/test/java/org/jabref/http/server/mwessl/SimpleHttpHandler.java @@ -0,0 +1,16 @@ +package org.jabref.http.server.mwessl; + +import org.glassfish.grizzly.http.server.HttpHandler; +import org.glassfish.grizzly.http.server.Request; +import org.glassfish.grizzly.http.server.Response; + +/** + * Simple {@link HttpHandler} implementation. + */ +public class SimpleHttpHandler extends HttpHandler { + + public void service(final Request request, final Response response) throws Exception { + response.setContentType("text/plain"); + response.getWriter().write("Hello world!"); + } +} diff --git a/src/test/java/org/jabref/http/server/mwessl/package-info.java b/src/test/java/org/jabref/http/server/mwessl/package-info.java new file mode 100644 index 00000000000..d74acbe1011 --- /dev/null +++ b/src/test/java/org/jabref/http/server/mwessl/package-info.java @@ -0,0 +1,8 @@ +/** + * Temporary code. + * + * Code based on https://github.com/eclipse-ee4j/grizzly/tree/master/samples/http-server-samples/src/main/java/org/glassfish/grizzly/samples/httpserver/secure + * + * BSD-3-Clause license + */ +package org.jabref.http.server.mwessl; diff --git a/src/test/java/org/jabref/logic/citationstyle/JabRefItemDataProviderTest.java b/src/test/java/org/jabref/logic/citationstyle/JabRefItemDataProviderTest.java new file mode 100644 index 00000000000..33172c35434 --- /dev/null +++ b/src/test/java/org/jabref/logic/citationstyle/JabRefItemDataProviderTest.java @@ -0,0 +1,49 @@ +package org.jabref.logic.citationstyle; + +import java.util.List; + +import org.jabref.model.database.BibDatabase; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.BibEntryTypesManager; +import org.jabref.model.entry.field.StandardField; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class JabRefItemDataProviderTest { + + @Test + void toJsonOneEntry() { + BibDatabase bibDatabase = new BibDatabase(List.of( + new BibEntry() + .withCitationKey("key") + .withField(StandardField.AUTHOR, "Test Author") + )); + BibDatabaseContext bibDatabaseContext = new BibDatabaseContext(bibDatabase); + JabRefItemDataProvider jabRefItemDataProvider = new JabRefItemDataProvider(); + jabRefItemDataProvider.setData(bibDatabaseContext, new BibEntryTypesManager()); + assertEquals(""" + [{"id":"key","type":"article","author":[{"family":"Author","given":"Test"}]}]""", + jabRefItemDataProvider.toJson()); + } + + @Test + void toJsonTwoEntries() { + BibDatabase bibDatabase = new BibDatabase(List.of( + new BibEntry() + .withCitationKey("key") + .withField(StandardField.AUTHOR, "Test Author"), + new BibEntry() + .withCitationKey("key2") + .withField(StandardField.AUTHOR, "Second Author") + )); + BibDatabaseContext bibDatabaseContext = new BibDatabaseContext(bibDatabase); + JabRefItemDataProvider jabRefItemDataProvider = new JabRefItemDataProvider(); + jabRefItemDataProvider.setData(bibDatabaseContext, new BibEntryTypesManager()); + assertEquals(""" + [{"id":"key","type":"article","author":[{"family":"Author","given":"Test"}]},{"id":"key2","type":"article","author":[{"family":"Author","given":"Second"}]}]""", + jabRefItemDataProvider.toJson()); + } +} diff --git a/src/test/java/org/jabref/testutils/interactive/http/rest-api.http b/src/test/java/org/jabref/testutils/interactive/http/rest-api.http new file mode 100644 index 00000000000..4994efdd6e2 --- /dev/null +++ b/src/test/java/org/jabref/testutils/interactive/http/rest-api.http @@ -0,0 +1,35 @@ +// This file is for IntelliJ's HTTP Client, available in the Ultimate Edition + +GET https://localhost:6051 + +### + +GET https://localhost:6051/libraries + +### + +GET https://localhost:6051/libraries/notfound + +### + +// if you have checkout the JabRef code at c:\git-repositories\jabref, then this +// will show the contents of your first opened library as BibTeX + +GET https://localhost:6051/libraries/jabref-authors.bib-026bd7ec +Accept: application/x-bibtex + +### + +// if you have checkout the JabRef code at c:\git-repositories\jabref, then this +// will show the contents of your first opened library using CSL JSON + +GET https://localhost:6051/libraries/jabref-authors.bib-026bd7ec +Accept: application/x-bibtex-library-csl+json + +### + +// if you have checkout the JabRef code at c:\git-repositories\jabref, then this +// will show the contents of your first opened library using json + embedded BibTeX + +GET https://localhost:6051/libraries/jabref-authors.bib-026bd7ec +Accept: application/json diff --git a/src/main/java/org/jabref/styletester/StyleTester.fxml b/src/test/java/org/jabref/testutils/interactive/styletester/StyleTester.fxml similarity index 99% rename from src/main/java/org/jabref/styletester/StyleTester.fxml rename to src/test/java/org/jabref/testutils/interactive/styletester/StyleTester.fxml index 1f9d65669cf..2240e83d8bb 100644 --- a/src/main/java/org/jabref/styletester/StyleTester.fxml +++ b/src/test/java/org/jabref/testutils/interactive/styletester/StyleTester.fxml @@ -37,7 +37,7 @@ + xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.jabref.testutils.interactive.styletester.StyleTesterView"> diff --git a/src/main/java/org/jabref/styletester/StyleTesterMain.java b/src/test/java/org/jabref/testutils/interactive/styletester/StyleTesterMain.java similarity index 96% rename from src/main/java/org/jabref/styletester/StyleTesterMain.java rename to src/test/java/org/jabref/testutils/interactive/styletester/StyleTesterMain.java index a6dd6abf351..9941472abcb 100644 --- a/src/main/java/org/jabref/styletester/StyleTesterMain.java +++ b/src/test/java/org/jabref/testutils/interactive/styletester/StyleTesterMain.java @@ -1,4 +1,4 @@ -package org.jabref.styletester; +package org.jabref.testutils.interactive.styletester; import javafx.application.Application; import javafx.scene.Scene; diff --git a/src/main/java/org/jabref/styletester/StyleTesterView.java b/src/test/java/org/jabref/testutils/interactive/styletester/StyleTesterView.java similarity index 96% rename from src/main/java/org/jabref/styletester/StyleTesterView.java rename to src/test/java/org/jabref/testutils/interactive/styletester/StyleTesterView.java index eb2db094606..c4e3c3779b0 100644 --- a/src/main/java/org/jabref/styletester/StyleTesterView.java +++ b/src/test/java/org/jabref/testutils/interactive/styletester/StyleTesterView.java @@ -1,4 +1,4 @@ -package org.jabref.styletester; +package org.jabref.testutils.interactive.styletester; import javafx.css.PseudoClass; import javafx.fxml.FXML; diff --git a/src/test/resources/org/jabref/http/server/general-server-test.bib b/src/test/resources/org/jabref/http/server/general-server-test.bib new file mode 100644 index 00000000000..c1d828316d1 --- /dev/null +++ b/src/test/resources/org/jabref/http/server/general-server-test.bib @@ -0,0 +1,7 @@ +@Misc{Author2023test, + author = {Demo Author}, + title = {Demo Title}, + year = {2023}, +} + +@Comment{jabref-meta: databaseType:bibtex;} From 37dd6b9220d1543cc351f5584b87de53dd3b1624 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Sun, 9 Apr 2023 01:15:27 +0200 Subject: [PATCH 02/10] Remove temporary code --- .../jabref/http/server/mwessl/GServer.java | 66 ------------------- .../http/server/mwessl/SimpleHttpHandler.java | 16 ----- .../http/server/mwessl/package-info.java | 8 --- 3 files changed, 90 deletions(-) delete mode 100644 src/test/java/org/jabref/http/server/mwessl/GServer.java delete mode 100644 src/test/java/org/jabref/http/server/mwessl/SimpleHttpHandler.java delete mode 100644 src/test/java/org/jabref/http/server/mwessl/package-info.java diff --git a/src/test/java/org/jabref/http/server/mwessl/GServer.java b/src/test/java/org/jabref/http/server/mwessl/GServer.java deleted file mode 100644 index c5b028f316a..00000000000 --- a/src/test/java/org/jabref/http/server/mwessl/GServer.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.jabref.http.server.mwessl; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.logging.Level; -import java.util.logging.Logger; - -import org.jabref.http.server.Server; - -import org.glassfish.grizzly.Grizzly; -import org.glassfish.grizzly.http.server.HttpServer; -import org.glassfish.grizzly.http.server.NetworkListener; -import org.glassfish.grizzly.http.server.ServerConfiguration; -import org.glassfish.grizzly.ssl.SSLContextConfigurator; -import org.glassfish.grizzly.ssl.SSLEngineConfigurator; - -/** - * Secured standalone Java HTTP server. - */ -public class GServer { - private static final Logger LOGGER = Grizzly.logger(Server.class); - - public static void main(String[] args) { - final HttpServer server = new HttpServer(); - final ServerConfiguration config = server.getServerConfiguration(); - - // Register simple HttpHandler - config.addHttpHandler(new SimpleHttpHandler(), "/"); - - // create a network listener that listens on port 8080. - final NetworkListener networkListener = new NetworkListener("secured-listener", NetworkListener.DEFAULT_NETWORK_HOST, - NetworkListener.DEFAULT_NETWORK_PORT); - - // Enable SSL on the listener - networkListener.setSecure(true); - networkListener.setSSLEngineConfig(createSslConfiguration()); - - server.addListener(networkListener); - try { - // Start the server - server.start(); - System.out.println("The secured server is running.\nhttps://localhost:" + NetworkListener.DEFAULT_NETWORK_PORT + "\nPress enter to stop..."); - System.in.read(); - } catch (IOException ioe) { - LOGGER.log(Level.SEVERE, ioe.toString(), ioe); - } finally { - server.shutdownNow(); - } - } - - /** - * Initialize server side SSL configuration. - * - * @return server side {@link SSLEngineConfigurator}. - */ - private static SSLEngineConfigurator createSslConfiguration() { - // Initialize SSLContext configuration - SSLContextConfigurator sslContextConfig = new SSLContextConfigurator(); - - sslContextConfig.setKeyStoreFile(Path.of("C:\\users\\koppor\\.keystore").toString()); - sslContextConfig.setKeyStorePass("changeit"); - - // Create SSLEngine configurator - return new SSLEngineConfigurator(sslContextConfig.createSSLContext(), false, false, false); - } -} diff --git a/src/test/java/org/jabref/http/server/mwessl/SimpleHttpHandler.java b/src/test/java/org/jabref/http/server/mwessl/SimpleHttpHandler.java deleted file mode 100644 index 58722b8cd1f..00000000000 --- a/src/test/java/org/jabref/http/server/mwessl/SimpleHttpHandler.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.jabref.http.server.mwessl; - -import org.glassfish.grizzly.http.server.HttpHandler; -import org.glassfish.grizzly.http.server.Request; -import org.glassfish.grizzly.http.server.Response; - -/** - * Simple {@link HttpHandler} implementation. - */ -public class SimpleHttpHandler extends HttpHandler { - - public void service(final Request request, final Response response) throws Exception { - response.setContentType("text/plain"); - response.getWriter().write("Hello world!"); - } -} diff --git a/src/test/java/org/jabref/http/server/mwessl/package-info.java b/src/test/java/org/jabref/http/server/mwessl/package-info.java deleted file mode 100644 index d74acbe1011..00000000000 --- a/src/test/java/org/jabref/http/server/mwessl/package-info.java +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Temporary code. - * - * Code based on https://github.com/eclipse-ee4j/grizzly/tree/master/samples/http-server-samples/src/main/java/org/glassfish/grizzly/samples/httpserver/secure - * - * BSD-3-Clause license - */ -package org.jabref.http.server.mwessl; From 8f0567757182a832e628f7958981d032eaaee241 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Sun, 9 Apr 2023 01:17:10 +0200 Subject: [PATCH 03/10] Fix checkstyle --- src/main/java/org/jabref/model/entry/BibEntry.java | 2 -- src/main/java/org/jabref/preferences/PreferencesService.java | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/jabref/model/entry/BibEntry.java b/src/main/java/org/jabref/model/entry/BibEntry.java index 3ec0822ec3e..5f5301d633a 100644 --- a/src/main/java/org/jabref/model/entry/BibEntry.java +++ b/src/main/java/org/jabref/model/entry/BibEntry.java @@ -918,7 +918,6 @@ public BibEntry withFields(Map content) { return this; } - public BibEntry withDate(Date date) { setDate(date); this.setChanged(false); @@ -1158,5 +1157,4 @@ public void mergeWith(BibEntry other, Set otherPrioritizedFields) { } } } - } diff --git a/src/main/java/org/jabref/preferences/PreferencesService.java b/src/main/java/org/jabref/preferences/PreferencesService.java index fba07fb2006..e4839db64f8 100644 --- a/src/main/java/org/jabref/preferences/PreferencesService.java +++ b/src/main/java/org/jabref/preferences/PreferencesService.java @@ -42,6 +42,7 @@ import org.jabref.model.entry.BibEntryTypesManager; import org.jabref.model.entry.field.Field; import org.jabref.model.metadata.SaveOrderConfig; + import org.jvnet.hk2.annotations.Contract; @Contract From 057e982ec4fc60f37872c96e1fbd5b0aca59c815 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Mon, 10 Apr 2023 12:59:22 +0200 Subject: [PATCH 04/10] Create http-server.md with a how-to for the SSL certificate generation --- docs/code-howtos/http-server.md | 16 +++++++++++ .../java/org/jabref/http/server/Server.java | 28 +++++++++---------- .../org/jabref/http/server/ServerTest.java | 8 ++++++ 3 files changed, 38 insertions(+), 14 deletions(-) create mode 100644 docs/code-howtos/http-server.md 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/src/main/java/org/jabref/http/server/Server.java b/src/main/java/org/jabref/http/server/Server.java index 92ed926ba6e..88d57869da4 100644 --- a/src/main/java/org/jabref/http/server/Server.java +++ b/src/main/java/org/jabref/http/server/Server.java @@ -1,6 +1,7 @@ package org.jabref.http.server; import java.net.URI; +import java.nio.file.Files; import java.nio.file.Path; import java.util.concurrent.CountDownLatch; @@ -42,20 +43,19 @@ static void startServer(CountDownLatch latch) { private static SSLContext getSslContext() { SSLContextConfigurator sslContextConfig = new SSLContextConfigurator(); - - // "server.jks" Needs to be generated using following command inside that directory: - // keytool -genkey -keyalg RSA -alias selfsigned -keystore server.jks -storepass changeit -validity 365 -keysize 2048 -dname "CN=localhost, OU=YourOrganizationUnit, O=YourOrganization, L=YourCity, S=YourState, C=YourCountry" - - String keystorePath = Path.of(AppDirsFactory.getInstance() - .getUserDataDir( - OS.APP_DIR_APP_NAME, - "ssl", - OS.APP_DIR_APP_AUTHOR)) - .resolve("server.p12").toString(); - - sslContextConfig.setKeyStoreFile(keystorePath); - sslContextConfig.setKeyStorePass("changeit"); - + 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(); } diff --git a/src/test/java/org/jabref/http/server/ServerTest.java b/src/test/java/org/jabref/http/server/ServerTest.java index 9395cc93171..039b0e68131 100644 --- a/src/test/java/org/jabref/http/server/ServerTest.java +++ b/src/test/java/org/jabref/http/server/ServerTest.java @@ -24,6 +24,14 @@ 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; From be1266495086b9057b2b155a10c9bf1b4b185a10 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Thu, 20 Apr 2023 11:41:31 +0200 Subject: [PATCH 05/10] Enables passing files to a test Server --- build.gradle | 6 ++ .../java/org/jabref/http/server/Server.java | 62 ++++++++++++++++++- .../java/org/jabref/logic/git/GitHandler.java | 2 +- .../jabref/http/server/http-server-demo.bib | 14 +++++ src/main/resources/tinylog.properties | 2 + .../org/jabref/http/server/TestServer.java | 14 ----- src/test/resources/testbib/jabref-authors.bib | 13 ++++ 7 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 src/main/resources/org/jabref/http/server/http-server-demo.bib delete mode 100644 src/test/java/org/jabref/http/server/TestServer.java diff --git a/build.gradle b/build.gradle index ac0799e23ee..5c012f27903 100644 --- a/build.gradle +++ b/build.gradle @@ -401,6 +401,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/src/main/java/org/jabref/http/server/Server.java b/src/main/java/org/jabref/http/server/Server.java index 88d57869da4..dc8411e8aa9 100644 --- a/src/main/java/org/jabref/http/server/Server.java +++ b/src/main/java/org/jabref/http/server/Server.java @@ -1,26 +1,81 @@ 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.concurrent.CountDownLatch; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import javax.net.ssl.SSLContext; +import javafx.collections.ObservableList; + 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; - static void startServer(CountDownLatch latch) { + /** + * 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() @@ -28,7 +83,9 @@ static void startServer(CountDownLatch latch) { .protocol("HTTPS") .port(6051) .build(); + LOGGER.debug("Starting server..."); SeBootstrap.start(Application.class, configuration).thenAccept(instance -> { + LOGGER.debug("Server started."); instance.stopOnShutdown(stopResult -> System.out.printf("Stop result: %s [Native stop result: %s].%n", stopResult, stopResult.unwrap(Object.class))); @@ -37,7 +94,6 @@ static void startServer(CountDownLatch latch) { instance.unwrap(Object.class)); System.out.println("Send SIGKILL to shutdown."); serverInstance = instance; - latch.countDown(); }); } 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/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/http/server/TestServer.java b/src/test/java/org/jabref/http/server/TestServer.java deleted file mode 100644 index 6ad1edcd970..00000000000 --- a/src/test/java/org/jabref/http/server/TestServer.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.jabref.http.server; - -import java.util.concurrent.CountDownLatch; - -import org.slf4j.bridge.SLF4JBridgeHandler; - -public class TestServer { - public static void main(final String[] args) throws InterruptedException { - SLF4JBridgeHandler.removeHandlersForRootLogger(); - SLF4JBridgeHandler.install(); - Server.startServer(new CountDownLatch(1)); - Thread.currentThread().join(); - } -} diff --git a/src/test/resources/testbib/jabref-authors.bib b/src/test/resources/testbib/jabref-authors.bib index 87a7c5eeb46..8687068e425 100644 --- a/src/test/resources/testbib/jabref-authors.bib +++ b/src/test/resources/testbib/jabref-authors.bib @@ -3003,6 +3003,19 @@ @InProceedings{SimonDietzDiezEtAl2019 priority = {prio1}, } +@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)}, + date = {2018}, + title = {Markdown Architectural Decision Records: Format and Tool Support}, + 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:biblatex;} @Comment{jabref-meta: grouping: From c675fd7c9694d801251e332ae399c38ed254001d Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Sat, 22 Apr 2023 06:09:34 +0200 Subject: [PATCH 06/10] Add initial sync --- build.gradle | 2 + src/main/java/module-info.java | 6 +++ .../org/jabref/http/client/SyncClient.java | 53 +++++++++++++++++++ .../java/org/jabref/http/dto/BibEntryDTO.java | 15 ++++++ .../org/jabref/http/server/Application.java | 49 ++++++++++++++++- .../jabref/http/server/LibraryResource.java | 2 +- .../org/jabref/http/server/RootResource.java | 25 ++++++--- .../jabref/http/server/ServerPreferences.java | 15 ++++++ .../jabref/http/server/UpdatesResource.java | 39 ++++++++++++++ .../org/jabref/http/sync/state/SyncState.java | 50 +++++++++++++++++ .../java/org/jabref/model/entry/BibEntry.java | 1 + .../org/jabref/http/server/ServerTest.java | 7 ++- .../http/server/UpdatesResourceTest.java | 44 +++++++++++++++ .../jabref/http/sync/state/SyncStateTest.java | 28 ++++++++++ .../interactive/http/SyncClientDemo.java | 9 ++++ .../testutils/interactive/http/rest-api.http | 34 ++++++++++++ 16 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/jabref/http/client/SyncClient.java create mode 100644 src/main/java/org/jabref/http/server/ServerPreferences.java create mode 100644 src/main/java/org/jabref/http/server/UpdatesResource.java create mode 100644 src/main/java/org/jabref/http/sync/state/SyncState.java create mode 100644 src/test/java/org/jabref/http/server/UpdatesResourceTest.java create mode 100644 src/test/java/org/jabref/http/sync/state/SyncStateTest.java create mode 100644 src/test/java/org/jabref/testutils/interactive/http/SyncClientDemo.java diff --git a/build.gradle b/build.gradle index e38208a9185..b1ccb23b722 100644 --- a/build.gradle +++ b/build.gradle @@ -217,6 +217,8 @@ dependencies { // 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' diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index e500c1ca5b7..8170e2ff111 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -62,6 +62,12 @@ requires jakarta.ws.rs; requires grizzly.framework; + // 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; 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 index a0d20d4353e..54b3af9477f 100644 --- a/src/main/java/org/jabref/http/dto/BibEntryDTO.java +++ b/src/main/java/org/jabref/http/dto/BibEntryDTO.java @@ -3,6 +3,8 @@ 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; @@ -36,6 +38,19 @@ public BibEntryDTO(BibEntry bibEntry, BibDatabaseMode bibDatabaseMode, FieldWrit ); } + 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); + } + private static String convertToString(BibEntry entry, BibDatabaseMode bibDatabaseMode, FieldWriterPreferences fieldWriterPreferences, BibEntryTypesManager bibEntryTypesManager) { StringWriter rawEntry = new StringWriter(); BibWriter bibWriter = new BibWriter(rawEntry, "\n"); diff --git a/src/main/java/org/jabref/http/server/Application.java b/src/main/java/org/jabref/http/server/Application.java index 00567335edb..1f9b7e98448 100644 --- a/src/main/java/org/jabref/http/server/Application.java +++ b/src/main/java/org/jabref/http/server/Application.java @@ -10,16 +10,63 @@ 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, CORSFilter.class); + return Set.of(RootResource.class, LibrariesResource.class, LibraryResource.class, UpdatesResource.class, CORSFilter.class); } /** diff --git a/src/main/java/org/jabref/http/server/LibraryResource.java b/src/main/java/org/jabref/http/server/LibraryResource.java index 9e10afa7fa1..9e8cdb107b9 100644 --- a/src/main/java/org/jabref/http/server/LibraryResource.java +++ b/src/main/java/org/jabref/http/server/LibraryResource.java @@ -47,7 +47,7 @@ public String getJson(@PathParam("id") String id) { bibEntry.getSharedBibEntryData().setSharedID(Objects.hash(bibEntry)); return bibEntry; }) - .map(entry -> new BibEntryDTO(entry, parserResult.getDatabaseContext().getMode(), preferences.getFieldWriterPreferences(), Globals.entryTypesManager)) + .map(entry -> new BibEntryDTO(entry, parserResult.getDatabaseContext().getMode())) .toList(); return gson.toJson(list); } diff --git a/src/main/java/org/jabref/http/server/RootResource.java b/src/main/java/org/jabref/http/server/RootResource.java index c55f2583db3..3545a0d5438 100644 --- a/src/main/java/org/jabref/http/server/RootResource.java +++ b/src/main/java/org/jabref/http/server/RootResource.java @@ -1,5 +1,8 @@ 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; @@ -11,12 +14,20 @@ public class RootResource { @Produces(MediaType.TEXT_HTML) public String get() { return """ - - -

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

- -"""; + + +

+ 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/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..6251bdc9b57 --- /dev/null +++ b/src/main/java/org/jabref/http/server/UpdatesResource.java @@ -0,0 +1,39 @@ +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.SyncState; + +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.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +@Path("updates") +public class UpdatesResource { + @Inject + Gson gson; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public String get(@QueryParam("since") int since) { + List changes = SyncState.INSTANCE.changes(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/state/SyncState.java b/src/main/java/org/jabref/http/sync/state/SyncState.java new file mode 100644 index 00000000000..b39d81bf220 --- /dev/null +++ b/src/main/java/org/jabref/http/sync/state/SyncState.java @@ -0,0 +1,50 @@ +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.gui.Globals; +import org.jabref.http.server.ServerPreferences; +import org.jabref.model.database.BibDatabaseMode; +import org.jabref.model.entry.BibEntry; +import org.jabref.http.dto.BibEntryDTO; + +public enum SyncState { + INSTANCE; + + // mapping from the shared ID to the DTO + private Map lastStateOfEntries = new HashMap<>(); + + // globalRevisionId -> set of IDs + private Map> idUpdated = new HashMap<>(); + + /** + * Adds or updates an entry + */ + public void putEntry(Integer globalRevision, BibEntry entry) { + int sharedID = entry.getSharedBibEntryData().getSharedID(); + assert sharedID >= 0; + lastStateOfEntries.put(sharedID, new BibEntryDTO(entry)); + idUpdated.computeIfAbsent(globalRevision, k -> new HashSet<>()).add(sharedID); + } + + /** + * Returns all changes between the given revisions. + * + * @param fromRevision the revision to start from (exclusive) + * @return a list of all changes + */ + public List changes(Integer fromRevision) { + return idUpdated.entrySet().stream() + .filter(entry -> entry.getKey() > fromRevision) + .flatMap(entry -> entry.getValue().stream()) + .distinct() + .sorted() + .map(sharedId -> lastStateOfEntries.get(sharedId)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/org/jabref/model/entry/BibEntry.java b/src/main/java/org/jabref/model/entry/BibEntry.java index 5f5301d633a..0523efef49a 100644 --- a/src/main/java/org/jabref/model/entry/BibEntry.java +++ b/src/main/java/org/jabref/model/entry/BibEntry.java @@ -1157,4 +1157,5 @@ public void mergeWith(BibEntry other, Set otherPrioritizedFields) { } } } + } diff --git a/src/test/java/org/jabref/http/server/ServerTest.java b/src/test/java/org/jabref/http/server/ServerTest.java index 039b0e68131..1736d7f248f 100644 --- a/src/test/java/org/jabref/http/server/ServerTest.java +++ b/src/test/java/org/jabref/http/server/ServerTest.java @@ -82,15 +82,14 @@ private static void initializePreferencesService() { when(importFormatPreferences.bibEntryPreferences()).thenReturn(bibEntryPreferences); when(bibEntryPreferences.getKeywordSeparator()).thenReturn(','); - FieldWriterPreferences fieldWriterPreferences = mock(FieldWriterPreferences.class); - when(preferencesService.getFieldWriterPreferences()).thenReturn(fieldWriterPreferences); - when(fieldWriterPreferences.isResolveStrings()).thenReturn(false); + 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); - when(preferencesService.getFieldWriterPreferences().getFieldContentFormatterPreferences()).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); 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..67e42876114 --- /dev/null +++ b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java @@ -0,0 +1,44 @@ +package org.jabref.http.server; + +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class UpdatesResourceTest extends ServerTest { + + @Override + protected jakarta.ws.rs.core.Application configure() { + ResourceConfig resourceConfig = new ResourceConfig(UpdatesResource.class); + addPreferencesToResourceConfig(resourceConfig); + addGsonToResourceConfig(resourceConfig); + return resourceConfig.getApplication(); + } + + @Test + void initialData() { + assertEquals(""" + [ + { + "sharingMetadata": { + "sharedID": 1, + "version": 2 + }, + "type": "Misc", + "citationKey": "e1.v2", + "content": {}, + "userComments": "" + }, + { + "sharingMetadata": { + "sharedID": 2, + "version": 1 + }, + "type": "Misc", + "citationKey": "e2.v1", + "content": {}, + "userComments": "" + } + ]""", target("/updates").queryParam("lastUpdate", "0").request().get(String.class)); + } +} diff --git a/src/test/java/org/jabref/http/sync/state/SyncStateTest.java b/src/test/java/org/jabref/http/sync/state/SyncStateTest.java new file mode 100644 index 00000000000..ef0d752da4f --- /dev/null +++ b/src/test/java/org/jabref/http/sync/state/SyncStateTest.java @@ -0,0 +1,28 @@ +package org.jabref.http.sync.state; + +import java.util.List; + +import org.jabref.model.entry.BibEntry; +import org.jabref.http.dto.BibEntryDTO; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SyncStateTest { + @Test void test() { + BibEntry entryE1V1 = new BibEntry().withCitationKey("e1.v1").withSharedBibEntryData(1, 1); + BibEntry entryE1V2 = new BibEntry().withCitationKey("e1.v2").withSharedBibEntryData(1, 2); + BibEntry entryE2V1 = new BibEntry().withCitationKey("e2.v1").withSharedBibEntryData(2, 1); + + SyncState.INSTANCE.putEntry( + 1, entryE1V1); + SyncState.INSTANCE.putEntry( + 1, entryE2V1); + SyncState.INSTANCE.putEntry( + 2, entryE1V2); + + List changes = SyncState.INSTANCE.changes(0); + assertEquals(List.of(new BibEntryDTO(entryE1V2), new BibEntryDTO(entryE2V1)), changes); + } +} diff --git a/src/test/java/org/jabref/testutils/interactive/http/SyncClientDemo.java b/src/test/java/org/jabref/testutils/interactive/http/SyncClientDemo.java new file mode 100644 index 00000000000..a31c57bc880 --- /dev/null +++ b/src/test/java/org/jabref/testutils/interactive/http/SyncClientDemo.java @@ -0,0 +1,9 @@ +package org.jabref.testutils.interactive.http; + +import org.jabref.http.client.SyncClient; + +public class SyncClientDemo { + public static final void main(String[] args) { + SyncClient client = new SyncClient(null); + } +} diff --git a/src/test/java/org/jabref/testutils/interactive/http/rest-api.http b/src/test/java/org/jabref/testutils/interactive/http/rest-api.http index 4994efdd6e2..ccbdecc4ad5 100644 --- a/src/test/java/org/jabref/testutils/interactive/http/rest-api.http +++ b/src/test/java/org/jabref/testutils/interactive/http/rest-api.http @@ -33,3 +33,37 @@ Accept: application/x-bibtex-library-csl+json GET https://localhost:6051/libraries/jabref-authors.bib-026bd7ec Accept: application/json + +### + +POST http://localhost:8080/updates +Accept: application/json + +[ + { + "sharingMetadata": { + "sharedID": 1, + "version": 2 + }, + "type": "Misc", + "citationKey": "e1.v2", + "content": {}, + "userComments": "" + }, + { + "sharingMetadata": { + "sharedID": 2, + "version": 1 + }, + "type": "Misc", + "citationKey": "e2.v1", + "content": {}, + "userComments": "" + } +] + +### Fetch OpenAPI description + +// Client can be generated with npx openapi-generator-cli generate -g java -o generated-client --library=jersey3 -i C:\git-repositories\jabref\.idea\httpRequests\2023-04-04T102607.200.json +GET http://localhost:8080/openapi.json +Accept: application/json From 13e0623d80eea9c35c79618b418b5e4c180a3327 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Sat, 22 Apr 2023 12:39:04 +0200 Subject: [PATCH 07/10] Fix ADR name --- ...-return-bibtex-string.md => 0028-http-return-bibtex-string.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/decisions/{0027-http-return-bibtex-string.md => 0028-http-return-bibtex-string.md} (100%) diff --git a/docs/decisions/0027-http-return-bibtex-string.md b/docs/decisions/0028-http-return-bibtex-string.md similarity index 100% rename from docs/decisions/0027-http-return-bibtex-string.md rename to docs/decisions/0028-http-return-bibtex-string.md From f3e7c2d44396ee1520052f10c9b5fe380259bd53 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Sat, 22 Apr 2023 14:24:53 +0200 Subject: [PATCH 08/10] Add "withChanged" to enable proper BibEntryDTOTest --- .github/workflows/tests.yml | 2 +- .../java/org/jabref/http/dto/BibEntryDTO.java | 2 +- .../java/org/jabref/http/server/Server.java | 7 +- .../org/jabref/http/sync/state/SyncState.java | 18 ++-- .../java/org/jabref/model/entry/BibEntry.java | 11 ++ .../architecture/MainArchitectureTest.java | 8 ++ .../org/jabref/http/dto/BibEntryDTOTest.java | 100 ++++++++++++++++++ .../http/server/UpdatesResourceTest.java | 28 +++++ 8 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 src/test/java/org/jabref/http/dto/BibEntryDTOTest.java 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/src/main/java/org/jabref/http/dto/BibEntryDTO.java b/src/main/java/org/jabref/http/dto/BibEntryDTO.java index 54b3af9477f..7fc89afc348 100644 --- a/src/main/java/org/jabref/http/dto/BibEntryDTO.java +++ b/src/main/java/org/jabref/http/dto/BibEntryDTO.java @@ -51,7 +51,7 @@ public BibEntryDTO(BibEntry entry) { this(entry, BibDatabaseMode.BIBTEX); } - private static String convertToString(BibEntry entry, BibDatabaseMode bibDatabaseMode, FieldWriterPreferences fieldWriterPreferences, BibEntryTypesManager bibEntryTypesManager) { + 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); diff --git a/src/main/java/org/jabref/http/server/Server.java b/src/main/java/org/jabref/http/server/Server.java index dc8411e8aa9..05f48aa111d 100644 --- a/src/main/java/org/jabref/http/server/Server.java +++ b/src/main/java/org/jabref/http/server/Server.java @@ -12,6 +12,7 @@ import javafx.collections.ObservableList; +import org.jabref.architecture.AllowedToUseStandardStreams; import org.jabref.logic.util.OS; import org.jabref.preferences.JabRefPreferences; @@ -87,12 +88,12 @@ private static void startServer() { SeBootstrap.start(Application.class, configuration).thenAccept(instance -> { LOGGER.debug("Server started."); instance.stopOnShutdown(stopResult -> - System.out.printf("Stop result: %s [Native stop result: %s].%n", stopResult, + LOGGER.info("Stop result: {} [Native stop result: {}}].", stopResult, stopResult.unwrap(Object.class))); final URI uri = instance.configuration().baseUri(); - System.out.printf("Instance %s running at %s [Native handle: %s].%n", instance, uri, + LOGGER.info("Instance {} running at {} [Native handle: {}].", instance, uri, instance.unwrap(Object.class)); - System.out.println("Send SIGKILL to shutdown."); + LOGGER.info("Send SIGKILL to shutdown."); serverInstance = instance; }); } diff --git a/src/main/java/org/jabref/http/sync/state/SyncState.java b/src/main/java/org/jabref/http/sync/state/SyncState.java index b39d81bf220..07235fc37c2 100644 --- a/src/main/java/org/jabref/http/sync/state/SyncState.java +++ b/src/main/java/org/jabref/http/sync/state/SyncState.java @@ -7,9 +7,6 @@ import java.util.Set; import java.util.stream.Collectors; -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.http.dto.BibEntryDTO; @@ -20,7 +17,7 @@ public enum SyncState { private Map lastStateOfEntries = new HashMap<>(); // globalRevisionId -> set of IDs - private Map> idUpdated = new HashMap<>(); + private Map> idsUpdated = new HashMap<>(); /** * Adds or updates an entry @@ -29,7 +26,7 @@ public void putEntry(Integer globalRevision, BibEntry entry) { int sharedID = entry.getSharedBibEntryData().getSharedID(); assert sharedID >= 0; lastStateOfEntries.put(sharedID, new BibEntryDTO(entry)); - idUpdated.computeIfAbsent(globalRevision, k -> new HashSet<>()).add(sharedID); + idsUpdated.computeIfAbsent(globalRevision, k -> new HashSet<>()).add(sharedID); } /** @@ -39,7 +36,7 @@ public void putEntry(Integer globalRevision, BibEntry entry) { * @return a list of all changes */ public List changes(Integer fromRevision) { - return idUpdated.entrySet().stream() + return idsUpdated.entrySet().stream() .filter(entry -> entry.getKey() > fromRevision) .flatMap(entry -> entry.getValue().stream()) .distinct() @@ -47,4 +44,13 @@ public List changes(Integer fromRevision) { .map(sharedId -> lastStateOfEntries.get(sharedId)) .collect(Collectors.toList()); } + + /** + * 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/model/entry/BibEntry.java b/src/main/java/org/jabref/model/entry/BibEntry.java index 0523efef49a..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; } 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/UpdatesResourceTest.java b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java index 67e42876114..e560ee00e1d 100644 --- a/src/test/java/org/jabref/http/server/UpdatesResourceTest.java +++ b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java @@ -1,12 +1,20 @@ package org.jabref.http.server; import org.glassfish.jersey.server.ResourceConfig; +import org.jabref.http.sync.state.SyncState; +import org.jabref.model.entry.BibEntry; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class UpdatesResourceTest extends ServerTest { + @BeforeEach + void resetState() { + SyncState.INSTANCE.reset(); + } + @Override protected jakarta.ws.rs.core.Application configure() { ResourceConfig resourceConfig = new ResourceConfig(UpdatesResource.class); @@ -15,8 +23,28 @@ protected jakarta.ws.rs.core.Application configure() { return resourceConfig.getApplication(); } + @Test + void noLastUpdateSupplied() { + assertEquals("[]", target("/updates").request().get(String.class)); + } + @Test void initialData() { + assertEquals("[]", target("/updates").queryParam("lastUpdate", "0").request().get(String.class)); + } + + @Test + void twoVersions() { + BibEntry entryE1V1 = new BibEntry().withCitationKey("e1.v1").withSharedBibEntryData(1, 1); + BibEntry entryE1V2 = new BibEntry().withCitationKey("e1.v2").withSharedBibEntryData(1, 2); + BibEntry entryE2V1 = new BibEntry().withCitationKey("e2.v1").withSharedBibEntryData(2, 1); + + SyncState.INSTANCE.putEntry( + 1, entryE1V1); + SyncState.INSTANCE.putEntry( + 1, entryE2V1); + SyncState.INSTANCE.putEntry( + 2, entryE1V2); assertEquals(""" [ { From f59e57f9b0bf83cbf2b0624c4da2d35c19f6433a Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Sat, 22 Apr 2023 15:08:43 +0200 Subject: [PATCH 09/10] Fix test --- .../http/server/UpdatesResourceTest.java | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/test/java/org/jabref/http/server/UpdatesResourceTest.java b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java index e560ee00e1d..7683937c1bd 100644 --- a/src/test/java/org/jabref/http/server/UpdatesResourceTest.java +++ b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java @@ -35,9 +35,9 @@ void initialData() { @Test void twoVersions() { - BibEntry entryE1V1 = new BibEntry().withCitationKey("e1.v1").withSharedBibEntryData(1, 1); - BibEntry entryE1V2 = new BibEntry().withCitationKey("e1.v2").withSharedBibEntryData(1, 2); - BibEntry entryE2V1 = new BibEntry().withCitationKey("e2.v1").withSharedBibEntryData(2, 1); + BibEntry entryE1V1 = new BibEntry().withCitationKey("e1.v1").withSharedBibEntryData(1, 1).withChanged(true); + BibEntry entryE1V2 = new BibEntry().withCitationKey("e1.v2").withSharedBibEntryData(1, 2).withChanged(true); + BibEntry entryE2V1 = new BibEntry().withCitationKey("e2.v1").withSharedBibEntryData(2, 1).withChanged(true); SyncState.INSTANCE.putEntry( 1, entryE1V1); @@ -47,26 +47,24 @@ void twoVersions() { 2, entryE1V2); assertEquals(""" [ - { - "sharingMetadata": { - "sharedID": 1, - "version": 2 - }, - "type": "Misc", - "citationKey": "e1.v2", - "content": {}, - "userComments": "" - }, - { - "sharingMetadata": { - "sharedID": 2, - "version": 1 - }, - "type": "Misc", - "citationKey": "e2.v1", - "content": {}, - "userComments": "" - } - ]""", target("/updates").queryParam("lastUpdate", "0").request().get(String.class)); + { + "sharingMetadata": { + "sharedID": 1, + "version": 2 + }, + "userComments": "", + "citationKey": "e1.v2", + "bibtex": "@Misc{e1.v2,\\n}\\n" + }, + { + "sharingMetadata": { + "sharedID": 2, + "version": 1 + }, + "userComments": "", + "citationKey": "e2.v1", + "bibtex": "@Misc{e2.v1,\\n}\\n" + } + ]""", target("/updates").queryParam("lastUpdate", "0").request().get(String.class)); } } From d7897d1e9e2751cfdf2ece84ff80ca6864fc4f0d Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Mon, 24 Apr 2023 08:19:38 +0200 Subject: [PATCH 10/10] WIP: Make SyncState library-aware --- build.gradle | 2 ++ docs/decisions/0029-use-cuid2.md | 26 ++++++++++++++++++ src/main/java/module-info.java | 1 + .../jabref/http/server/LibraryResource.java | 12 ++++----- .../jabref/http/server/UpdatesResource.java | 13 ++++++--- .../org/jabref/http/sync/package-info.java | 5 ++++ .../http/sync/state/ChangesAndServerView.java | 8 ++++++ .../org/jabref/http/sync/state/HashInfo.java | 20 ++++++++++++++ .../org/jabref/http/sync/state/SyncState.java | 22 ++++++++++----- .../model/entry/SharedBibEntryData.java | 2 ++ .../http/server/UpdatesResourceTest.java | 27 +++++++++++-------- .../jabref/http/sync/state/SyncStateTest.java | 26 +++++++++++++----- 12 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 docs/decisions/0029-use-cuid2.md create mode 100644 src/main/java/org/jabref/http/sync/package-info.java create mode 100644 src/main/java/org/jabref/http/sync/state/ChangesAndServerView.java create mode 100644 src/main/java/org/jabref/http/sync/state/HashInfo.java diff --git a/build.gradle b/build.gradle index b1ccb23b722..7ed9102c2ca 100644 --- a/build.gradle +++ b/build.gradle @@ -221,6 +221,8 @@ dependencies { 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' 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 8170e2ff111..66fa2c7263e 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -61,6 +61,7 @@ requires java.net.http; requires jakarta.ws.rs; requires grizzly.framework; + // requires cuid; // OpenAPI generation requires io.swagger.v3.core; diff --git a/src/main/java/org/jabref/http/server/LibraryResource.java b/src/main/java/org/jabref/http/server/LibraryResource.java index 9e8cdb107b9..94e3e5a7505 100644 --- a/src/main/java/org/jabref/http/server/LibraryResource.java +++ b/src/main/java/org/jabref/http/server/LibraryResource.java @@ -41,7 +41,7 @@ public class LibraryResource { @GET @Produces(MediaType.APPLICATION_JSON) public String getJson(@PathParam("id") String id) { - ParserResult parserResult = getParserResult(id); + ParserResult parserResult = getParserResult(preferences, id); List list = parserResult.getDatabase().getEntries().stream() .map(bibEntry -> { bibEntry.getSharedBibEntryData().setSharedID(Objects.hash(bibEntry)); @@ -55,14 +55,14 @@ public String getJson(@PathParam("id") String id) { @GET @Produces(org.jabref.http.MediaType.JSON_CSL_ITEM) public String getClsItemJson(@PathParam("id") String id) { - ParserResult parserResult = getParserResult(id); + ParserResult parserResult = getParserResult(preferences, id); JabRefItemDataProvider jabRefItemDataProvider = new JabRefItemDataProvider(); jabRefItemDataProvider.setData(parserResult.getDatabaseContext(), new BibEntryTypesManager()); return jabRefItemDataProvider.toJson(); } - private ParserResult getParserResult(String id) { - java.nio.file.Path library = getLibraryPath(id); + 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); @@ -76,7 +76,7 @@ private ParserResult getParserResult(String id) { @GET @Produces(org.jabref.http.MediaType.BIBTEX) public Response getBibtex(@PathParam("id") String id) { - java.nio.file.Path library = getLibraryPath(id); + java.nio.file.Path library = getLibraryPath(preferences, id); String libraryAsString; try { libraryAsString = Files.readString(library); @@ -89,7 +89,7 @@ public Response getBibtex(@PathParam("id") String id) { .build(); } - private java.nio.file.Path getLibraryPath(String id) { + 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) diff --git a/src/main/java/org/jabref/http/server/UpdatesResource.java b/src/main/java/org/jabref/http/server/UpdatesResource.java index 6251bdc9b57..25733425b4d 100644 --- a/src/main/java/org/jabref/http/server/UpdatesResource.java +++ b/src/main/java/org/jabref/http/server/UpdatesResource.java @@ -4,7 +4,9 @@ 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; @@ -12,19 +14,24 @@ 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("updates") +@Path("libraries/{id}/updates") public class UpdatesResource { @Inject Gson gson; + @Inject + PreferencesService preferences; + @GET @Produces(MediaType.APPLICATION_JSON) - public String get(@QueryParam("since") int since) { - List changes = SyncState.INSTANCE.changes(since); + 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); } 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 index 07235fc37c2..1aba356ba2b 100644 --- a/src/main/java/org/jabref/http/sync/state/SyncState.java +++ b/src/main/java/org/jabref/http/sync/state/SyncState.java @@ -7,20 +7,24 @@ 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 enum SyncState { - INSTANCE; - +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 + * 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(); @@ -31,18 +35,22 @@ public void putEntry(Integer globalRevision, BibEntry entry) { /** * 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) - * @return a list of all changes */ - public List changes(Integer fromRevision) { - return idsUpdated.entrySet().stream() + 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); } /** 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/test/java/org/jabref/http/server/UpdatesResourceTest.java b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java index 7683937c1bd..18a2cbaea92 100644 --- a/src/test/java/org/jabref/http/server/UpdatesResourceTest.java +++ b/src/test/java/org/jabref/http/server/UpdatesResourceTest.java @@ -2,7 +2,11 @@ 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; @@ -10,10 +14,11 @@ class UpdatesResourceTest extends ServerTest { - @BeforeEach - void resetState() { - SyncState.INSTANCE.reset(); - } + private final String path = "/li changes = SyncState.INSTANCE.changes(0); - assertEquals(List.of(new BibEntryDTO(entryE1V2), new BibEntryDTO(entryE2V1)), changes); + ChangesAndServerView changes = syncState.changesAndServerView(0); + assertEquals(new ChangesAndServerView( + List.of(new BibEntryDTO(entryE1V2), new BibEntryDTO(entryE2V1)), + List.of( + new HashInfo(1, entryE1V2.hashCode()), + new HashInfo(2, entryE2V1.hashCode()))), + changes); } }