diff --git a/CHANGELOG.md b/CHANGELOG.md
index d114e148b6f..99caa4130c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,12 +14,14 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We added a new CLI that supports txt, csv, and console-based output for consistency in BibTeX entries. [#11984](https://github.com/JabRef/jabref/issues/11984)
- We added a new dialog for bibliography consistency check. [#11950](https://github.com/JabRef/jabref/issues/11950)
- We added a feature for copying entries to libraries, available via the context menu, with an option to include cross-references. [#12374](https://github.com/JabRef/jabref/pull/12374)
+- We introduced a settings parameters to manage citations' relations local storage time-to-live with a default value set to 30 days. [#11189](https://github.com/JabRef/jabref/issues/11189)
### Changed
- We moved the "Generate a new key for imported entries" option from the "Web search" tab to the "Citation key generator" tab in preferences. [#12436](https://github.com/JabRef/jabref/pull/12436)
- We improved the offline parsing of BibTeX data from PDF-documents. [#12278](https://github.com/JabRef/jabref/issues/12278)
- The tab bar is now hidden when only one library is open. [#9971](https://github.com/JabRef/jabref/issues/9971)
+- We improved the citations relations caching by implementing a two levels cache aside strategy including offline storage. [#11189](https://github.com/JabRef/jabref/issues/11189)
- When working with CSL styles in LibreOffice, citing with a new style now updates all other citations in the document to have the currently selected style. [#12472](https://github.com/JabRef/jabref/pull/12472)
- We improved the user comments field visibility so that it remains displayed if it contains text. Additionally, users can now easily toggle the field on or off via buttons unless disabled in preferences. [#11021](https://github.com/JabRef/jabref/issues/11021)
- The LibreOffice integration for CSL styles is now more performant. [#12472](https://github.com/JabRef/jabref/pull/12472)
diff --git a/src/main/java/org/jabref/gui/JabRefGUI.java b/src/main/java/org/jabref/gui/JabRefGUI.java
index b85b2437d8f..680d48bdeab 100644
--- a/src/main/java/org/jabref/gui/JabRefGUI.java
+++ b/src/main/java/org/jabref/gui/JabRefGUI.java
@@ -28,6 +28,7 @@
import org.jabref.gui.util.UiTaskExecutor;
import org.jabref.logic.UiCommand;
import org.jabref.logic.ai.AiService;
+import org.jabref.logic.citation.SearchCitationsRelationsService;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.net.ProxyRegisterer;
import org.jabref.logic.os.OS;
@@ -173,6 +174,9 @@ public void initialize() {
dialogService,
taskExecutor);
Injector.setModelOrService(AiService.class, aiService);
+
+ var searchCitationsRelationsService = new SearchCitationsRelationsService(preferences.getImporterPreferences());
+ Injector.setModelOrService(SearchCitationsRelationsService.class, searchCitationsRelationsService);
}
private void setupProxy() {
diff --git a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java
index f36a42aeef0..3fe492c7749 100644
--- a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java
+++ b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java
@@ -52,6 +52,7 @@
import org.jabref.gui.util.UiTaskExecutor;
import org.jabref.logic.ai.AiService;
import org.jabref.logic.bibtex.TypedBibEntry;
+import org.jabref.logic.citation.SearchCitationsRelationsService;
import org.jabref.logic.help.HelpFile;
import org.jabref.logic.importer.EntryBasedFetcher;
import org.jabref.logic.importer.WebFetchers;
@@ -123,6 +124,7 @@ public class EntryEditor extends BorderPane {
@Inject private KeyBindingRepository keyBindingRepository;
@Inject private JournalAbbreviationRepository journalAbbreviationRepository;
@Inject private AiService aiService;
+ @Inject private SearchCitationsRelationsService searchCitationsRelationsService;
private final List allPossibleTabs;
@@ -305,8 +307,13 @@ private List createTabs() {
tabs.add(new MathSciNetTab());
tabs.add(new FileAnnotationTab(libraryTab.getAnnotationCache()));
tabs.add(new SciteTab(preferences, taskExecutor, dialogService));
- tabs.add(new CitationRelationsTab(dialogService, databaseContext,
- undoManager, stateManager, fileMonitor, preferences, libraryTab, taskExecutor, bibEntryTypesManager));
+ tabs.add(new CitationRelationsTab(
+ dialogService, databaseContext,
+ undoManager, stateManager,
+ fileMonitor, preferences,
+ libraryTab, taskExecutor,
+ bibEntryTypesManager, searchCitationsRelationsService
+ ));
tabs.add(new RelatedArticlesTab(buildInfo, databaseContext, preferences, dialogService, taskExecutor));
sourceTab = new SourceTab(
databaseContext,
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsCache.java b/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsCache.java
deleted file mode 100644
index de2f832b48f..00000000000
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsCache.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package org.jabref.gui.entryeditor.citationrelationtab;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-
-import org.jabref.model.entry.BibEntry;
-import org.jabref.model.entry.identifier.DOI;
-
-import org.eclipse.jgit.util.LRUMap;
-
-public class BibEntryRelationsCache {
- private static final Integer MAX_CACHED_ENTRIES = 100;
- private static final Map> CITATIONS_MAP = new LRUMap<>(MAX_CACHED_ENTRIES, MAX_CACHED_ENTRIES);
- private static final Map> REFERENCES_MAP = new LRUMap<>(MAX_CACHED_ENTRIES, MAX_CACHED_ENTRIES);
-
- public List getCitations(BibEntry entry) {
- return CITATIONS_MAP.getOrDefault(entry.getDOI().map(DOI::asString).orElse(""), Collections.emptyList());
- }
-
- public List getReferences(BibEntry entry) {
- return REFERENCES_MAP.getOrDefault(entry.getDOI().map(DOI::asString).orElse(""), Collections.emptyList());
- }
-
- public void cacheOrMergeCitations(BibEntry entry, List citations) {
- entry.getDOI().ifPresent(doi -> CITATIONS_MAP.put(doi.asString(), citations));
- }
-
- public void cacheOrMergeReferences(BibEntry entry, List references) {
- entry.getDOI().ifPresent(doi -> REFERENCES_MAP.putIfAbsent(doi.asString(), references));
- }
-
- public boolean citationsCached(BibEntry entry) {
- return CITATIONS_MAP.containsKey(entry.getDOI().map(DOI::asString).orElse(""));
- }
-
- public boolean referencesCached(BibEntry entry) {
- return REFERENCES_MAP.containsKey(entry.getDOI().map(DOI::asString).orElse(""));
- }
-}
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsRepository.java b/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsRepository.java
deleted file mode 100644
index f7f29052da2..00000000000
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsRepository.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package org.jabref.gui.entryeditor.citationrelationtab;
-
-import java.util.List;
-
-import org.jabref.gui.entryeditor.citationrelationtab.semanticscholar.SemanticScholarFetcher;
-import org.jabref.logic.importer.FetcherException;
-import org.jabref.model.entry.BibEntry;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class BibEntryRelationsRepository {
- private static final Logger LOGGER = LoggerFactory.getLogger(BibEntryRelationsRepository.class);
-
- private final SemanticScholarFetcher fetcher;
- private final BibEntryRelationsCache cache;
-
- public BibEntryRelationsRepository(SemanticScholarFetcher fetcher, BibEntryRelationsCache cache) {
- this.fetcher = fetcher;
- this.cache = cache;
- }
-
- public List getCitations(BibEntry entry) {
- if (needToRefreshCitations(entry)) {
- forceRefreshCitations(entry);
- }
-
- return cache.getCitations(entry);
- }
-
- public List getReferences(BibEntry entry) {
- if (needToRefreshReferences(entry)) {
- List references;
- try {
- references = fetcher.searchCiting(entry);
- } catch (FetcherException e) {
- LOGGER.error("Error while fetching references", e);
- references = List.of();
- }
- cache.cacheOrMergeReferences(entry, references);
- }
-
- return cache.getReferences(entry);
- }
-
- public void forceRefreshCitations(BibEntry entry) {
- try {
- List citations = fetcher.searchCitedBy(entry);
- cache.cacheOrMergeCitations(entry, citations);
- } catch (FetcherException e) {
- LOGGER.error("Error while fetching citations", e);
- }
- }
-
- public boolean needToRefreshCitations(BibEntry entry) {
- return !cache.citationsCached(entry);
- }
-
- public boolean needToRefreshReferences(BibEntry entry) {
- return !cache.referencesCached(entry);
- }
-
- public void forceRefreshReferences(BibEntry entry) {
- List references;
- try {
- references = fetcher.searchCiting(entry);
- } catch (FetcherException e) {
- LOGGER.error("Error while fetching references", e);
- references = List.of();
- }
- cache.cacheOrMergeReferences(entry, references);
- }
-}
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationRelationsTab.java b/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationRelationsTab.java
index 97c31f96234..362dae09c16 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationRelationsTab.java
+++ b/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationRelationsTab.java
@@ -37,8 +37,6 @@
import org.jabref.gui.collab.entrychange.PreviewWithSourceTab;
import org.jabref.gui.desktop.os.NativeDesktop;
import org.jabref.gui.entryeditor.EntryEditorTab;
-import org.jabref.gui.entryeditor.citationrelationtab.semanticscholar.CitationFetcher;
-import org.jabref.gui.entryeditor.citationrelationtab.semanticscholar.SemanticScholarFetcher;
import org.jabref.gui.icon.IconTheme;
import org.jabref.gui.mergeentries.EntriesMergeResult;
import org.jabref.gui.mergeentries.MergeEntriesDialog;
@@ -51,8 +49,10 @@
import org.jabref.logic.bibtex.BibEntryWriter;
import org.jabref.logic.bibtex.FieldPreferences;
import org.jabref.logic.bibtex.FieldWriter;
+import org.jabref.logic.citation.SearchCitationsRelationsService;
import org.jabref.logic.database.DuplicateCheck;
import org.jabref.logic.exporter.BibWriter;
+import org.jabref.logic.importer.fetcher.CitationFetcher;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.os.OS;
import org.jabref.logic.util.BackgroundTask;
@@ -92,7 +92,7 @@ public class CitationRelationsTab extends EntryEditorTab {
private final GuiPreferences preferences;
private final LibraryTab libraryTab;
private final TaskExecutor taskExecutor;
- private final BibEntryRelationsRepository bibEntryRelationsRepository;
+ private final SearchCitationsRelationsService searchCitationsRelationsService;
private final CitationsRelationsTabViewModel citationsRelationsTabViewModel;
private final DuplicateCheck duplicateCheck;
private final BibEntryTypesManager entryTypesManager;
@@ -107,7 +107,8 @@ public CitationRelationsTab(DialogService dialogService,
GuiPreferences preferences,
LibraryTab libraryTab,
TaskExecutor taskExecutor,
- BibEntryTypesManager bibEntryTypesManager) {
+ BibEntryTypesManager bibEntryTypesManager,
+ SearchCitationsRelationsService searchCitationsRelationsService) {
this.dialogService = dialogService;
this.databaseContext = databaseContext;
this.preferences = preferences;
@@ -121,9 +122,17 @@ public CitationRelationsTab(DialogService dialogService,
this.entryTypesManager = bibEntryTypesManager;
this.duplicateCheck = new DuplicateCheck(entryTypesManager);
- this.bibEntryRelationsRepository = new BibEntryRelationsRepository(new SemanticScholarFetcher(preferences.getImporterPreferences()),
- new BibEntryRelationsCache());
- citationsRelationsTabViewModel = new CitationsRelationsTabViewModel(databaseContext, preferences, undoManager, stateManager, dialogService, fileUpdateMonitor, taskExecutor);
+ this.searchCitationsRelationsService = searchCitationsRelationsService;
+
+ this.citationsRelationsTabViewModel = new CitationsRelationsTabViewModel(
+ databaseContext,
+ preferences,
+ undoManager,
+ stateManager,
+ dialogService,
+ fileUpdateMonitor,
+ taskExecutor
+ );
}
/**
@@ -197,18 +206,13 @@ private SplitPane getPaneAndStartSearch(BibEntry entry) {
citingVBox.getChildren().addAll(citingHBox, citingListView);
citedByVBox.getChildren().addAll(citedByHBox, citedByListView);
- refreshCitingButton.setOnMouseClicked(event -> searchForRelations(
- entry,
- citingListView,
- abortCitingButton,
- refreshCitingButton,
- CitationFetcher.SearchType.CITES,
- importCitingButton,
- citingProgress,
- true));
+ refreshCitingButton.setOnMouseClicked(event -> {
+ searchForRelations(entry, citingListView, abortCitingButton,
+ refreshCitingButton, CitationFetcher.SearchType.CITES, importCitingButton, citingProgress);
+ });
refreshCitedByButton.setOnMouseClicked(event -> searchForRelations(entry, citedByListView, abortCitedButton,
- refreshCitedByButton, CitationFetcher.SearchType.CITED_BY, importCitedByButton, citedByProgress, true));
+ refreshCitedByButton, CitationFetcher.SearchType.CITED_BY, importCitedByButton, citedByProgress));
// Create SplitPane to hold all nodes above
SplitPane container = new SplitPane(citingVBox, citedByVBox);
@@ -216,10 +220,10 @@ private SplitPane getPaneAndStartSearch(BibEntry entry) {
styleFetchedListView(citingListView);
searchForRelations(entry, citingListView, abortCitingButton, refreshCitingButton,
- CitationFetcher.SearchType.CITES, importCitingButton, citingProgress, false);
+ CitationFetcher.SearchType.CITES, importCitingButton, citingProgress);
searchForRelations(entry, citedByListView, abortCitedButton, refreshCitedByButton,
- CitationFetcher.SearchType.CITED_BY, importCitedByButton, citedByProgress, false);
+ CitationFetcher.SearchType.CITED_BY, importCitedByButton, citedByProgress);
return container;
}
@@ -411,7 +415,7 @@ protected void bindToEntry(BibEntry entry) {
*/
private void searchForRelations(BibEntry entry, CheckListView listView, Button abortButton,
Button refreshButton, CitationFetcher.SearchType searchType, Button importButton,
- ProgressIndicator progress, boolean shouldRefresh) {
+ ProgressIndicator progress) {
if (entry.getDOI().isEmpty()) {
hideNodes(abortButton, progress);
showNodes(refreshButton);
@@ -425,47 +429,61 @@ private void searchForRelations(BibEntry entry, CheckListView> task;
-
- if (searchType == CitationFetcher.SearchType.CITES) {
- task = BackgroundTask.wrap(() -> {
- if (shouldRefresh) {
- bibEntryRelationsRepository.forceRefreshReferences(entry);
- }
- return bibEntryRelationsRepository.getReferences(entry);
- });
- citingTask = task;
- } else {
- task = BackgroundTask.wrap(() -> {
- if (shouldRefresh) {
- bibEntryRelationsRepository.forceRefreshCitations(entry);
- }
- return bibEntryRelationsRepository.getCitations(entry);
- });
- citedByTask = task;
- }
-
- task.onRunning(() -> prepareToSearchForRelations(abortButton, refreshButton, importButton, progress, task))
- .onSuccess(fetchedList -> onSearchForRelationsSucceed(entry, listView, abortButton, refreshButton,
- searchType, importButton, progress, fetchedList, observableList))
+ this.createBackgroundTask(entry, searchType)
+ .consumeOnRunning(task -> prepareToSearchForRelations(
+ abortButton, refreshButton, importButton, progress, task
+ ))
+ .onSuccess(fetchedList -> onSearchForRelationsSucceed(
+ entry,
+ listView,
+ abortButton,
+ refreshButton,
+ searchType,
+ importButton,
+ progress,
+ fetchedList,
+ observableList
+ ))
.onFailure(exception -> {
LOGGER.error("Error while fetching citing Articles", exception);
hideNodes(abortButton, progress, importButton);
listView.setPlaceholder(new Label(Localization.lang("Error while fetching citing entries: %0",
exception.getMessage())));
-
refreshButton.setVisible(true);
dialogService.notify(exception.getMessage());
})
.executeWith(taskExecutor);
}
+ /**
+ * TODO: Make the method return a callable and let the calling method create the background task.
+ */
+ private BackgroundTask> createBackgroundTask(
+ BibEntry entry, CitationFetcher.SearchType searchType
+ ) {
+ return switch (searchType) {
+ case CitationFetcher.SearchType.CITES -> {
+ citingTask = BackgroundTask.wrap(
+ () -> this.searchCitationsRelationsService.searchReferences(entry)
+ );
+ yield citingTask;
+ }
+ case CitationFetcher.SearchType.CITED_BY -> {
+ citedByTask = BackgroundTask.wrap(
+ () -> this.searchCitationsRelationsService.searchCitations(entry)
+ );
+ yield citedByTask;
+ }
+ };
+ }
+
private void onSearchForRelationsSucceed(BibEntry entry, CheckListView listView,
Button abortButton, Button refreshButton,
CitationFetcher.SearchType searchType, Button importButton,
@@ -482,7 +500,7 @@ private void onSearchForRelationsSucceed(BibEntry entry, CheckListView new CitationRelationItem(entr, localEntry, true))
.orElseGet(() -> new CitationRelationItem(entr, false)))
.toList()
- );
+ );
if (!observableList.isEmpty()) {
listView.refresh();
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationsRelationsTabViewModel.java b/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationsRelationsTabViewModel.java
index be404997d09..e413aff6458 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationsRelationsTabViewModel.java
+++ b/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationsRelationsTabViewModel.java
@@ -7,10 +7,10 @@
import org.jabref.gui.DialogService;
import org.jabref.gui.StateManager;
-import org.jabref.gui.entryeditor.citationrelationtab.semanticscholar.CitationFetcher;
import org.jabref.gui.externalfiles.ImportHandler;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.logic.citationkeypattern.CitationKeyGenerator;
+import org.jabref.logic.importer.fetcher.CitationFetcher;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.model.database.BibDatabaseContext;
diff --git a/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTab.fxml b/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTab.fxml
index 86fc1471ee9..adfb1253f28 100644
--- a/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTab.fxml
+++ b/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTab.fxml
@@ -25,6 +25,10 @@
+
+
+
+
diff --git a/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTab.java b/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTab.java
index f842ba1233e..c7d0771b460 100644
--- a/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTab.java
+++ b/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTab.java
@@ -26,6 +26,7 @@
import com.airhacks.afterburner.views.ViewLoader;
import com.tobiasdiez.easybind.EasyBind;
+import org.apache.logging.log4j.util.Strings;
public class WebSearchTab extends AbstractPreferenceTabView implements PreferencesTab {
@@ -34,6 +35,7 @@ public class WebSearchTab extends AbstractPreferenceTabView defaultPlainCitationParser;
+ @FXML private TextField citationsRelationStoreTTL;
@FXML private CheckBox useCustomDOI;
@FXML private TextField useCustomDOIName;
@@ -82,6 +84,25 @@ public void initialize() {
defaultPlainCitationParser.itemsProperty().bind(viewModel.plainCitationParsers());
defaultPlainCitationParser.valueProperty().bindBidirectional(viewModel.defaultPlainCitationParserProperty());
+ viewModel.citationsRelationsStoreTTLProperty()
+ .addListener((observable, oldValue, newValue) -> {
+ if (newValue != null && !newValue.toString().equals(citationsRelationStoreTTL.getText())) {
+ citationsRelationStoreTTL.setText(newValue.toString());
+ }
+ });
+ citationsRelationStoreTTL
+ .textProperty()
+ .addListener((observable, oldValue, newValue) -> {
+ if (Strings.isBlank(newValue)) {
+ return;
+ }
+ if (!newValue.matches("\\d*")) {
+ citationsRelationStoreTTL.setText(newValue.replaceAll("\\D", ""));
+ return;
+ }
+ viewModel.citationsRelationsStoreTTLProperty().set(Integer.parseInt(newValue));
+ });
+
grobidEnabled.selectedProperty().bindBidirectional(viewModel.grobidEnabledProperty());
grobidURL.textProperty().bindBidirectional(viewModel.grobidURLProperty());
grobidURL.disableProperty().bind(grobidEnabled.selectedProperty().not());
diff --git a/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTabViewModel.java b/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTabViewModel.java
index ced3fc6a146..ab6ff1e501f 100644
--- a/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTabViewModel.java
+++ b/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTabViewModel.java
@@ -6,10 +6,12 @@
import java.util.stream.Collectors;
import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
@@ -48,6 +50,8 @@ public class WebSearchTabViewModel implements PreferenceTabViewModel {
new SimpleListProperty<>(FXCollections.observableArrayList(PlainCitationParserChoice.values()));
private final ObjectProperty defaultPlainCitationParser = new SimpleObjectProperty<>();
+ private final IntegerProperty citationsRelationStoreTTL = new SimpleIntegerProperty();
+
private final BooleanProperty useCustomDOIProperty = new SimpleBooleanProperty();
private final StringProperty useCustomDOINameProperty = new SimpleStringProperty("");
@@ -129,6 +133,7 @@ public void setValues() {
shouldDownloadLinkedOnlineFiles.setValue(filePreferences.shouldDownloadLinkedFiles());
shouldkeepDownloadUrl.setValue(filePreferences.shouldKeepDownloadUrl());
defaultPlainCitationParser.setValue(importerPreferences.getDefaultPlainCitationParser());
+ citationsRelationStoreTTL.setValue(importerPreferences.getCitationsRelationsStoreTTL());
useCustomDOIProperty.setValue(doiPreferences.isUseCustom());
useCustomDOINameProperty.setValue(doiPreferences.getDefaultBaseURI());
@@ -160,6 +165,7 @@ public void storeSettings() {
filePreferences.setDownloadLinkedFiles(shouldDownloadLinkedOnlineFiles.getValue());
filePreferences.setKeepDownloadUrl(shouldkeepDownloadUrl.getValue());
importerPreferences.setDefaultPlainCitationParser(defaultPlainCitationParser.getValue());
+ importerPreferences.setCitationsRelationsStoreTTL(citationsRelationStoreTTL.getValue());
grobidPreferences.setGrobidEnabled(grobidEnabledProperty.getValue());
grobidPreferences.setGrobidUseAsked(grobidPreferences.isGrobidUseAsked());
grobidPreferences.setGrobidURL(grobidURLProperty.getValue());
@@ -237,6 +243,10 @@ public BooleanProperty getApikeyPersistProperty() {
return apikeyPersistProperty;
}
+ public IntegerProperty citationsRelationsStoreTTLProperty() {
+ return citationsRelationStoreTTL;
+ }
+
public void checkCustomApiKey() {
final String apiKeyName = selectedApiKeyProperty.get().getName();
diff --git a/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java b/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java
new file mode 100644
index 00000000000..c09ddbc1f20
--- /dev/null
+++ b/src/main/java/org/jabref/logic/citation/SearchCitationsRelationsService.java
@@ -0,0 +1,77 @@
+package org.jabref.logic.citation;
+
+import java.util.List;
+
+import org.jabref.logic.citation.repository.BibEntryRelationsRepository;
+import org.jabref.logic.citation.repository.BibEntryRelationsRepositoryChain;
+import org.jabref.logic.importer.FetcherException;
+import org.jabref.logic.importer.ImporterPreferences;
+import org.jabref.logic.importer.fetcher.CitationFetcher;
+import org.jabref.logic.importer.fetcher.SemanticScholarCitationFetcher;
+import org.jabref.logic.util.Directories;
+import org.jabref.model.entry.BibEntry;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SearchCitationsRelationsService {
+
+ private static final Logger LOGGER = LoggerFactory
+ .getLogger(SearchCitationsRelationsService.class);
+
+ private final CitationFetcher citationFetcher;
+ private final BibEntryRelationsRepository relationsRepository;
+
+ public SearchCitationsRelationsService(ImporterPreferences importerPreferences) {
+ this.citationFetcher = new SemanticScholarCitationFetcher(importerPreferences);
+ this.relationsRepository = BibEntryRelationsRepositoryChain.of(
+ Directories.getCitationsRelationsDirectory(),
+ importerPreferences.getCitationsRelationsStoreTTL()
+ );
+ }
+
+ /**
+ * @implNote Typically, this would be a Shim in JavaFX
+ */
+ @VisibleForTesting
+ public SearchCitationsRelationsService(
+ CitationFetcher citationFetcher, BibEntryRelationsRepository repository
+ ) {
+ this.citationFetcher = citationFetcher;
+ this.relationsRepository = repository;
+ }
+
+ public List searchReferences(BibEntry referencer) {
+ boolean isFetchingAllowed = this.relationsRepository.isReferencesUpdatable(referencer)
+ || !this.relationsRepository.containsReferences(referencer);
+ if (isFetchingAllowed) {
+ try {
+ var references = this.citationFetcher.searchCiting(referencer);
+ this.relationsRepository.insertReferences(referencer, references);
+ } catch (FetcherException e) {
+ LOGGER.error("Error while fetching references for entry {}", referencer.getTitle(), e);
+ }
+ }
+ return this.relationsRepository.readReferences(referencer);
+ }
+
+ /**
+ * If the store was empty and nothing was fetch in any case (empty fetch, or error) then yes => empty list
+ * If the store was not empty and nothing was fetched after a successful fetch => the store will be erased and the returned collection will be empty
+ * If the store was not empty and an error occurs while fetching => will return the content of the store
+ */
+ public List searchCitations(BibEntry cited) {
+ boolean isFetchingAllowed = this.relationsRepository.isCitationsUpdatable(cited)
+ || !this.relationsRepository.containsCitations(cited);
+ if (isFetchingAllowed) {
+ try {
+ var citations = this.citationFetcher.searchCitedBy(cited);
+ this.relationsRepository.insertCitations(cited, citations);
+ } catch (FetcherException e) {
+ LOGGER.error("Error while fetching citations for entry {}", cited.getTitle(), e);
+ }
+ }
+ return this.relationsRepository.readCitations(cited);
+ }
+}
diff --git a/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationRepository.java b/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationRepository.java
new file mode 100644
index 00000000000..4fbb6bad1bd
--- /dev/null
+++ b/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationRepository.java
@@ -0,0 +1,24 @@
+package org.jabref.logic.citation.repository;
+
+import java.util.List;
+
+import org.jabref.model.entry.BibEntry;
+
+/**
+ * Generic interface for a repository that stores relations between BibEntries.
+ */
+public interface BibEntryRelationRepository {
+
+ List getRelations(BibEntry entry);
+
+ /**
+ * Adds the given relations to the entry. Appends to existing relations.
+ */
+ void addRelations(BibEntry entry, List relations);
+
+ boolean containsKey(BibEntry entry);
+
+ default boolean isUpdatable(BibEntry entry) {
+ return true;
+ }
+}
diff --git a/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationRepositoryChain.java b/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationRepositoryChain.java
new file mode 100644
index 00000000000..46f728c3f5e
--- /dev/null
+++ b/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationRepositoryChain.java
@@ -0,0 +1,59 @@
+package org.jabref.logic.citation.repository;
+
+import java.util.List;
+
+import org.jabref.model.entry.BibEntry;
+
+public class BibEntryRelationRepositoryChain implements BibEntryRelationRepository {
+
+ private static final BibEntryRelationRepository EMPTY = new BibEntryRelationRepositoryChain(null, null);
+
+ private final BibEntryRelationRepository current;
+ private final BibEntryRelationRepository next;
+
+ BibEntryRelationRepositoryChain(BibEntryRelationRepository current, BibEntryRelationRepository next) {
+ this.current = current;
+ this.next = next;
+ }
+
+ @Override
+ public List getRelations(BibEntry entry) {
+ if (this.current.containsKey(entry)) {
+ return this.current.getRelations(entry);
+ }
+ if (this.next == EMPTY) {
+ return List.of();
+ }
+ var relations = this.next.getRelations(entry);
+ this.current.addRelations(entry, relations);
+ // Makes sure to obtain a copy and not a direct reference to what was inserted
+ return this.current.getRelations(entry);
+ }
+
+ @Override
+ public void addRelations(BibEntry entry, List relations) {
+ if (this.next != EMPTY) {
+ this.next.addRelations(entry, relations);
+ }
+ this.current.addRelations(entry, relations);
+ }
+
+ @Override
+ public boolean containsKey(BibEntry entry) {
+ return this.current.containsKey(entry)
+ || (this.next != EMPTY && this.next.containsKey(entry));
+ }
+
+ @Override
+ public boolean isUpdatable(BibEntry entry) {
+ return this.current.isUpdatable(entry)
+ && (this.next == EMPTY || this.next.isUpdatable(entry));
+ }
+
+ public static BibEntryRelationRepository of(BibEntryRelationRepository... dao) {
+ return List.of(dao)
+ .reversed()
+ .stream()
+ .reduce(EMPTY, (acc, current) -> new BibEntryRelationRepositoryChain(current, acc));
+ }
+}
diff --git a/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationsRepository.java b/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationsRepository.java
new file mode 100644
index 00000000000..cb99b791cad
--- /dev/null
+++ b/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationsRepository.java
@@ -0,0 +1,24 @@
+package org.jabref.logic.citation.repository;
+
+import java.util.List;
+
+import org.jabref.model.entry.BibEntry;
+
+public interface BibEntryRelationsRepository {
+
+ void insertCitations(BibEntry entry, List citations);
+
+ List readCitations(BibEntry entry);
+
+ boolean containsCitations(BibEntry entry);
+
+ boolean isCitationsUpdatable(BibEntry entry);
+
+ void insertReferences(BibEntry entry, List citations);
+
+ List readReferences(BibEntry entry);
+
+ boolean containsReferences(BibEntry entry);
+
+ boolean isReferencesUpdatable(BibEntry entry);
+}
diff --git a/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryChain.java b/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryChain.java
new file mode 100644
index 00000000000..29dd8f35a04
--- /dev/null
+++ b/src/main/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryChain.java
@@ -0,0 +1,83 @@
+package org.jabref.logic.citation.repository;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Objects;
+
+import org.jabref.model.entry.BibEntry;
+
+public class BibEntryRelationsRepositoryChain implements BibEntryRelationsRepository {
+
+ private static final String CITATIONS_STORE = "citations";
+ private static final String REFERENCES_STORE = "references";
+
+ private final BibEntryRelationRepository citationsDao;
+ private final BibEntryRelationRepository referencesDao;
+
+ public BibEntryRelationsRepositoryChain(Path citationsStore, Path relationsStore, int storeTTL) {
+ this.citationsDao = BibEntryRelationRepositoryChain.of(
+ LRUCacheBibEntryRelationsRepository.CITATIONS,
+ new MVStoreBibEntryRelationRepository(citationsStore, CITATIONS_STORE, storeTTL)
+ );
+ this.referencesDao = BibEntryRelationRepositoryChain.of(
+ LRUCacheBibEntryRelationsRepository.REFERENCES,
+ new MVStoreBibEntryRelationRepository(relationsStore, REFERENCES_STORE, storeTTL)
+ );
+ }
+
+ @Override
+ public void insertCitations(BibEntry entry, List citations) {
+ citationsDao.addRelations(
+ entry, Objects.requireNonNullElseGet(citations, List::of)
+ );
+ }
+
+ @Override
+ public List readCitations(BibEntry entry) {
+ if (entry == null) {
+ return List.of();
+ }
+ return citationsDao.getRelations(entry);
+ }
+
+ @Override
+ public boolean containsCitations(BibEntry entry) {
+ return citationsDao.containsKey(entry);
+ }
+
+ @Override
+ public boolean isCitationsUpdatable(BibEntry entry) {
+ return citationsDao.isUpdatable(entry);
+ }
+
+ @Override
+ public void insertReferences(BibEntry entry, List references) {
+ referencesDao.addRelations(
+ entry, Objects.requireNonNullElseGet(references, List::of)
+ );
+ }
+
+ @Override
+ public List readReferences(BibEntry entry) {
+ if (entry == null) {
+ return List.of();
+ }
+ return referencesDao.getRelations(entry);
+ }
+
+ @Override
+ public boolean containsReferences(BibEntry entry) {
+ return referencesDao.containsKey(entry);
+ }
+
+ @Override
+ public boolean isReferencesUpdatable(BibEntry entry) {
+ return referencesDao.isUpdatable(entry);
+ }
+
+ public static BibEntryRelationsRepositoryChain of(Path citationsRelationsDirectory, int storeTTL) {
+ var citationsPath = citationsRelationsDirectory.resolve("%s.mv".formatted(CITATIONS_STORE));
+ var relationsPath = citationsRelationsDirectory.resolve("%s.mv".formatted(REFERENCES_STORE));
+ return new BibEntryRelationsRepositoryChain(citationsPath, relationsPath, storeTTL);
+ }
+}
diff --git a/src/main/java/org/jabref/logic/citation/repository/LRUCacheBibEntryRelationsRepository.java b/src/main/java/org/jabref/logic/citation/repository/LRUCacheBibEntryRelationsRepository.java
new file mode 100644
index 00000000000..c3229c3de30
--- /dev/null
+++ b/src/main/java/org/jabref/logic/citation/repository/LRUCacheBibEntryRelationsRepository.java
@@ -0,0 +1,56 @@
+package org.jabref.logic.citation.repository;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.identifier.DOI;
+
+import org.eclipse.jgit.util.LRUMap;
+
+import static org.jabref.logic.citation.repository.LRUCacheBibEntryRelationsRepository.Configuration.MAX_CACHED_ENTRIES;
+
+public enum LRUCacheBibEntryRelationsRepository implements BibEntryRelationRepository {
+
+ CITATIONS(new LRUMap<>(MAX_CACHED_ENTRIES, MAX_CACHED_ENTRIES)),
+ REFERENCES(new LRUMap<>(MAX_CACHED_ENTRIES, MAX_CACHED_ENTRIES));
+
+ public static class Configuration {
+ public static final int MAX_CACHED_ENTRIES = 128; // Let's use a power of two for sizing
+ }
+
+ private final Map> relationsMap;
+
+ LRUCacheBibEntryRelationsRepository(Map> relationsMap) {
+ this.relationsMap = relationsMap;
+ }
+
+ @Override
+ public List getRelations(BibEntry entry) {
+ return entry
+ .getDOI()
+ .stream()
+ .flatMap(doi -> this.relationsMap.getOrDefault(doi, Set.of()).stream())
+ .toList();
+ }
+
+ @Override
+ public synchronized void addRelations(BibEntry entry, List relations) {
+ entry.getDOI().ifPresent(doi -> {
+ var cachedRelations = this.relationsMap.getOrDefault(doi, new LinkedHashSet<>());
+ cachedRelations.addAll(relations);
+ relationsMap.put(doi, cachedRelations);
+ });
+ }
+
+ @Override
+ public boolean containsKey(BibEntry entry) {
+ return entry.getDOI().map(this.relationsMap::containsKey).orElse(false);
+ }
+
+ public void clearEntries() {
+ this.relationsMap.clear();
+ }
+}
diff --git a/src/main/java/org/jabref/logic/citation/repository/MVStoreBibEntryRelationRepository.java b/src/main/java/org/jabref/logic/citation/repository/MVStoreBibEntryRelationRepository.java
new file mode 100644
index 00000000000..58d60d5a7f1
--- /dev/null
+++ b/src/main/java/org/jabref/logic/citation/repository/MVStoreBibEntryRelationRepository.java
@@ -0,0 +1,270 @@
+package org.jabref.logic.citation.repository;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.jabref.logic.importer.ImportFormatPreferences;
+import org.jabref.logic.importer.ParseException;
+import org.jabref.logic.importer.fileformat.BibtexParser;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.BibEntryPreferences;
+import org.jabref.model.entry.field.Field;
+import org.jabref.model.entry.field.UnknownField;
+
+import com.google.common.annotations.VisibleForTesting;
+import org.h2.mvstore.MVMap;
+import org.h2.mvstore.MVStore;
+import org.h2.mvstore.WriteBuffer;
+import org.h2.mvstore.type.BasicDataType;
+import org.jspecify.annotations.NonNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class is responsible for storing and retrieving relations between BibEntry objects.
+ * It uses an MVStore to store the relations.
+ */
+public class MVStoreBibEntryRelationRepository implements BibEntryRelationRepository {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(MVStoreBibEntryRelationRepository.class);
+
+ private final static ZoneId TIME_STAMP_ZONE_ID = ZoneId.of("UTC");
+ private final static String TIME_STAMP_SUFFIX = "-insertion-timestamp";
+
+ private final String mapName;
+ private final String insertionTimeStampMapName;
+ private final MVStore.Builder storeConfiguration;
+ private final int storeTTLInDays;
+ private final MVMap.Builder> mapConfiguration;
+
+ MVStoreBibEntryRelationRepository(Path path, String mapName, int storeTTLInDays) {
+ this(
+ path,
+ mapName,
+ storeTTLInDays,
+ new MVStoreBibEntryRelationRepository.BibEntryHashSetSerializer()
+ );
+ }
+
+ MVStoreBibEntryRelationRepository(
+ Path path, String mapName, int storeTTLInDays, BasicDataType> serializer
+ ) {
+ try {
+ Files.createDirectories(path.getParent());
+ if (!Files.exists(path)) {
+ Files.createFile(path);
+ }
+ } catch (IOException e) {
+ LOGGER.error("An error occurred while opening {} storage", mapName, e);
+ }
+
+ this.mapName = mapName;
+ this.insertionTimeStampMapName = mapName + TIME_STAMP_SUFFIX;
+ this.storeConfiguration = new MVStore.Builder()
+ .autoCommitDisabled()
+ .fileName(path.toAbsolutePath().toString());
+ this.storeTTLInDays = storeTTLInDays;
+ this.mapConfiguration = new MVMap.Builder>().valueType(serializer);
+ }
+
+ @Override
+ public List getRelations(BibEntry entry) {
+ return entry
+ .getDOI()
+ .map(doi -> {
+ try (var store = this.storeConfiguration.open()) {
+ MVMap> relationsMap = store.openMap(mapName, mapConfiguration);
+ return relationsMap.getOrDefault(doi.asString(), new LinkedHashSet<>()).stream().toList();
+ }
+ })
+ .orElse(List.of());
+ }
+
+ /**
+ * Allows insertion of empty list in order to keep track of insertion date for an entry.
+ */
+ @Override
+ synchronized public void addRelations(@NonNull BibEntry entry, @NonNull List relations) {
+ entry.getDOI().ifPresent(doi -> {
+ try (var store = this.storeConfiguration.open()) {
+ // Save the relations
+ // FIXME: This is costing much performance - store should be stared only once
+ MVMap> relationsMap = store.openMap(mapName, mapConfiguration);
+ var relationsAlreadyStored = relationsMap.getOrDefault(doi.asString(), new LinkedHashSet<>());
+ relationsAlreadyStored.addAll(relations);
+ relationsMap.put(doi.asString(), relationsAlreadyStored);
+
+ // Save insertion timestamp
+ var insertionTime = LocalDateTime.now(TIME_STAMP_ZONE_ID);
+ MVMap insertionTimeStampMap = store.openMap(insertionTimeStampMapName);
+ insertionTimeStampMap.put(doi.asString(), insertionTime);
+
+ // Commit
+ store.commit();
+ }
+ });
+ }
+
+ @Override
+ synchronized public boolean containsKey(BibEntry entry) {
+ return entry
+ .getDOI()
+ .map(doi -> {
+ try (var store = this.storeConfiguration.open()) {
+ MVMap> relationsMap = store.openMap(mapName, mapConfiguration);
+ return relationsMap.containsKey(doi.asString());
+ }
+ })
+ .orElse(false);
+ }
+
+ @Override
+ synchronized public boolean isUpdatable(BibEntry entry) {
+ var clock = Clock.system(TIME_STAMP_ZONE_ID);
+ return this.isUpdatable(entry, clock);
+ }
+
+ @VisibleForTesting
+ boolean isUpdatable(final BibEntry entry, final Clock clock) {
+ final var executionTime = LocalDateTime.now(clock);
+ return entry
+ .getDOI()
+ .map(doi -> {
+ try (var store = this.storeConfiguration.open()) {
+ MVMap insertionTimeStampMap = store.openMap(insertionTimeStampMapName);
+ return insertionTimeStampMap.getOrDefault(doi.asString(), executionTime);
+ }
+ })
+ .map(lastExecutionTime -> lastExecutionTime.equals(executionTime)
+ || lastExecutionTime.isBefore(executionTime.minusDays(this.storeTTLInDays))
+ )
+ .orElse(true);
+ }
+
+ static class BibEntrySerializer extends BasicDataType {
+
+ private final List fieldsToRemoveFromSerializedEntry = List.of(new UnknownField("_jabref_shared"));
+
+ private static String toString(BibEntry entry) {
+ return entry.toString();
+ }
+
+ private static Optional fromString(String serializedString, List fieldsToRemove) {
+ try {
+ var importFormatPreferences = new ImportFormatPreferences(
+ new BibEntryPreferences('$'), null, null, null, null, null
+ );
+ return BibtexParser
+ .singleFromString(serializedString, importFormatPreferences)
+ .map(entry -> {
+ fieldsToRemove.forEach(entry::clearField);
+ return entry;
+ });
+ } catch (ParseException e) {
+ LOGGER.error("An error occurred while parsing from relation MV store.", e);
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public int getMemory(BibEntry obj) {
+ return toString(obj).getBytes(StandardCharsets.UTF_8).length;
+ }
+
+ @Override
+ public void write(WriteBuffer buff, BibEntry bibEntry) {
+ var asBytes = toString(bibEntry).getBytes(StandardCharsets.UTF_8);
+ buff.putInt(asBytes.length);
+ buff.put(asBytes);
+ }
+
+ @Override
+ public BibEntry read(ByteBuffer buff) {
+ int serializedEntrySize = buff.getInt();
+ var serializedEntry = new byte[serializedEntrySize];
+ buff.get(serializedEntry);
+ return fromString(
+ new String(serializedEntry, StandardCharsets.UTF_8),
+ this.fieldsToRemoveFromSerializedEntry
+ )
+ .orElse(new BibEntry());
+ }
+
+ @Override
+ public int compare(BibEntry a, BibEntry b) {
+ if (a == null || b == null) {
+ throw new NullPointerException();
+ }
+ return toString(a).compareTo(toString(b));
+ }
+
+ @Override
+ public BibEntry[] createStorage(int size) {
+ return new BibEntry[size];
+ }
+
+ @Override
+ public boolean isMemoryEstimationAllowed() {
+ return false;
+ }
+ }
+
+ static class BibEntryHashSetSerializer extends BasicDataType> {
+
+ private final BasicDataType bibEntryDataType;
+
+ BibEntryHashSetSerializer() {
+ this.bibEntryDataType = new BibEntrySerializer();
+ }
+
+ BibEntryHashSetSerializer(BasicDataType bibEntryDataType) {
+ this.bibEntryDataType = bibEntryDataType;
+ }
+
+ @Override
+ public int getMemory(LinkedHashSet bibEntries) {
+ // Memory size is the sum of all aggregated bibEntries' memory size plus 4 bytes.
+ // Those 4 bytes are used to store the length of the collection itself.
+ return bibEntries
+ .stream()
+ .map(this.bibEntryDataType::getMemory)
+ .reduce(0, Integer::sum) + 4;
+ }
+
+ @Override
+ public void write(WriteBuffer buff, LinkedHashSet bibEntries) {
+ buff.putInt(bibEntries.size());
+ bibEntries.forEach(entry -> this.bibEntryDataType.write(buff, entry));
+ }
+
+ @Override
+ public LinkedHashSet read(ByteBuffer buff) {
+ return IntStream.range(0, buff.getInt())
+ .mapToObj(it -> this.bibEntryDataType.read(buff))
+ .filter(entry -> !entry.isEmpty())
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public LinkedHashSet[] createStorage(int size) {
+ return (LinkedHashSet[]) new LinkedHashSet[size];
+ }
+
+ @Override
+ public boolean isMemoryEstimationAllowed() {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/org/jabref/logic/importer/ImporterPreferences.java b/src/main/java/org/jabref/logic/importer/ImporterPreferences.java
index 9f7a7923d6a..709b02b9005 100644
--- a/src/main/java/org/jabref/logic/importer/ImporterPreferences.java
+++ b/src/main/java/org/jabref/logic/importer/ImporterPreferences.java
@@ -7,8 +7,10 @@
import java.util.Set;
import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -29,6 +31,7 @@ public class ImporterPreferences {
private final BooleanProperty persistCustomKeys;
private final ObservableList catalogs;
private final ObjectProperty defaultPlainCitationParser;
+ private final IntegerProperty citationsRelationsStoreTTL;
public ImporterPreferences(boolean importerEnabled,
boolean generateNewKeyOnImport,
@@ -39,7 +42,8 @@ public ImporterPreferences(boolean importerEnabled,
Map defaultApiKeys,
boolean persistCustomKeys,
List catalogs,
- PlainCitationParserChoice defaultPlainCitationParser
+ PlainCitationParserChoice defaultPlainCitationParser,
+ int citationsRelationsStoreTTL
) {
this.importerEnabled = new SimpleBooleanProperty(importerEnabled);
this.generateNewKeyOnImport = new SimpleBooleanProperty(generateNewKeyOnImport);
@@ -51,6 +55,7 @@ public ImporterPreferences(boolean importerEnabled,
this.persistCustomKeys = new SimpleBooleanProperty(persistCustomKeys);
this.catalogs = FXCollections.observableArrayList(catalogs);
this.defaultPlainCitationParser = new SimpleObjectProperty<>(defaultPlainCitationParser);
+ this.citationsRelationsStoreTTL = new SimpleIntegerProperty(citationsRelationsStoreTTL);
}
public boolean areImporterEnabled() {
@@ -159,4 +164,16 @@ public ObjectProperty defaultPlainCitationParserPrope
public void setDefaultPlainCitationParser(PlainCitationParserChoice defaultPlainCitationParser) {
this.defaultPlainCitationParser.set(defaultPlainCitationParser);
}
+
+ public int getCitationsRelationsStoreTTL() {
+ return this.citationsRelationsStoreTTL.get();
+ }
+
+ public IntegerProperty citationsRelationsStoreTTLProperty() {
+ return this.citationsRelationsStoreTTL;
+ }
+
+ public void setCitationsRelationsStoreTTL(int citationsRelationsStoreTTL) {
+ this.citationsRelationsStoreTTL.set(citationsRelationsStoreTTL);
+ }
}
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationFetcher.java b/src/main/java/org/jabref/logic/importer/fetcher/CitationFetcher.java
similarity index 94%
rename from src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationFetcher.java
rename to src/main/java/org/jabref/logic/importer/fetcher/CitationFetcher.java
index 1b87c7ab0bb..58c4f32d080 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationFetcher.java
+++ b/src/main/java/org/jabref/logic/importer/fetcher/CitationFetcher.java
@@ -1,4 +1,4 @@
-package org.jabref.gui.entryeditor.citationrelationtab.semanticscholar;
+package org.jabref.logic.importer.fetcher;
import java.util.List;
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/SemanticScholarFetcher.java b/src/main/java/org/jabref/logic/importer/fetcher/SemanticScholarCitationFetcher.java
similarity index 89%
rename from src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/SemanticScholarFetcher.java
rename to src/main/java/org/jabref/logic/importer/fetcher/SemanticScholarCitationFetcher.java
index c085fbc50bd..3f0d8cbd6f8 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/SemanticScholarFetcher.java
+++ b/src/main/java/org/jabref/logic/importer/fetcher/SemanticScholarCitationFetcher.java
@@ -1,4 +1,4 @@
-package org.jabref.gui.entryeditor.citationrelationtab.semanticscholar;
+package org.jabref.logic.importer.fetcher;
import java.net.MalformedURLException;
import java.net.URL;
@@ -6,21 +6,22 @@
import org.jabref.logic.importer.FetcherException;
import org.jabref.logic.importer.ImporterPreferences;
-import org.jabref.logic.importer.fetcher.CustomizableKeyFetcher;
import org.jabref.logic.net.URLDownload;
import org.jabref.logic.util.URLUtil;
+import org.jabref.model.citation.semanticscholar.CitationsResponse;
+import org.jabref.model.citation.semanticscholar.ReferencesResponse;
import org.jabref.model.entry.BibEntry;
import com.google.gson.Gson;
-public class SemanticScholarFetcher implements CitationFetcher, CustomizableKeyFetcher {
+public class SemanticScholarCitationFetcher implements CitationFetcher, CustomizableKeyFetcher {
public static final String FETCHER_NAME = "Semantic Scholar Citations Fetcher";
private static final String SEMANTIC_SCHOLAR_API = "https://api.semanticscholar.org/graph/v1/";
private final ImporterPreferences importerPreferences;
- public SemanticScholarFetcher(ImporterPreferences importerPreferences) {
+ public SemanticScholarCitationFetcher(ImporterPreferences importerPreferences) {
this.importerPreferences = importerPreferences;
}
diff --git a/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java b/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java
index b350ebebb24..221eb20e0f4 100644
--- a/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java
+++ b/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java
@@ -31,7 +31,6 @@
import javafx.collections.ListChangeListener;
import javafx.collections.SetChangeListener;
-import org.jabref.gui.entryeditor.citationrelationtab.semanticscholar.SemanticScholarFetcher;
import org.jabref.logic.FilePreferences;
import org.jabref.logic.InternalPreferences;
import org.jabref.logic.JabRefException;
@@ -60,6 +59,7 @@
import org.jabref.logic.importer.fetcher.IEEE;
import org.jabref.logic.importer.fetcher.MrDlibPreferences;
import org.jabref.logic.importer.fetcher.ScienceDirect;
+import org.jabref.logic.importer.fetcher.SemanticScholarCitationFetcher;
import org.jabref.logic.importer.fetcher.SpringerFetcher;
import org.jabref.logic.importer.fileformat.CustomImporter;
import org.jabref.logic.importer.plaincitation.PlainCitationParserChoice;
@@ -220,6 +220,7 @@ public class JabRefCliPreferences implements CliPreferences {
public static final String SEARCH_WINDOW_DIVIDER_POS = "searchWindowDividerPos";
public static final String SEARCH_CATALOGS = "searchCatalogs";
public static final String DEFAULT_PLAIN_CITATION_PARSER = "defaultPlainCitationParser";
+ public static final String CITATIONS_RELATIONS_STORE_TTL = "citationsRelationsStoreTTL";
public static final String IMPORTERS_ENABLED = "importersEnabled";
public static final String GENERATE_KEY_ON_IMPORT = "generateKeyOnImport";
public static final String GROBID_ENABLED = "grobidEnabled";
@@ -460,6 +461,7 @@ protected JabRefCliPreferences() {
defaults.put(DEFAULT_PLAIN_CITATION_PARSER, PlainCitationParserChoice.RULE_BASED.name());
defaults.put(IMPORTERS_ENABLED, Boolean.TRUE);
defaults.put(GENERATE_KEY_ON_IMPORT, Boolean.TRUE);
+ defaults.put(CITATIONS_RELATIONS_STORE_TTL, 30);
// region: Grobid
defaults.put(GROBID_ENABLED, Boolean.FALSE);
@@ -2045,7 +2047,8 @@ public ImporterPreferences getImporterPreferences() {
getDefaultFetcherKeys(),
getBoolean(FETCHER_CUSTOM_KEY_PERSIST),
getStringList(SEARCH_CATALOGS),
- PlainCitationParserChoice.valueOf(get(DEFAULT_PLAIN_CITATION_PARSER))
+ PlainCitationParserChoice.valueOf(get(DEFAULT_PLAIN_CITATION_PARSER)),
+ getInt(CITATIONS_RELATIONS_STORE_TTL)
);
EasyBind.listen(importerPreferences.importerEnabledProperty(), (obs, oldValue, newValue) -> putBoolean(IMPORTERS_ENABLED, newValue));
@@ -2057,6 +2060,7 @@ public ImporterPreferences getImporterPreferences() {
importerPreferences.getCustomImporters().addListener((InvalidationListener) c -> storeCustomImportFormats(importerPreferences.getCustomImporters()));
importerPreferences.getCatalogs().addListener((InvalidationListener) c -> putStringList(SEARCH_CATALOGS, importerPreferences.getCatalogs()));
EasyBind.listen(importerPreferences.defaultPlainCitationParserProperty(), (obs, oldValue, newValue) -> put(DEFAULT_PLAIN_CITATION_PARSER, newValue.name()));
+ EasyBind.listen(importerPreferences.citationsRelationsStoreTTLProperty(), (obs, oldValue, newValue) -> put(CITATIONS_RELATIONS_STORE_TTL, newValue.toString()));
return importerPreferences;
}
@@ -2138,7 +2142,7 @@ private Map getDefaultFetcherKeys() {
}
Map keys = new HashMap<>();
- keys.put(SemanticScholarFetcher.FETCHER_NAME, buildInfo.semanticScholarApiKey);
+ keys.put(SemanticScholarCitationFetcher.FETCHER_NAME, buildInfo.semanticScholarApiKey);
keys.put(AstrophysicsDataSystem.FETCHER_NAME, buildInfo.astrophysicsDataSystemAPIKey);
keys.put(BiodiversityLibrary.FETCHER_NAME, buildInfo.biodiversityHeritageApiKey);
keys.put(ScienceDirect.FETCHER_NAME, buildInfo.scienceDirectApiKey);
diff --git a/src/main/java/org/jabref/logic/util/BackgroundTask.java b/src/main/java/org/jabref/logic/util/BackgroundTask.java
index 1a905432945..11e2b4083e4 100644
--- a/src/main/java/org/jabref/logic/util/BackgroundTask.java
+++ b/src/main/java/org/jabref/logic/util/BackgroundTask.java
@@ -172,6 +172,16 @@ public BackgroundTask onRunning(Runnable onRunning) {
return this;
}
+ /**
+ * Curry a consumer to on an on running runnable and invoke it after the task is started.
+ *
+ * @param onRunningConsumer should not be null
+ * @see BackgroundTask#consumeOnRunning(Consumer)
+ */
+ public BackgroundTask consumeOnRunning(Consumer> onRunningConsumer) {
+ return this.onRunning(() -> onRunningConsumer.accept(this));
+ }
+
/**
* Sets the {@link Consumer} that is invoked after the task is successfully finished.
* The consumer always runs on the JavaFX thread.
diff --git a/src/main/java/org/jabref/logic/util/Directories.java b/src/main/java/org/jabref/logic/util/Directories.java
index e87666d749a..3619edd5ab3 100644
--- a/src/main/java/org/jabref/logic/util/Directories.java
+++ b/src/main/java/org/jabref/logic/util/Directories.java
@@ -62,4 +62,13 @@ public static Path getSslDirectory() {
"ssl",
OS.APP_DIR_APP_AUTHOR));
}
+
+ public static Path getCitationsRelationsDirectory() {
+ return Path.of(
+ AppDirsFactory.getInstance()
+ .getUserDataDir(
+ OS.APP_DIR_APP_NAME,
+ "relations",
+ OS.APP_DIR_APP_AUTHOR));
+ }
}
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/AuthorResponse.java b/src/main/java/org/jabref/model/citation/semanticscholar/AuthorResponse.java
similarity index 84%
rename from src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/AuthorResponse.java
rename to src/main/java/org/jabref/model/citation/semanticscholar/AuthorResponse.java
index 539b99cc39d..8489099a4fb 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/AuthorResponse.java
+++ b/src/main/java/org/jabref/model/citation/semanticscholar/AuthorResponse.java
@@ -1,4 +1,4 @@
-package org.jabref.gui.entryeditor.citationrelationtab.semanticscholar;
+package org.jabref.model.citation.semanticscholar;
/**
* Used for GSON
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationDataItem.java b/src/main/java/org/jabref/model/citation/semanticscholar/CitationDataItem.java
similarity index 79%
rename from src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationDataItem.java
rename to src/main/java/org/jabref/model/citation/semanticscholar/CitationDataItem.java
index 684285b46df..8f9d44535e9 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationDataItem.java
+++ b/src/main/java/org/jabref/model/citation/semanticscholar/CitationDataItem.java
@@ -1,4 +1,4 @@
-package org.jabref.gui.entryeditor.citationrelationtab.semanticscholar;
+package org.jabref.model.citation.semanticscholar;
/**
* Used for GSON
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationsResponse.java b/src/main/java/org/jabref/model/citation/semanticscholar/CitationsResponse.java
similarity index 89%
rename from src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationsResponse.java
rename to src/main/java/org/jabref/model/citation/semanticscholar/CitationsResponse.java
index 999eb7eca2a..8fdbec26948 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/CitationsResponse.java
+++ b/src/main/java/org/jabref/model/citation/semanticscholar/CitationsResponse.java
@@ -1,4 +1,4 @@
-package org.jabref.gui.entryeditor.citationrelationtab.semanticscholar;
+package org.jabref.model.citation.semanticscholar;
import java.util.List;
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/PaperDetails.java b/src/main/java/org/jabref/model/citation/semanticscholar/PaperDetails.java
similarity index 98%
rename from src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/PaperDetails.java
rename to src/main/java/org/jabref/model/citation/semanticscholar/PaperDetails.java
index 58ba269616e..073e15f384d 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/PaperDetails.java
+++ b/src/main/java/org/jabref/model/citation/semanticscholar/PaperDetails.java
@@ -1,4 +1,4 @@
-package org.jabref.gui.entryeditor.citationrelationtab.semanticscholar;
+package org.jabref.model.citation.semanticscholar;
import java.util.List;
import java.util.Map;
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/ReferenceDataItem.java b/src/main/java/org/jabref/model/citation/semanticscholar/ReferenceDataItem.java
similarity index 70%
rename from src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/ReferenceDataItem.java
rename to src/main/java/org/jabref/model/citation/semanticscholar/ReferenceDataItem.java
index b9c53c355e9..ccbb170355c 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/ReferenceDataItem.java
+++ b/src/main/java/org/jabref/model/citation/semanticscholar/ReferenceDataItem.java
@@ -1,4 +1,4 @@
-package org.jabref.gui.entryeditor.citationrelationtab.semanticscholar;
+package org.jabref.model.citation.semanticscholar;
/**
* Used for GSON
diff --git a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/ReferencesResponse.java b/src/main/java/org/jabref/model/citation/semanticscholar/ReferencesResponse.java
similarity index 89%
rename from src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/ReferencesResponse.java
rename to src/main/java/org/jabref/model/citation/semanticscholar/ReferencesResponse.java
index 0a6ac34af07..a0f9c6426a3 100644
--- a/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/semanticscholar/ReferencesResponse.java
+++ b/src/main/java/org/jabref/model/citation/semanticscholar/ReferencesResponse.java
@@ -1,4 +1,4 @@
-package org.jabref.gui.entryeditor.citationrelationtab.semanticscholar;
+package org.jabref.model.citation.semanticscholar;
import java.util.List;
diff --git a/src/main/java/org/jabref/model/entry/BibEntry.java b/src/main/java/org/jabref/model/entry/BibEntry.java
index ade92e89575..0976bb24a5e 100644
--- a/src/main/java/org/jabref/model/entry/BibEntry.java
+++ b/src/main/java/org/jabref/model/entry/BibEntry.java
@@ -1,5 +1,6 @@
package org.jabref.model.entry;
+import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -96,7 +97,7 @@
*
*/
@AllowedToUseLogic("because it needs access to parser and writers")
-public class BibEntry implements Cloneable {
+public class BibEntry implements Cloneable, Serializable {
public static final EntryType DEFAULT_TYPE = StandardEntryType.Misc;
private static final Logger LOGGER = LoggerFactory.getLogger(BibEntry.class);
@@ -999,6 +1000,11 @@ public BibEntry withMonth(Month parsedMonth) {
return this;
}
+ public BibEntry withType(EntryType type) {
+ this.setType(type);
+ return this;
+ }
+
/*
* Returns user comments (arbitrary text before the entry), if they exist. If not, returns the empty String
*/
diff --git a/src/main/resources/l10n/JabRef_de.properties b/src/main/resources/l10n/JabRef_de.properties
index 280a5088912..fbdb9ac2cd0 100644
--- a/src/main/resources/l10n/JabRef_de.properties
+++ b/src/main/resources/l10n/JabRef_de.properties
@@ -2823,3 +2823,4 @@ Citation\ Entry=Zitationseintrag
File\ Move\ Errors=Fehler beim Verschieben von Dateien
Could\ not\ move\ file\ %0.\ Please\ close\ this\ file\ and\ retry.=Datei %0 konnte nicht verschoben werden. Bitte schließen Sie diese Datei und versuchen Sie es erneut.
+Citations\ relations\ local\ storage\ time-to-live\ (in\ days)=Speicherdauer für lokale Zitationsbeziehungen (in Tagen)
diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties
index eb0f31267d0..64bb8815dd1 100644
--- a/src/main/resources/l10n/JabRef_en.properties
+++ b/src/main/resources/l10n/JabRef_en.properties
@@ -2856,3 +2856,5 @@ Include\ or\ exclude\ cross-referenced\ entries=Include or exclude cross-referen
Would\ you\ like\ to\ include\ cross-reference\ entries\ in\ the\ current\ operation?=Would you like to include cross-reference entries in the current operation?
Entries\ copied\ successfully,\ including\ cross-references.=Entries copied successfully, including cross-references.
Entries\ copied\ successfully,\ without\ cross-references.=Entries copied successfully, without cross-references.
+
+Citations\ relations\ local\ storage\ time-to-live\ (in\ days)=Citations relations local storage time-to-live (in days)
diff --git a/src/main/resources/l10n/JabRef_fr.properties b/src/main/resources/l10n/JabRef_fr.properties
index 1d23d64a174..a45d92e5f6a 100644
--- a/src/main/resources/l10n/JabRef_fr.properties
+++ b/src/main/resources/l10n/JabRef_fr.properties
@@ -2856,3 +2856,5 @@ Include\ or\ exclude\ cross-referenced\ entries=Inclure ou exclure les entrées
Would\ you\ like\ to\ include\ cross-reference\ entries\ in\ the\ current\ operation?=Voulez-vous inclure les entrées de références croisées dans l'opération en cours ?
Entries\ copied\ successfully,\ including\ cross-references.=Entrées copiées avec succès, y compris les références croisées.
Entries\ copied\ successfully,\ without\ cross-references.=Entrées copiées avec succès, sans les références croisées.
+
+Citations\ relations\ local\ storage\ time-to-live\ (in\ days)=Durée de vie du stockage local des relations de citations (en jours)
diff --git a/src/main/resources/l10n/JabRef_it.properties b/src/main/resources/l10n/JabRef_it.properties
index bf2bfb5d92d..1dc2357506a 100644
--- a/src/main/resources/l10n/JabRef_it.properties
+++ b/src/main/resources/l10n/JabRef_it.properties
@@ -2828,3 +2828,5 @@ Include\ or\ exclude\ cross-referenced\ entries=Includi o escludi voci con rifer
Would\ you\ like\ to\ include\ cross-reference\ entries\ in\ the\ current\ operation?=Vuoi includere voci di riferimento incrociate nell'operazione corrente?
Entries\ copied\ successfully,\ including\ cross-references.=Voci copiate con successo, compresi riferimenti incrociati.
Entries\ copied\ successfully,\ without\ cross-references.=Voci copiate con successo, senza riferimenti incrociati.
+
+Citations\ relations\ local\ storage\ time-to-live\ (in\ days)=Durata della memorizzazione locale delle relazioni di citazione (in giorni)
diff --git a/src/main/resources/l10n/JabRef_pl.properties b/src/main/resources/l10n/JabRef_pl.properties
index 3cc55df6c9f..3ef0aad201c 100644
--- a/src/main/resources/l10n/JabRef_pl.properties
+++ b/src/main/resources/l10n/JabRef_pl.properties
@@ -1922,3 +1922,5 @@ Could\ not\ move\ file\ %0.\ Please\ close\ this\ file\ and\ retry.=Nie można p
Copy\ to=Kopiuj do
Include=Uwzględnij
Exclude=Wyklucz
+
+Citations\ relations\ local\ storage\ time-to-live\ (in\ days)=Czas przechowywania lokalnego relacji cytowa? (w dniach)
diff --git a/src/main/resources/l10n/JabRef_pt_BR.properties b/src/main/resources/l10n/JabRef_pt_BR.properties
index 0a484a94b3c..c62eb474be0 100644
--- a/src/main/resources/l10n/JabRef_pt_BR.properties
+++ b/src/main/resources/l10n/JabRef_pt_BR.properties
@@ -2848,3 +2848,5 @@ Include=Incluir
Exclude=Excluir
Include\ or\ exclude\ cross-referenced\ entries=Incluir ou excluir referências cruzadas
Would\ you\ like\ to\ include\ cross-reference\ entries\ in\ the\ current\ operation?=Gostaria de incluir referências cruzadas na operação atual?
+
+Citations\ relations\ local\ storage\ time-to-live\ (in\ days)=Tempo de vida do armazenamento local das relações de citações (em dias)
diff --git a/src/test/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsRepositoryTest.java b/src/test/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsRepositoryTest.java
deleted file mode 100644
index 41106d57a6b..00000000000
--- a/src/test/java/org/jabref/gui/entryeditor/citationrelationtab/BibEntryRelationsRepositoryTest.java
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.jabref.gui.entryeditor.citationrelationtab;
-
-import java.util.List;
-
-import org.jabref.gui.entryeditor.citationrelationtab.semanticscholar.SemanticScholarFetcher;
-import org.jabref.model.entry.BibEntry;
-import org.jabref.model.entry.field.StandardField;
-
-import org.junit.jupiter.api.Test;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-class BibEntryRelationsRepositoryTest {
-
- private List getCitedBy(BibEntry entry) {
- return List.of(createCitingBibEntry(entry));
- }
-
- private BibEntry createBibEntry(int i) {
- return new BibEntry()
- .withCitationKey("entry" + i)
- .withField(StandardField.DOI, "10.1234/5678" + i);
- }
-
- private BibEntry createCitingBibEntry(Integer i) {
- return new BibEntry()
- .withCitationKey("citing_entry" + i)
- .withField(StandardField.DOI, "10.2345/6789" + i);
- }
-
- private BibEntry createCitingBibEntry(BibEntry citedEntry) {
- return createCitingBibEntry(Integer.valueOf(citedEntry.getCitationKey().get().substring(5)));
- }
-
- @Test
- void getCitations() throws Exception {
- SemanticScholarFetcher semanticScholarFetcher = mock(SemanticScholarFetcher.class);
- when(semanticScholarFetcher.searchCitedBy(any(BibEntry.class))).thenAnswer(invocation -> {
- BibEntry entry = invocation.getArgument(0);
- return getCitedBy(entry);
- });
- BibEntryRelationsCache bibEntryRelationsCache = new BibEntryRelationsCache();
-
- BibEntryRelationsRepository bibEntryRelationsRepository = new BibEntryRelationsRepository(semanticScholarFetcher, bibEntryRelationsCache);
-
- for (int i = 0; i < 150; i++) {
- BibEntry entry = createBibEntry(i);
- List citations = bibEntryRelationsRepository.getCitations(entry);
- assertEquals(getCitedBy(entry), citations);
- }
-
- for (int i = 0; i < 150; i++) {
- BibEntry entry = createBibEntry(i);
- List citations = bibEntryRelationsRepository.getCitations(entry);
- assertEquals(getCitedBy(entry), citations);
- }
- }
-}
diff --git a/src/test/java/org/jabref/gui/entryeditor/citationrelationtab/CitationsRelationsTabViewModelTest.java b/src/test/java/org/jabref/gui/entryeditor/citationrelationtab/CitationsRelationsTabViewModelTest.java
index b3190fc57f4..7beb894f1b7 100644
--- a/src/test/java/org/jabref/gui/entryeditor/citationrelationtab/CitationsRelationsTabViewModelTest.java
+++ b/src/test/java/org/jabref/gui/entryeditor/citationrelationtab/CitationsRelationsTabViewModelTest.java
@@ -9,8 +9,6 @@
import org.jabref.gui.DialogService;
import org.jabref.gui.StateManager;
-import org.jabref.gui.entryeditor.citationrelationtab.semanticscholar.CitationFetcher;
-import org.jabref.gui.externalfiles.ImportHandler;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.logic.FilePreferences;
import org.jabref.logic.bibtex.FieldPreferences;
@@ -19,6 +17,7 @@
import org.jabref.logic.database.DuplicateCheck;
import org.jabref.logic.importer.ImportFormatPreferences;
import org.jabref.logic.importer.ImporterPreferences;
+import org.jabref.logic.importer.fetcher.CitationFetcher;
import org.jabref.logic.preferences.OwnerPreferences;
import org.jabref.logic.preferences.TimestampPreferences;
import org.jabref.logic.util.CurrentThreadTaskExecutor;
@@ -42,9 +41,7 @@
import static org.mockito.Mockito.when;
class CitationsRelationsTabViewModelTest {
- private ImportHandler importHandler;
private BibDatabaseContext bibDatabaseContext;
- private BibEntry testEntry;
@Mock
private GuiPreferences preferences;
diff --git a/src/test/java/org/jabref/logic/citation/SearchCitationsRelationsServiceTest.java b/src/test/java/org/jabref/logic/citation/SearchCitationsRelationsServiceTest.java
new file mode 100644
index 00000000000..1e15af113f7
--- /dev/null
+++ b/src/test/java/org/jabref/logic/citation/SearchCitationsRelationsServiceTest.java
@@ -0,0 +1,224 @@
+package org.jabref.logic.citation;
+
+import java.util.HashMap;
+import java.util.List;
+
+import org.jabref.logic.citation.repository.BibEntryRelationsRepositoryHelpersForTest;
+import org.jabref.logic.importer.fetcher.CitationFetcherHelpersForTest;
+import org.jabref.model.entry.BibEntry;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SearchCitationsRelationsServiceTest {
+
+ @Nested
+ class CitationsTests {
+ @Test
+ void serviceShouldSearchForCitations() {
+ // GIVEN
+ var cited = new BibEntry();
+ var citationsToReturn = List.of(new BibEntry());
+ var repository = BibEntryRelationsRepositoryHelpersForTest.Mocks.from(
+ e -> citationsToReturn, null, null, null, entry -> false, entry -> false
+ );
+ var searchService = new SearchCitationsRelationsService(null, repository);
+
+ // WHEN
+ List citations = searchService.searchCitations(cited);
+
+ // THEN
+ assertEquals(citationsToReturn, citations);
+ }
+
+ @Test
+ void serviceShouldCallTheFetcherForCitationsWhenRepositoryIsUpdatable() {
+ // GiVEN
+ var cited = new BibEntry();
+ var newCitations = new BibEntry();
+ var citationsToReturn = List.of(newCitations);
+ var citationsDatabase = new HashMap>();
+ var fetcher = CitationFetcherHelpersForTest.Mocks.from(
+ entry -> {
+ if (entry == cited) {
+ return citationsToReturn;
+ }
+ return List.of();
+ },
+ null
+ );
+ var repository = BibEntryRelationsRepositoryHelpersForTest.Mocks.from(
+ e -> citationsToReturn,
+ citationsDatabase::put,
+ List::of,
+ (e, r) -> { },
+ e -> true,
+ e -> false
+ );
+ var searchService = new SearchCitationsRelationsService(fetcher, repository);
+
+ // WHEN
+ var citations = searchService.searchCitations(cited);
+
+ // THEN
+ assertTrue(citationsDatabase.containsKey(cited));
+ assertEquals(citationsToReturn, citationsDatabase.get(cited));
+ assertEquals(citationsToReturn, citations);
+ }
+
+ @Test
+ void serviceShouldFetchCitationsIfRepositoryIsEmpty() {
+ var cited = new BibEntry();
+ var newCitations = new BibEntry();
+ var citationsToReturn = List.of(newCitations);
+ var citationsDatabase = new HashMap>();
+ var fetcher = CitationFetcherHelpersForTest.Mocks.from(
+ entry -> {
+ if (entry == cited) {
+ return citationsToReturn;
+ }
+ return List.of();
+ },
+ null
+ );
+ var repository = BibEntryRelationsRepositoryHelpersForTest.Mocks.from(
+ citationsDatabase, null
+ );
+ var searchService = new SearchCitationsRelationsService(fetcher, repository);
+
+ // WHEN
+ var citations = searchService.searchCitations(cited);
+
+ // THEN
+ assertTrue(citationsDatabase.containsKey(cited));
+ assertEquals(citationsToReturn, citationsDatabase.get(cited));
+ assertEquals(citationsToReturn, citations);
+ }
+
+ @Test
+ void insertingAnEmptyCitationsShouldBePossible() {
+ var cited = new BibEntry();
+ var citationsDatabase = new HashMap>();
+ var fetcher = CitationFetcherHelpersForTest.Mocks.from(
+ entry -> List.of(), null
+ );
+ var repository = BibEntryRelationsRepositoryHelpersForTest.Mocks.from(
+ citationsDatabase, null
+ );
+ var searchService = new SearchCitationsRelationsService(fetcher, repository);
+
+ // WHEN
+ var citations = searchService.searchCitations(cited);
+
+ // THEN
+ assertTrue(citations.isEmpty());
+ assertTrue(citationsDatabase.containsKey(cited));
+ assertTrue(citationsDatabase.get(cited).isEmpty());
+ }
+ }
+
+ @Nested
+ class ReferencesTests {
+ @Test
+ void serviceShouldSearchForReferences() {
+ // GIVEN
+ var referencer = new BibEntry();
+ var referencesToReturn = List.of(new BibEntry());
+ var repository = BibEntryRelationsRepositoryHelpersForTest.Mocks.from(
+ null, null, e -> referencesToReturn, null, e -> false, e -> false
+ );
+ var searchService = new SearchCitationsRelationsService(null, repository);
+
+ // WHEN
+ List references = searchService.searchReferences(referencer);
+
+ // THEN
+ assertEquals(referencesToReturn, references);
+ }
+
+ @Test
+ void serviceShouldCallTheFetcherForReferencesWhenRepositoryIsUpdatable() {
+ // GIVEN
+ var referencer = new BibEntry();
+ var newReference = new BibEntry();
+ var referencesToReturn = List.of(newReference);
+ var referencesDatabase = new HashMap>();
+ var fetcher = CitationFetcherHelpersForTest.Mocks.from(null, entry -> {
+ if (entry == referencer) {
+ return referencesToReturn;
+ }
+ return List.of();
+ });
+ var repository = BibEntryRelationsRepositoryHelpersForTest.Mocks.from(
+ List::of,
+ (e, c) -> { },
+ e -> referencesToReturn,
+ referencesDatabase::put,
+ e -> false,
+ e -> true
+ );
+ var searchService = new SearchCitationsRelationsService(fetcher, repository);
+
+ // WHEN
+ var references = searchService.searchReferences(referencer);
+
+ // THEN
+ assertTrue(referencesDatabase.containsKey(referencer));
+ assertEquals(referencesToReturn, referencesDatabase.get(referencer));
+ assertEquals(referencesToReturn, references);
+ }
+
+ @Test
+ void serviceShouldFetchReferencesIfRepositoryIsEmpty() {
+ var reference = new BibEntry();
+ var newCitations = new BibEntry();
+ var referencesToReturn = List.of(newCitations);
+ var referencesDatabase = new HashMap>();
+ var fetcher = CitationFetcherHelpersForTest.Mocks.from(
+ null,
+ entry -> {
+ if (entry == reference) {
+ return referencesToReturn;
+ }
+ return List.of();
+ }
+ );
+ var repository = BibEntryRelationsRepositoryHelpersForTest.Mocks.from(
+ null, referencesDatabase
+ );
+ var searchService = new SearchCitationsRelationsService(fetcher, repository);
+
+ // WHEN
+ var references = searchService.searchReferences(reference);
+
+ // THEN
+ assertTrue(referencesDatabase.containsKey(reference));
+ assertEquals(referencesToReturn, referencesDatabase.get(reference));
+ assertEquals(referencesToReturn, references);
+ }
+
+ @Test
+ void insertingAnEmptyReferencesShouldBePossible() {
+ var referencer = new BibEntry();
+ var referenceDatabase = new HashMap>();
+ var fetcher = CitationFetcherHelpersForTest.Mocks.from(
+ null, entry -> List.of()
+ );
+ var repository = BibEntryRelationsRepositoryHelpersForTest.Mocks.from(
+ null, referenceDatabase
+ );
+ var searchService = new SearchCitationsRelationsService(fetcher, repository);
+
+ // WHEN
+ var citations = searchService.searchReferences(referencer);
+
+ // THEN
+ assertTrue(citations.isEmpty());
+ assertTrue(referenceDatabase.containsKey(referencer));
+ assertTrue(referenceDatabase.get(referencer).isEmpty());
+ }
+ }
+}
diff --git a/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationRepositoryChainTest.java b/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationRepositoryChainTest.java
new file mode 100644
index 00000000000..cffb6c7065c
--- /dev/null
+++ b/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationRepositoryChainTest.java
@@ -0,0 +1,180 @@
+package org.jabref.logic.citation.repository;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.random.RandomGenerator;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.jabref.model.citation.semanticscholar.PaperDetails;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.field.StandardField;
+import org.jabref.model.entry.identifier.DOI;
+import org.jabref.model.entry.types.StandardEntryType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class BibEntryRelationRepositoryChainTest {
+
+ private static Stream createBibEntries() {
+ return IntStream
+ .range(0, 150)
+ .mapToObj(BibEntryRelationRepositoryChainTest::createBibEntry);
+ }
+
+ private static BibEntry createBibEntry(int i) {
+ return new BibEntry()
+ .withCitationKey(String.valueOf(i))
+ .withField(StandardField.DOI, "10.1234/5678" + i);
+ }
+
+ /**
+ * Create a fake list of relations for a bibEntry based on the {@link PaperDetails#toBibEntry()} logic
+ * that corresponds to this use case: we want to make sure that relations coming from SemanticScholar
+ * and mapped as BibEntry will be serializable by the MVStore.
+ *
+ * @param entry should not be null
+ * @return never empty
+ */
+ private static List createRelations(BibEntry entry) {
+ return entry
+ .getCitationKey()
+ .map(key -> RandomGenerator.StreamableGenerator
+ .of("L128X256MixRandom").ints(150)
+ .mapToObj(i -> new BibEntry()
+ .withField(StandardField.TITLE, "A title:" + i)
+ .withField(StandardField.YEAR, String.valueOf(2024))
+ .withField(StandardField.AUTHOR, "A list of authors:" + i)
+ .withType(StandardEntryType.Book)
+ .withField(StandardField.DOI, entry.getDOI().map(DOI::asString).orElse("") + ":" + i)
+ .withField(StandardField.URL, "www.jabref.org/" + i)
+ .withField(StandardField.ABSTRACT, "The Universe is expanding:" + i)
+ )
+ )
+ .orElseThrow()
+ .toList();
+ }
+
+ private static Stream createCacheAndBibEntry() {
+ return Stream
+ .of(LRUCacheBibEntryRelationsRepository.CITATIONS, LRUCacheBibEntryRelationsRepository.REFERENCES)
+ .flatMap(dao -> {
+ dao.clearEntries();
+ return createBibEntries().map(entry -> Arguments.of(dao, entry));
+ });
+ }
+
+ private static class RepositoryMock implements BibEntryRelationRepository {
+
+ Map> table = new HashMap<>();
+
+ @Override
+ public List getRelations(BibEntry entry) {
+ return this.table.getOrDefault(entry, List.of());
+ }
+
+ @Override
+ public void addRelations(BibEntry entry, List relations) {
+ this.table.put(entry, relations);
+ }
+
+ @Override
+ public boolean containsKey(BibEntry entry) {
+ return this.table.containsKey(entry);
+ }
+
+ @Override
+ public boolean isUpdatable(BibEntry entry) {
+ return !this.containsKey(entry);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("createCacheAndBibEntry")
+ void theChainShouldReadFromFirstNode(BibEntryRelationRepository dao, BibEntry entry) {
+ // GIVEN
+ var relations = createRelations(entry);
+ dao.addRelations(entry, relations);
+ var secondDao = new RepositoryMock();
+ var doaChain = BibEntryRelationRepositoryChain.of(dao, secondDao);
+
+ // WHEN
+ var relationsFromChain = doaChain.getRelations(entry);
+
+ // THEN
+ Assertions.assertEquals(relations, relationsFromChain);
+ Assertions.assertEquals(relations, dao.getRelations(entry));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createCacheAndBibEntry")
+ void theChainShouldReadFromSecondNode(BibEntryRelationRepository dao, BibEntry entry) {
+ // GIVEN
+ var relations = createRelations(entry);
+ dao.addRelations(entry, relations);
+ var firstDao = new RepositoryMock();
+ var doaChain = BibEntryRelationRepositoryChain.of(firstDao, dao);
+
+ // WHEN
+ var relationsFromChain = doaChain.getRelations(entry);
+
+ // THEN
+ Assertions.assertEquals(relations, relationsFromChain);
+ Assertions.assertEquals(relations, dao.getRelations(entry));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createCacheAndBibEntry")
+ void theChainShouldReadFromSecondNodeAndRecopyToFirstNode(BibEntryRelationRepository dao, BibEntry entry) {
+ // GIVEN
+ var relations = createRelations(entry);
+ var firstDao = new RepositoryMock();
+ var doaChain = BibEntryRelationRepositoryChain.of(firstDao, dao);
+
+ // WHEN
+ doaChain.addRelations(entry, relations);
+ var relationsFromChain = doaChain.getRelations(entry);
+
+ // THEN
+ Assertions.assertEquals(relations, relationsFromChain);
+ Assertions.assertEquals(relations, firstDao.getRelations(entry));
+ Assertions.assertEquals(relations, dao.getRelations(entry));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createCacheAndBibEntry")
+ void theChainShouldContainAKeyEvenIfItWasOnlyInsertedInLastNode(BibEntryRelationRepository secondDao, BibEntry entry) {
+ // GIVEN
+ var relations = createRelations(entry);
+ var firstDao = new RepositoryMock();
+ var doaChain = BibEntryRelationRepositoryChain.of(firstDao, secondDao);
+
+ // WHEN
+ secondDao.addRelations(entry, relations);
+
+ // THEN
+ Assertions.assertFalse(firstDao.containsKey(entry));
+ Assertions.assertTrue(doaChain.containsKey(entry));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createCacheAndBibEntry")
+ void theChainShouldNotBeUpdatableBeforeInsertionAndNotAfterAnInsertion(BibEntryRelationRepository dao, BibEntry entry) {
+ // GIVEN
+ var relations = createRelations(entry);
+ var lastDao = new RepositoryMock();
+ var daoChain = BibEntryRelationRepositoryChain.of(dao, lastDao);
+ Assertions.assertTrue(daoChain.isUpdatable(entry));
+
+ // WHEN
+ daoChain.addRelations(entry, relations);
+
+ // THEN
+ Assertions.assertTrue(daoChain.containsKey(entry));
+ Assertions.assertFalse(daoChain.isUpdatable(entry));
+ }
+}
diff --git a/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryChainTest.java b/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryChainTest.java
new file mode 100644
index 00000000000..903bc84e932
--- /dev/null
+++ b/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryChainTest.java
@@ -0,0 +1,99 @@
+package org.jabref.logic.citation.repository;
+
+import java.nio.file.Files;
+import java.util.List;
+import java.util.random.RandomGenerator;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+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.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+
+class BibEntryRelationsRepositoryChainTest {
+
+ private static Stream createBibEntries() {
+ return IntStream
+ .range(0, 150)
+ .mapToObj(BibEntryRelationsRepositoryChainTest::createBibEntry);
+ }
+
+ private static BibEntry createBibEntry(int i) {
+ return new BibEntry()
+ .withCitationKey(String.valueOf(i))
+ .withField(StandardField.DOI, "10.1234/5678" + i);
+ }
+
+ private static List createRelations(BibEntry entry) {
+ return entry
+ .getCitationKey()
+ .map(key -> RandomGenerator
+ .StreamableGenerator.of("L128X256MixRandom").ints(150)
+ .mapToObj(i -> new BibEntry()
+ .withCitationKey("%s relation %s".formatted(key, i))
+ .withField(StandardField.DOI, "10.2345/6789" + i)
+ )
+ )
+ .orElseThrow()
+ .toList();
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void repositoryShouldMergeCitationsWhenInserting(BibEntry bibEntry) throws Exception {
+ // GIVEN
+ var tempDir = Files.createTempDirectory("temp");
+ var mvStorePath = Files.createTempFile(tempDir, "cache", "");
+ var bibEntryRelationsRepository = new BibEntryRelationsRepositoryChain(mvStorePath, mvStorePath, 0);
+ assertFalse(bibEntryRelationsRepository.containsCitations(bibEntry));
+
+ // WHEN
+ var firstRelations = createRelations(bibEntry);
+ var secondRelations = createRelations(bibEntry);
+ bibEntryRelationsRepository.insertCitations(bibEntry, firstRelations);
+ bibEntryRelationsRepository.insertCitations(bibEntry, secondRelations);
+
+ // THEN
+ var uniqueRelations = Stream
+ .concat(firstRelations.stream(), secondRelations.stream())
+ .distinct()
+ .toList();
+ var relationFromCache = bibEntryRelationsRepository.readCitations(bibEntry);
+ assertFalse(uniqueRelations.isEmpty());
+ assertNotSame(uniqueRelations, relationFromCache);
+ assertEquals(uniqueRelations, relationFromCache);
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void repositoryShouldMergeReferencesWhenInserting(BibEntry bibEntry) throws Exception {
+ // GIVEN
+ var tempDir = Files.createTempDirectory("temp");
+ var mvStorePath = Files.createTempFile(tempDir, "cache", "");
+ var bibEntryRelationsRepository = new BibEntryRelationsRepositoryChain(mvStorePath, mvStorePath, 0);
+ assertFalse(bibEntryRelationsRepository.containsReferences(bibEntry));
+
+ // WHEN
+ var firstRelations = createRelations(bibEntry);
+ var secondRelations = createRelations(bibEntry);
+ bibEntryRelationsRepository.insertReferences(bibEntry, firstRelations);
+ bibEntryRelationsRepository.insertReferences(bibEntry, secondRelations);
+
+ // THEN
+ var uniqueRelations = Stream
+ .concat(firstRelations.stream(), secondRelations.stream())
+ .distinct()
+ .collect(Collectors.toList());
+ var relationFromCache = bibEntryRelationsRepository.readReferences(bibEntry);
+ assertFalse(uniqueRelations.isEmpty());
+ assertNotSame(uniqueRelations, relationFromCache);
+ assertEquals(uniqueRelations, relationFromCache);
+ }
+}
diff --git a/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryHelpersForTest.java b/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryHelpersForTest.java
new file mode 100644
index 00000000000..291aff74ae2
--- /dev/null
+++ b/src/test/java/org/jabref/logic/citation/repository/BibEntryRelationsRepositoryHelpersForTest.java
@@ -0,0 +1,119 @@
+package org.jabref.logic.citation.repository;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+import org.jabref.model.entry.BibEntry;
+
+/**
+ * Provide helpers methods and classes for tests to manage {@link BibEntryRelationsRepository} mocks.
+ */
+public class BibEntryRelationsRepositoryHelpersForTest {
+
+ /**
+ * Provide mocks factories for {@link BibEntryRelationsRepository} mocks.
+ *
+ * Those implementations should help to test the values passed to an injected repository instance
+ * when it is called from {@link org.jabref.logic.citation.SearchCitationsRelationsService}.
+ */
+ public static class Mocks {
+ public static BibEntryRelationsRepository from(
+ Function> retrieveCitations,
+ BiConsumer> insertCitations,
+ Function> retrieveReferences,
+ BiConsumer> insertReferences,
+ Function isCitationsUpdatable,
+ Function isReferencesUpdatable
+ ) {
+ return new BibEntryRelationsRepository() {
+ @Override
+ public void insertCitations(BibEntry entry, List citations) {
+ insertCitations.accept(entry, citations);
+ }
+
+ @Override
+ public List readCitations(BibEntry entry) {
+ return retrieveCitations.apply(entry);
+ }
+
+ @Override
+ public boolean containsCitations(BibEntry entry) {
+ return true;
+ }
+
+ @Override
+ public boolean isCitationsUpdatable(BibEntry entry) {
+ return isCitationsUpdatable.apply(entry);
+ }
+
+ @Override
+ public void insertReferences(BibEntry entry, List citations) {
+ insertReferences.accept(entry, citations);
+ }
+
+ @Override
+ public List readReferences(BibEntry entry) {
+ return retrieveReferences.apply(entry);
+ }
+
+ @Override
+ public boolean containsReferences(BibEntry entry) {
+ return true;
+ }
+
+ @Override
+ public boolean isReferencesUpdatable(BibEntry entry) {
+ return isReferencesUpdatable.apply(entry);
+ }
+ };
+ }
+
+ public static BibEntryRelationsRepository from(
+ Map> citationsDB, Map> referencesDB
+ ) {
+ return new BibEntryRelationsRepository() {
+ @Override
+ public void insertCitations(BibEntry entry, List citations) {
+ citationsDB.put(entry, citations);
+ }
+
+ @Override
+ public List readCitations(BibEntry entry) {
+ return citationsDB.getOrDefault(entry, List.of());
+ }
+
+ @Override
+ public boolean containsCitations(BibEntry entry) {
+ return citationsDB.containsKey(entry);
+ }
+
+ @Override
+ public boolean isCitationsUpdatable(BibEntry entry) {
+ return true;
+ }
+
+ @Override
+ public void insertReferences(BibEntry entry, List citations) {
+ referencesDB.put(entry, citations);
+ }
+
+ @Override
+ public List readReferences(BibEntry entry) {
+ return referencesDB.getOrDefault(entry, List.of());
+ }
+
+ @Override
+ public boolean containsReferences(BibEntry entry) {
+ return referencesDB.containsKey(entry);
+ }
+
+ @Override
+ public boolean isReferencesUpdatable(BibEntry entry) {
+ return true;
+ }
+ };
+ }
+ }
+}
diff --git a/src/test/java/org/jabref/logic/citation/repository/LRUCacheBibEntryRelationsRepositoryTest.java b/src/test/java/org/jabref/logic/citation/repository/LRUCacheBibEntryRelationsRepositoryTest.java
new file mode 100644
index 00000000000..caf6e7c34dd
--- /dev/null
+++ b/src/test/java/org/jabref/logic/citation/repository/LRUCacheBibEntryRelationsRepositoryTest.java
@@ -0,0 +1,113 @@
+package org.jabref.logic.citation.repository;
+
+import java.util.List;
+import java.util.random.RandomGenerator;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.jabref.model.citation.semanticscholar.PaperDetails;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.field.StandardField;
+import org.jabref.model.entry.identifier.DOI;
+import org.jabref.model.entry.types.StandardEntryType;
+
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class LRUCacheBibEntryRelationsRepositoryTest {
+
+ private static Stream createBibEntries() {
+ return IntStream
+ .range(0, 150)
+ .mapToObj(LRUCacheBibEntryRelationsRepositoryTest::createBibEntry);
+ }
+
+ private static BibEntry createBibEntry(int i) {
+ return new BibEntry()
+ .withCitationKey(String.valueOf(i))
+ .withField(StandardField.DOI, "10.1234/5678" + i);
+ }
+
+ /**
+ * Create a fake list of relations for a bibEntry based on the {@link PaperDetails#toBibEntry()} logic
+ * that corresponds to this use case: we want to make sure that relations coming from SemanticScholar
+ * and mapped as BibEntry will be serializable by the MVStore.
+ *
+ * @param entry should not be null
+ * @return never empty
+ */
+ private static List createRelations(BibEntry entry) {
+ return entry
+ .getCitationKey()
+ .map(key -> RandomGenerator.StreamableGenerator
+ .of("L128X256MixRandom").ints(150)
+ .mapToObj(i -> new BibEntry()
+ .withField(StandardField.TITLE, "A title: " + i)
+ .withField(StandardField.YEAR, String.valueOf(2024))
+ .withField(StandardField.AUTHOR, "{A list of authors: " + i + "}")
+ .withType(StandardEntryType.Book)
+ .withField(StandardField.DOI, entry.getDOI().map(DOI::asString).orElse("") + ":" + i)
+ .withField(StandardField.ABSTRACT, "The Universe is expanding: " + i)
+ )
+ )
+ .orElseThrow()
+ .collect(Collectors.toList());
+ }
+
+ private static Stream createCacheAndBibEntry() {
+ return Stream
+ .of(LRUCacheBibEntryRelationsRepository.CITATIONS, LRUCacheBibEntryRelationsRepository.REFERENCES)
+ .flatMap(dao -> createBibEntries().map(entry -> Arguments.of(dao, entry)));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createCacheAndBibEntry")
+ void repositoryShouldMergeCitationsWhenInserting(LRUCacheBibEntryRelationsRepository dao, BibEntry entry) {
+ // GIVEN
+ dao.clearEntries();
+ assertFalse(dao.containsKey(entry));
+
+ // WHEN
+ var firstRelations = createRelations(entry);
+ var secondRelations = createRelations(entry);
+ dao.addRelations(entry, firstRelations);
+ dao.addRelations(entry, secondRelations);
+
+ // THEN
+ var uniqueRelations = Stream
+ .concat(firstRelations.stream(), secondRelations.stream())
+ .distinct()
+ .toList();
+ var relationFromCache = dao.getRelations(entry);
+ assertTrue(dao.containsKey(entry));
+ assertFalse(uniqueRelations.isEmpty());
+ assertNotSame(uniqueRelations, relationFromCache);
+ assertEquals(uniqueRelations, relationFromCache);
+ }
+
+ @ParameterizedTest
+ @MethodSource("createCacheAndBibEntry")
+ void clearingCacheShouldWork(LRUCacheBibEntryRelationsRepository dao, BibEntry entry) {
+ // GIVEN
+ dao.clearEntries();
+ var relations = createRelations(entry);
+ assertFalse(dao.containsKey(entry));
+
+ // WHEN
+ dao.addRelations(entry, relations);
+ assertTrue(dao.containsKey(entry));
+ dao.clearEntries();
+
+ // THEN
+ assertFalse(dao.containsKey(entry));
+ }
+}
diff --git a/src/test/java/org/jabref/logic/citation/repository/MVStoreBibEntryRelationsRepositoryRepositoryTest.java b/src/test/java/org/jabref/logic/citation/repository/MVStoreBibEntryRelationsRepositoryRepositoryTest.java
new file mode 100644
index 00000000000..da5ee65dfe6
--- /dev/null
+++ b/src/test/java/org/jabref/logic/citation/repository/MVStoreBibEntryRelationsRepositoryRepositoryTest.java
@@ -0,0 +1,215 @@
+package org.jabref.logic.citation.repository;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.random.RandomGenerator;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.jabref.model.citation.semanticscholar.PaperDetails;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.field.StandardField;
+import org.jabref.model.entry.identifier.DOI;
+import org.jabref.model.entry.types.StandardEntryType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+
+class MVStoreBibEntryRelationsRepositoryRepositoryTest {
+
+ private final static String MV_STORE_NAME = "test-relations.mv";
+ private final static String MAP_NAME = "test-relations";
+
+ @TempDir Path temporaryFolder;
+
+ private static Stream createBibEntries() {
+ return IntStream
+ .range(0, 150)
+ .mapToObj(MVStoreBibEntryRelationsRepositoryRepositoryTest::createBibEntry);
+ }
+
+ private static BibEntry createBibEntry(int i) {
+ return new BibEntry()
+ .withCitationKey(String.valueOf(i))
+ .withField(StandardField.DOI, "10.1234/5678" + i);
+ }
+
+ /**
+ * Create a fake list of relations for a bibEntry based on the {@link PaperDetails#toBibEntry()} logic
+ * that corresponds to this use case: we want to make sure that relations coming from SemanticScholar
+ * and mapped as BibEntry will be serializable by the MVStore.
+ *
+ * @param entry should not be null
+ * @return never empty
+ */
+ private static List createRelations(BibEntry entry) {
+ return entry
+ .getCitationKey()
+ .map(key -> RandomGenerator.StreamableGenerator
+ .of("L128X256MixRandom").ints(150)
+ .mapToObj(i -> new BibEntry()
+ .withField(StandardField.TITLE, "A title: " + i)
+ .withField(StandardField.YEAR, String.valueOf(2024))
+ .withField(StandardField.AUTHOR, "{A list of authors: " + i + "}")
+ .withType(StandardEntryType.Book)
+ .withField(StandardField.DOI, entry.getDOI().map(DOI::asString).orElse("") + ":" + i)
+ .withField(StandardField.ABSTRACT, "The Universe is expanding: " + i)
+ )
+ )
+ .orElseThrow()
+ .toList();
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void DAOShouldMergeRelationsWhenInserting(BibEntry bibEntry) throws IOException {
+ // GIVEN
+ var file = Files.createFile(temporaryFolder.resolve(MV_STORE_NAME));
+ var dao = new MVStoreBibEntryRelationRepository(file.toAbsolutePath(), MAP_NAME, 7);
+ Assertions.assertFalse(dao.containsKey(bibEntry));
+ var firstRelations = createRelations(bibEntry);
+ var secondRelations = createRelations(bibEntry);
+
+ // WHEN
+ dao.addRelations(bibEntry, firstRelations);
+ dao.addRelations(bibEntry, secondRelations);
+ var relationFromCache = dao
+ .getRelations(bibEntry)
+ .stream()
+ .toList();
+
+ // THEN
+ var uniqueRelations = Stream
+ .concat(firstRelations.stream(), secondRelations.stream())
+ .distinct()
+ .toList();
+ assertFalse(uniqueRelations.isEmpty());
+ assertNotSame(uniqueRelations, relationFromCache);
+ assertEquals(uniqueRelations, relationFromCache);
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void containsKeyShouldReturnFalseIfNothingWasInserted(BibEntry entry) throws IOException {
+ // GIVEN
+ var file = Files.createFile(temporaryFolder.resolve(MV_STORE_NAME));
+ var dao = new MVStoreBibEntryRelationRepository(file.toAbsolutePath(), MAP_NAME, 7);
+
+ // THEN
+ Assertions.assertFalse(dao.containsKey(entry));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void containsKeyShouldReturnTrueIfRelationsWereInserted(BibEntry entry) throws IOException {
+ // GIVEN
+ var file = Files.createFile(temporaryFolder.resolve(MV_STORE_NAME));
+ var dao = new MVStoreBibEntryRelationRepository(file.toAbsolutePath(), MAP_NAME, 7);
+ var relations = createRelations(entry);
+
+ // WHEN
+ dao.addRelations(entry, relations);
+
+ // THEN
+ Assertions.assertTrue(dao.containsKey(entry));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void isUpdatableShouldReturnTrueBeforeInsertionsAndFalseAfterInsertions(BibEntry entry) throws IOException {
+ // GIVEN
+ var file = Files.createFile(temporaryFolder.resolve(MV_STORE_NAME));
+ var dao = new MVStoreBibEntryRelationRepository(file.toAbsolutePath(), MAP_NAME, 7);
+ var relations = createRelations(entry);
+ Assertions.assertTrue(dao.isUpdatable(entry));
+
+ // WHEN
+ dao.addRelations(entry, relations);
+
+ // THEN
+ Assertions.assertFalse(dao.isUpdatable(entry));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void isUpdatableShouldReturnTrueAfterOneWeek(BibEntry entry) throws IOException {
+ // GIVEN
+ var file = Files.createFile(temporaryFolder.resolve(MV_STORE_NAME));
+ var dao = new MVStoreBibEntryRelationRepository(file.toAbsolutePath(), MAP_NAME, 7);
+ var relations = createRelations(entry);
+ var clock = Clock.fixed(Instant.now(), ZoneId.of("UTC"));
+ Assertions.assertTrue(dao.isUpdatable(entry, clock));
+
+ // WHEN
+ dao.addRelations(entry, relations);
+
+ // THEN
+ Assertions.assertFalse(dao.isUpdatable(entry, clock));
+ var clockOneWeekAfter = Clock.fixed(
+ LocalDateTime.now(ZoneId.of("UTC")).plusWeeks(1).toInstant(ZoneOffset.UTC),
+ ZoneId.of("UTC")
+ );
+ Assertions.assertTrue(dao.isUpdatable(entry, clockOneWeekAfter));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void isUpdatableShouldReturnFalseAfterOneWeekWhenTTLisSetTo30(BibEntry entry) throws IOException {
+ // GIVEN
+ var file = Files.createFile(temporaryFolder.resolve(MV_STORE_NAME));
+ var dao = new MVStoreBibEntryRelationRepository(file.toAbsolutePath(), MAP_NAME, 30);
+ var relations = createRelations(entry);
+ var clock = Clock.fixed(Instant.now(), ZoneId.of("UTC"));
+ Assertions.assertTrue(dao.isUpdatable(entry, clock));
+
+ // WHEN
+ dao.addRelations(entry, relations);
+
+ // THEN
+ Assertions.assertFalse(dao.isUpdatable(entry, clock));
+ var clockOneWeekAfter = Clock.fixed(
+ LocalDateTime.now(ZoneId.of("UTC")).plusWeeks(1).toInstant(ZoneOffset.UTC),
+ ZoneId.of("UTC")
+ );
+ Assertions.assertFalse(dao.isUpdatable(entry, clockOneWeekAfter));
+ }
+
+ @ParameterizedTest
+ @MethodSource("createBibEntries")
+ void deserializerErrorShouldReturnEmptyList(BibEntry entry) throws IOException {
+ // GIVEN
+ var serializer = new MVStoreBibEntryRelationRepository.BibEntryHashSetSerializer(
+ new MVStoreBibEntryRelationRepository.BibEntrySerializer() {
+ @Override
+ public BibEntry read(ByteBuffer buffer) {
+ // Fake the return after an exception
+ return new BibEntry();
+ }
+ }
+ );
+ var file = Files.createFile(temporaryFolder.resolve(MV_STORE_NAME));
+ var dao = new MVStoreBibEntryRelationRepository(file.toAbsolutePath(), MAP_NAME, 7, serializer);
+ var relations = createRelations(entry);
+ dao.addRelations(entry, relations);
+
+ // WHEN
+ var deserializedRelations = dao.getRelations(entry);
+
+ // THEN
+ Assertions.assertTrue(deserializedRelations.isEmpty());
+ }
+}
diff --git a/src/test/java/org/jabref/logic/importer/fetcher/CitationFetcherHelpersForTest.java b/src/test/java/org/jabref/logic/importer/fetcher/CitationFetcherHelpersForTest.java
new file mode 100644
index 00000000000..8a33679359e
--- /dev/null
+++ b/src/test/java/org/jabref/logic/importer/fetcher/CitationFetcherHelpersForTest.java
@@ -0,0 +1,32 @@
+package org.jabref.logic.importer.fetcher;
+
+import java.util.List;
+import java.util.function.Function;
+
+import org.jabref.model.entry.BibEntry;
+
+public class CitationFetcherHelpersForTest {
+ public static class Mocks {
+ public static CitationFetcher from(
+ Function> retrieveCitedBy,
+ Function> retrieveCiting
+ ) {
+ return new CitationFetcher() {
+ @Override
+ public List searchCitedBy(BibEntry entry) {
+ return retrieveCitedBy.apply(entry);
+ }
+
+ @Override
+ public List searchCiting(BibEntry entry) {
+ return retrieveCiting.apply(entry);
+ }
+
+ @Override
+ public String getName() {
+ return "Test citation fetcher";
+ }
+ };
+ }
+ }
+}