Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bbc4092
Add context menus for citation relation labels to open API URLs in br…
LoayTarek5 Feb 11, 2026
62eac21
Add methods to retrieve API URIs for references and citations
LoayTarek5 Feb 11, 2026
1f71170
Add methods to generate citations and references API URIs in OpenAlex…
LoayTarek5 Feb 11, 2026
e02ebd8
Add default methods for fetching references and citations API URIs in…
LoayTarek5 Feb 11, 2026
8d61d0e
Add methods to retrieve references and citations API URIs in CrossRef…
LoayTarek5 Feb 11, 2026
c4aab95
Add methods to retrieve references and citations API URIs in OpenCita…
LoayTarek5 Feb 11, 2026
2ab0abb
Add methods to retrieve references and citations API URIs in Semantic…
LoayTarek5 Feb 11, 2026
1908c74
Add new localization entries for API URL handling in JabRef_en.proper…
LoayTarek5 Feb 11, 2026
44efe86
Add option to open citation fetcher API URL in browser from Citations…
LoayTarek5 Feb 11, 2026
cda18f9
Merge branch 'main' into feature-provide-insights-citation-fetcher-15033
LoayTarek5 Feb 11, 2026
c393af8
Merge branch 'main' into feature-provide-insights-citation-fetcher-15033
LoayTarek5 Feb 11, 2026
a6f7ec2
Merge branch 'main' into feature-provide-insights-citation-fetcher-15033
LoayTarek5 Feb 12, 2026
4a8b11b
Log warning when failing to open API URL in browser
LoayTarek5 Feb 12, 2026
69826de
Refactor reference and citation fetching
LoayTarek5 Feb 12, 2026
e9bd7b7
Update use references API URI for fetching citations
LoayTarek5 Feb 12, 2026
d90751a
Refactor to use API URIs for fetching references and citations
LoayTarek5 Feb 12, 2026
8d3fce3
improving API URL construction for citations and references
LoayTarek5 Feb 12, 2026
d83fb89
Merge branch 'feature-provide-insights-citation-fetcher-15033' of git…
LoayTarek5 Feb 12, 2026
0cfda30
use URI instead of URI.create()
LoayTarek5 Feb 12, 2026
9625d42
remove comment
LoayTarek5 Feb 12, 2026
1985a02
Refactor citation count to follow the same pattern of other uri
LoayTarek5 Feb 12, 2026
a690cd9
Merge branch 'main' into feature-provide-insights-citation-fetcher-15033
LoayTarek5 Feb 12, 2026
428c5bc
Refactor context menu for citation labels to ensure API URLs are only…
LoayTarek5 Feb 13, 2026
214aeba
Re-use getApiUrl and reduce duplicated code
LoayTarek5 Feb 13, 2026
bcb064e
Merge branch 'feature-provide-insights-citation-fetcher-15033' of git…
LoayTarek5 Feb 13, 2026
9acfa28
Merge branch 'main' into feature-provide-insights-citation-fetcher-15033
LoayTarek5 Feb 13, 2026
4194f98
remove 'No API URL available.' from JabRef_en.properties file
LoayTarek5 Feb 13, 2026
91d8731
Merge branch 'feature-provide-insights-citation-fetcher-15033' of git…
LoayTarek5 Feb 13, 2026
cb9db34
Reorder methods
koppor Feb 14, 2026
fcfd186
Streamline OpenAlex code
koppor Feb 14, 2026
bd46453
Streamline SemanticScholarCitationFetcher
koppor Feb 14, 2026
125fb2d
Merge remote-tracking branch 'origin/main' into feature-provide-insig…
koppor Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We added the option to enable/disable the HTTP-Server for the browser extension to the Quick Settings on the Welcome screen [#14902](https://github.com/JabRef/jabref/issues/14902)
- We added the ability to update bibliographic information based on the existing entry data. [#14185](https://github.com/JabRef/jabref/issues/14185)
- We added an option to clear [groups with explicitly selected entries](https://docs.jabref.org/finding-sorting-and-cleaning-entries/groups#explicit-selection). [#15001](https://github.com/JabRef/jabref/issues/15001)
- We added an option to open the citation fetcher API URL in the browser in the Citations tab. [#15033](https://github.com/JabRef/jabref/issues/15033)
- We added the option to group entries by entry type [#15040](https://github.com/JabRef/jabref/issues/15040)

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SplitPane;
Expand Down Expand Up @@ -402,6 +404,10 @@ private SplitPane getPaneAndStartSearch(BibEntry entry) {
styleTopBarNode(fetcherCombo, 75.0);
fetcherCombo.valueProperty().bindBidirectional(entryEditorPreferences.citationFetcherTypeProperty());

// Add context menus to labels for opening API URLs
citingLabel.setContextMenu(createCitationContextMenu(entry, CitationFetcher.SearchType.CITES));
citedByLabel.setContextMenu(createCitationContextMenu(entry, CitationFetcher.SearchType.CITED_BY));

// Create abort buttons for both sides
Button abortCitingButton = IconTheme.JabRefIcons.CLOSE.asButton();
abortCitingButton.getGraphic().resize(30, 30);
Expand Down Expand Up @@ -623,6 +629,52 @@ private void styleLabel(Label label, String tooltipText) {
label.setMaxWidth(Double.MAX_VALUE);
}

/// Creates a context menu for citation relation labels with an option to open the API URL in browser
///
/// @param entry the BibEntry to get the API URL for
/// @param searchType the type of search (CITES for references, CITED_BY for citations)
/// @return a ContextMenu with the "Open API URL in browser" option
private ContextMenu createCitationContextMenu(BibEntry entry, CitationFetcher.SearchType searchType) {
ContextMenu contextMenu = new ContextMenu();

MenuItem openApiUrl = new MenuItem(Localization.lang("Open API URL in browser"));

/// Create a binding that checks if URI is available
BooleanBinding uriAvailable = Bindings.createBooleanBinding(
() -> {
Optional<URI> uri = searchType == CitationFetcher.SearchType.CITES
? searchCitationsRelationsService.getReferencesApiUri(entry)
: searchCitationsRelationsService.getCitationsApiUri(entry);
return uri.isPresent();
},
fetcherCombo.valueProperty() /// revaluate when fetcher changes
);

/// Disable the menu item when URI is not available
openApiUrl.disableProperty().bind(uriAvailable.not());

/// Set action to open browser (only executes when enabled)
openApiUrl.setOnAction(_ -> {
Optional<URI> uri = searchType == CitationFetcher.SearchType.CITES
? searchCitationsRelationsService.getReferencesApiUri(entry)
: searchCitationsRelationsService.getCitationsApiUri(entry);

/// URI should always be present because menu item is disabled otherwise
/// check for safety
uri.ifPresent(apiUri -> {
try {
NativeDesktop.openBrowser(apiUri, preferences.getExternalApplicationsPreferences());
} catch (IOException e) {
LOGGER.warn("Could not open API URL in browser: {}", apiUri, e);
dialogService.notify(Localization.lang("Unable to open link."));
}
});
});

contextMenu.getItems().add(openApiUrl);
return contextMenu;
}

/// Method to style refresh buttons
///
/// @param node node to style
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jabref.logic.citation;

import java.net.URI;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -116,4 +117,12 @@ public int getCitationCount(BibEntry citationCounted, Optional<String> actualFie
public void close() {
relationsRepository.close();
}

public Optional<URI> getReferencesApiUri(BibEntry entry) {
return citationFetcher.getReferencesApiUri(entry);
}

public Optional<URI> getCitationsApiUri(BibEntry entry) {
return citationFetcher.getCitationsApiUri(entry);
}
}
119 changes: 65 additions & 54 deletions jablib/src/main/java/org/jabref/logic/importer/fetcher/OpenAlex.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import org.jabref.logic.cleanup.DoiCleanup;
import org.jabref.logic.importer.EntryBasedFetcher;
Expand Down Expand Up @@ -40,6 +41,7 @@
import org.apache.hc.core5.net.URIBuilder;
import org.jooq.lambda.Unchecked;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -306,7 +308,10 @@ public TrustLevel getTrustLevel() {

// region CitationFetcher

private Stream<BibEntry> workUrlsToBibEntryStream(JSONArray workUrlArray) {
private List<BibEntry> workUrlsToBibEntryList(@Nullable JSONArray workUrlArray) {
if (workUrlArray == null) {
List.of();
}
// TODO: This could be batched - see https://github.com/JabRef/jabref/pull/15023#issuecomment-3846630255
return IntStream.range(0, workUrlArray.length())
.mapToObj(workUrlArray::getString)
Expand All @@ -332,39 +337,68 @@ private Stream<BibEntry> workUrlsToBibEntryStream(JSONArray workUrlArray) {
LOGGER.debug("Could not fetch work at URL: {}", redactedUrl, e);
return new BibEntry().withField(StandardField.URL, redactedUrl).withChanged(true);
}
}));
}))
.toList();
}

@Override
public List<BibEntry> getReferences(BibEntry entry) throws FetcherException {
try {
LOGGER.trace("Getting references for entry: {}", entry.getKeyAuthorTitleYear(10));
return getWorkObject(entry, List.of("referenced_works"))
.map(work -> work.optJSONArray("referenced_works"))
.filter(Objects::nonNull)
.stream()
.flatMap(workUrlArray -> workUrlsToBibEntryStream(workUrlArray))
.toList();
} catch (RuntimeException e) {
LOGGER.warn("Could not get references", e);
if (e.getCause() instanceof FetcherException fetcherException) {
throw fetcherException;
}
throw new FetcherException("Could not get references", e.getCause());
private List<BibEntry> workArrayToBibEntryList(@Nullable JSONArray workUrlArray) {
if (workUrlArray == null) {
return List.of();
}
}

private Stream<BibEntry> workArrayToBibEntryStream(JSONArray workUrlArray) {
return IntStream.range(0, workUrlArray.length())
.mapToObj(workUrlArray::getJSONObject)
.map(Unchecked.function(jsonItem -> jsonItemToBibEntry(jsonItem)));
.map(Unchecked.function(jsonItem -> jsonItemToBibEntry(jsonItem)))
.toList();
}

private List<BibEntry> fetch(Optional<URI> apiUri, String arrayElementName, Function<JSONArray, List<BibEntry>> handler) throws FetcherException {
if (apiUri.isEmpty()) {
return List.of();
}
try (ProgressInputStream stream = getUrlDownload(apiUri.get().toURL()).asInputStream()) {
JSONObject response = JsonReader.toJsonObject(stream);
JSONArray results = response.getJSONArray(arrayElementName);
return handler.apply(results);
} catch (IOException | ParseException e) {
throw new FetcherException("Could not fetch data from OpenAlex", e);
}
}

/// @implNote This method is similar to [#getCitations(BibEntry)]. Streamlining this into one is not leading to more maintainable code, because handling the exceptions properly
@Override
public List<BibEntry> getReferences(BibEntry entry) throws FetcherException {
LOGGER.trace("Getting references for entry: {}", entry.getKeyAuthorTitleYear(10));
return fetch(getReferencesApiUri(entry), "referenced_works", this::workUrlsToBibEntryList);
}

@Override
public List<BibEntry> getCitations(BibEntry entry) throws FetcherException {
LOGGER.trace("Getting citations for entry: {}", entry.getKeyAuthorTitleYear(10));
return fetch(getCitationsApiUri(entry), "results", this::workArrayToBibEntryList);
}

@Override
public Optional<Integer> getCitationCount(BibEntry entry) throws FetcherException {
return getWorkObject(entry, List.of("cited_by_count"))
.map(work -> work.optInt("cited_by_count"))
.filter(Objects::nonNull);
}

@Override
public Optional<URI> getReferencesApiUri(BibEntry entry) {
try {
/* Officially, `cited_by_api_url` is to be used to get citations.
* However, this URL may be missing */
return getUrl(entry, List.of("referenced_works"))
.map(Unchecked.function(URL::toURI));
} catch (MalformedURLException e) {
LOGGER.debug("Could not create references API URI", e);
return Optional.empty();
}
}

@Override
public Optional<URI> getCitationsApiUri(BibEntry entry) {
/* Officially, `cited_by_api_url` is to be used to get citations.
* However, this URL may be missing */
/*
return getWorkObject(entry, List.of("cited_by_api_url"))
.map(work -> work.optString("cited_by_api_url"))
Expand All @@ -380,44 +414,21 @@ public List<BibEntry> getCitations(BibEntry entry) throws FetcherException {
.toList();
*/

// Instead, we perform a search for works that cite the given work's ID
// Instead, we perform a search for works that cite the given work's ID
try {
return getWorkObject(entry, List.of("id"))
.map(work -> work.optString("id"))
.filter(Objects::nonNull)
.map(Unchecked.function(id ->
getUriBuilder("", List.of())
.addParameter("filter", "cites:" + id)
.build()
.toURL()
))
.map(Unchecked.function(url -> {
try (ProgressInputStream stream = getUrlDownload(url).asInputStream()) {
JSONObject response = JsonReader.toJsonObject(stream);
return response.getJSONArray("results");
} catch (RuntimeException e) {
String redactedUrl = FetcherException.getRedactedUrl(url.toString());
LOGGER.warn("Could not fetch work at URL: {}", redactedUrl, e);
throw e;
}
}))
.stream()
.flatMap(worksArray -> workArrayToBibEntryStream(worksArray))
.toList();
} catch (RuntimeException e) {
LOGGER.warn("Could not get citations", e);
if (e.getCause() instanceof FetcherException fetcherException) {
throw fetcherException;
}
throw new FetcherException("Could not get citations", e.getCause());
));
} catch (FetcherException e) {
LOGGER.debug("Could not create citations API URI", e);
return Optional.empty();
}
}

@Override
public Optional<Integer> getCitationCount(BibEntry entry) throws FetcherException {
return getWorkObject(entry, List.of("cited_by_count"))
.map(work -> work.optInt("cited_by_count"))
.filter(Objects::nonNull);
}

// endregion
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jabref.logic.importer.fetcher.citation;

import java.net.URI;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -47,4 +48,20 @@ enum SearchType {
/// @param entry entry to search citation count field
/// @return returns a {@link Integer} for citation count field (may be empty)
Optional<Integer> getCitationCount(BibEntry entry) throws FetcherException;

/// Returns the API URL for fetching references
///
/// @param entry the entry to get references for
/// @return the URI for the references API, or empty if not supported
default Optional<URI> getReferencesApiUri(BibEntry entry) {
return Optional.empty();
}

/// Returns the API URL for fetching citations
///
/// @param entry the entry to get citations for
/// @return the URI for the citations API, or empty if not supported
default Optional<URI> getCitationsApiUri(BibEntry entry) {
return Optional.empty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -76,14 +78,15 @@ public List<BibEntry> getReferences(BibEntry entry) throws FetcherException {
if (doi.isEmpty()) {
findDoiForEntry(clonedEntry);
}
if (doi.isEmpty()) {

Optional<URI> uri = getReferencesApiUri(clonedEntry);
if (uri.isEmpty()) {
return List.of();
}

final PlainCitationParser parser = PlainCitationParserFactory.getPlainCitationParser(importerPreferences.getDefaultPlainCitationParser(), citationKeyPatternPreferences, grobidPreferences, importFormatPreferences, aiService);

String url = API_URL + doi.get().asString();
try (InputStream stream = new URLDownload(url).asInputStream()) {
try (InputStream stream = new URLDownload(uri.get().toString()).asInputStream()) {
JsonNode node = mapper.readTree(stream);
LOGGER.atDebug()
.addKeyValue("payload", node)
Expand Down Expand Up @@ -192,4 +195,26 @@ private void findDoiForEntry(BibEntry clonedEntry) {
LOGGER.debug("Failed to find DOI", e);
}
}

@Override
public Optional<URI> getReferencesApiUri(BibEntry entry) {
Optional<DOI> doi = entry.getField(StandardField.DOI).flatMap(DOI::parse);
if (doi.isEmpty()) {
return Optional.empty();
}

try {
String apiUrl = API_URL + doi.get().asString();
return Optional.of(new URI(apiUrl));
} catch (URISyntaxException e) {
LOGGER.debug("Could not create references API URI", e);
return Optional.empty();
}
}

/// CrossRef does not support fetching citations for a given entry.
@Override
public Optional<URI> getCitationsApiUri(BibEntry entry) {
return Optional.empty();
}
}
Loading
Loading