Skip to content

Commit e2948d2

Browse files
feat: fetcher from clipboard and auto-select identifier type if valid (#13111)
* feat: fetcher from clipboard and auto-select identifier type if valid * test: add unit tests for identifier detection (DOI, ISBN, arXiv, RFC) * Update Changelog * test: rewrite identifier detection tests to match JabRef code style * test: update identifier detection tests to follow JabRef assertion style * Refactor: Extract identifier detection and fetcher matching logic in NewEntryView * Test: Move identifier detection unit tests to CompositeIdFetcherTest * docs: Document and trace requirement for identifier clipboard autofocus behavior * docs: fix Markdown lint errors in focus.md * refactor: move identifier recognition logic to Identifier.from() - Moved getIdentifier() from CompositeIdFetcher to Identifier as static factory method 'from(String)' - Updated all call sites to use Identifier.from(...) * refactor: use structured check for ISBN fetcher with module export - Replaced string matching with instanceof IsbnFetcher in NewEntryView - Exported isbn fetcher package from jablib to enable module access --------- Co-authored-by: Christoph <[email protected]>
1 parent 4796a03 commit e2948d2

File tree

7 files changed

+149
-33
lines changed

7 files changed

+149
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
2121
- We added a new button to toggle the file path between an absolute and relative formats in context of library properties. [#13031](https://github.com/JabRef/jabref/issues/13031)
2222
- We added automatic selection of the “Enter Identifier” tab with pre-filled clipboard content if the clipboard contains a valid identifier when opening the “Create New Entry” dialog. [#13087](https://github.com/JabRef/jabref/issues/13087)
2323
- We added an "Open example library" button to Welcome Tab. [#13014](https://github.com/JabRef/jabref/issues/13014)
24+
- We added automatic detection and selection of the identifier type (e.g., DOI, ISBN, arXiv) based on clipboard content when opening the "New Entry" dialog [#13111](https://github.com/JabRef/jabref/pull/13111)
2425
- We added support for import of a Refer/BibIX file format. [#13069](https://github.com/JabRef/jabref/issues/13069)
2526

2627
### Changed

docs/requirements/focus.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,20 @@ This provides immediate keyboard interaction capabilities (such as Ctrl+V for pa
1313

1414
Needs: impl
1515

16+
### Automatic Identifier Detection and Focus in New Entry Dialog
17+
`req~newentry.clipboard.autofocus~1`
18+
19+
When the "New Entry" dialog is opened:
20+
21+
- If the clipboard contains a valid identifier (e.g., DOI, ISBN, ArXiv, RFC):
22+
23+
- The dialog automatically switches to the "Enter Identifier" tab.
24+
- The identifier input field is automatically filled with the clipboard content.
25+
- The field receives keyboard focus and its content is selected.
26+
- The corresponding fetcher (e.g., DOI, ISBN) is automatically selected based on the detected identifier type.
27+
28+
This behavior streamlines the process of creating new entries by allowing users to copy an identifier and open the dialog, without needing to manually select the input field, switch tabs, or choose a fetcher manually.
29+
30+
Needs: impl
31+
1632
<!-- markdownlint-disable-file MD022 -->

jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,25 @@
3535
import org.jabref.gui.util.UiTaskExecutor;
3636
import org.jabref.gui.util.ViewModelListCellFactory;
3737
import org.jabref.logic.ai.AiService;
38-
import org.jabref.logic.importer.CompositeIdFetcher;
3938
import org.jabref.logic.importer.IdBasedFetcher;
4039
import org.jabref.logic.importer.WebFetcher;
40+
import org.jabref.logic.importer.fetcher.ArXivFetcher;
4141
import org.jabref.logic.importer.fetcher.DoiFetcher;
42+
import org.jabref.logic.importer.fetcher.RfcFetcher;
43+
import org.jabref.logic.importer.fetcher.isbntobibtex.IsbnFetcher;
4244
import org.jabref.logic.importer.plaincitation.PlainCitationParserChoice;
4345
import org.jabref.logic.l10n.Localization;
4446
import org.jabref.logic.util.TaskExecutor;
4547
import org.jabref.model.database.BibDatabaseMode;
4648
import org.jabref.model.entry.BibEntry;
4749
import org.jabref.model.entry.BibEntryType;
4850
import org.jabref.model.entry.BibEntryTypesManager;
51+
import org.jabref.model.entry.identifier.ArXivIdentifier;
52+
import org.jabref.model.entry.identifier.DOI;
53+
import org.jabref.model.entry.identifier.ISBN;
4954
import org.jabref.model.entry.identifier.Identifier;
55+
import org.jabref.model.entry.identifier.RFC;
56+
import org.jabref.model.entry.identifier.SSRN;
5057
import org.jabref.model.entry.types.BiblatexAPAEntryTypeDefinitions;
5158
import org.jabref.model.entry.types.BiblatexEntryTypeDefinitions;
5259
import org.jabref.model.entry.types.BiblatexSoftwareEntryTypeDefinitions;
@@ -147,7 +154,7 @@ private void finalizeTabs() {
147154
if (approach == null) {
148155
final String clipboardText = ClipBoardManager.getContents().trim();
149156
if (!StringUtil.isBlank(clipboardText)) {
150-
Optional<Identifier> identifier = CompositeIdFetcher.getIdentifier(clipboardText);
157+
Optional<Identifier> identifier = Identifier.from(clipboardText);
151158
if (identifier.isPresent()) {
152159
approach = NewEntryDialogTab.ENTER_IDENTIFIER;
153160
interpretText.setText(clipboardText);
@@ -254,14 +261,6 @@ private void initializeLookupIdentifier() {
254261
idText.setPromptText(Localization.lang("Enter the reference identifier to search for."));
255262
idText.textProperty().bindBidirectional(viewModel.idTextProperty());
256263
final String clipboardText = ClipBoardManager.getContents().trim();
257-
if (!StringUtil.isBlank(clipboardText) && !clipboardText.contains("\n")) {
258-
// :TODO: Better validation would be nice here, so clipboard text is only copied over if it matches a
259-
// supported identifier format.
260-
idText.setText(clipboardText);
261-
idText.selectAll();
262-
}
263-
264-
idLookupGuess.selectedProperty().addListener((_, _, newValue) -> preferences.setIdLookupGuessing(newValue));
265264

266265
ToggleGroup toggleGroup = new ToggleGroup();
267266
idLookupGuess.setToggleGroup(toggleGroup);
@@ -273,6 +272,23 @@ private void initializeLookupIdentifier() {
273272
idLookupSpecify.selectedProperty().set(true);
274273
}
275274

275+
// [impl->req~newentry.clipboard.autofocus~1]
276+
Optional<Identifier> validClipboardId = extractValidIdentifierFromClipboard();
277+
if (validClipboardId.isPresent()) {
278+
idText.setText(ClipBoardManager.getContents().trim());
279+
idText.selectAll();
280+
281+
Identifier id = validClipboardId.get();
282+
Platform.runLater(() -> {
283+
idLookupSpecify.setSelected(true);
284+
fetcherForIdentifier(id).ifPresent(idFetcher::setValue);
285+
});
286+
} else {
287+
Platform.runLater(() -> idLookupGuess.setSelected(true));
288+
}
289+
290+
idLookupGuess.selectedProperty().addListener((_, _, newValue) -> preferences.setIdLookupGuessing(newValue));
291+
276292
idFetcher.itemsProperty().bind(viewModel.idFetchersProperty());
277293
new ViewModelListCellFactory<IdBasedFetcher>().withText(WebFetcher::getName).install(idFetcher);
278294
idFetcher.disableProperty().bind(idLookupSpecify.selectedProperty().not());
@@ -523,4 +539,41 @@ private static PlainCitationParserChoice parserFromName(String parserName, List<
523539
}
524540
return null;
525541
}
542+
543+
private Optional<Identifier> extractValidIdentifierFromClipboard() {
544+
String clipboardText = ClipBoardManager.getContents().trim();
545+
546+
if (!StringUtil.isBlank(clipboardText) && !clipboardText.contains("\n")) {
547+
Optional<Identifier> identifier = Identifier.from(clipboardText);
548+
if (identifier.isPresent()) {
549+
Identifier id = identifier.get();
550+
boolean isValid = switch (id) {
551+
case DOI doi ->
552+
DOI.isValid(doi.asString());
553+
case ISBN isbn ->
554+
isbn.isValid();
555+
default ->
556+
true;
557+
};
558+
if (isValid) {
559+
return Optional.of(id);
560+
}
561+
}
562+
}
563+
564+
return Optional.empty();
565+
}
566+
567+
private Optional<IdBasedFetcher> fetcherForIdentifier(Identifier id) {
568+
for (IdBasedFetcher fetcher : idFetcher.getItems()) {
569+
if ((id instanceof DOI && fetcher instanceof DoiFetcher) ||
570+
(id instanceof ISBN && (fetcher instanceof IsbnFetcher) ||
571+
(id instanceof ArXivIdentifier && fetcher instanceof ArXivFetcher) ||
572+
(id instanceof RFC && fetcher instanceof RfcFetcher) ||
573+
(id instanceof SSRN && fetcher instanceof DoiFetcher))) {
574+
return Optional.of(fetcher);
575+
}
576+
}
577+
return Optional.empty();
578+
}
526579
}

jablib/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
exports org.jabref.logic.integrity;
8080
exports org.jabref.logic.formatter.casechanger;
8181
exports org.jabref.logic.shared.exception;
82+
exports org.jabref.logic.importer.fetcher.isbntobibtex;
8283
exports org.jabref.logic.importer.fetcher.transformers;
8384
exports org.jabref.logic.biblog;
8485
exports org.jabref.model.biblog;

jablib/src/main/java/org/jabref/logic/importer/CompositeIdFetcher.java

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package org.jabref.logic.importer;
22

33
import java.util.Optional;
4-
import java.util.function.Supplier;
5-
import java.util.stream.Stream;
64

75
import org.jabref.logic.importer.fetcher.ArXivFetcher;
86
import org.jabref.logic.importer.fetcher.DoiFetcher;
@@ -15,7 +13,6 @@
1513
import org.jabref.model.entry.identifier.Identifier;
1614
import org.jabref.model.entry.identifier.RFC;
1715
import org.jabref.model.entry.identifier.SSRN;
18-
import org.jabref.model.strings.StringUtil;
1916

2017
public class CompositeIdFetcher {
2118

@@ -67,25 +64,6 @@ public String getName() {
6764
}
6865

6966
public static boolean containsValidId(String identifier) {
70-
return getIdentifier(identifier).isPresent();
71-
}
72-
73-
public static Optional<Identifier> getIdentifier(String identifier) {
74-
if (StringUtil.isBlank(identifier)) {
75-
return Optional.empty();
76-
}
77-
78-
return Stream.<Supplier<Optional<? extends Identifier>>>of(
79-
() -> DOI.findInText(identifier),
80-
() -> ArXivIdentifier.parse(identifier),
81-
() -> ISBN.parse(identifier),
82-
() -> SSRN.parse(identifier),
83-
() -> RFC.parse(identifier)
84-
)
85-
.map(Supplier::get)
86-
.filter(Optional::isPresent)
87-
.map(Optional::get)
88-
.map(id -> (Identifier) id)
89-
.findFirst();
67+
return Identifier.from(identifier).isPresent();
9068
}
9169
}

jablib/src/main/java/org/jabref/model/entry/identifier/Identifier.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import java.net.URI;
44
import java.util.Optional;
5+
import java.util.function.Supplier;
6+
import java.util.stream.Stream;
57

68
import org.jabref.model.entry.field.Field;
9+
import org.jabref.model.strings.StringUtil;
710

811
/**
912
* All implementing classes should additionally offer
@@ -23,4 +26,23 @@ public interface Identifier {
2326
Field getDefaultField();
2427

2528
Optional<URI> getExternalURI();
29+
30+
public static Optional<Identifier> from(String identifier) {
31+
if (StringUtil.isBlank(identifier)) {
32+
return Optional.empty();
33+
}
34+
35+
return Stream.<Supplier<Optional<? extends Identifier>>>of(
36+
() -> DOI.findInText(identifier),
37+
() -> ArXivIdentifier.parse(identifier),
38+
() -> ISBN.parse(identifier),
39+
() -> SSRN.parse(identifier),
40+
() -> RFC.parse(identifier)
41+
)
42+
.map(Supplier::get)
43+
.filter(Optional::isPresent)
44+
.map(Optional::get)
45+
.map(id -> (Identifier) id)
46+
.findFirst();
47+
}
2648
}

jablib/src/test/java/org/jabref/logic/importer/fetcher/CompositeIdFetcherTest.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,25 @@
1010
import org.jabref.model.entry.field.InternalField;
1111
import org.jabref.model.entry.field.StandardField;
1212
import org.jabref.model.entry.field.UnknownField;
13+
import org.jabref.model.entry.identifier.ArXivIdentifier;
14+
import org.jabref.model.entry.identifier.DOI;
15+
import org.jabref.model.entry.identifier.ISBN;
16+
import org.jabref.model.entry.identifier.Identifier;
17+
import org.jabref.model.entry.identifier.RFC;
1318
import org.jabref.model.entry.types.StandardEntryType;
1419
import org.jabref.testutils.category.FetcherTest;
1520

1621
import org.junit.jupiter.api.BeforeEach;
22+
import org.junit.jupiter.api.Test;
1723
import org.junit.jupiter.params.ParameterizedTest;
1824
import org.junit.jupiter.params.provider.Arguments;
1925
import org.junit.jupiter.params.provider.MethodSource;
2026
import org.junit.jupiter.params.provider.ValueSource;
2127
import org.mockito.Answers;
2228

2329
import static org.junit.jupiter.api.Assertions.assertEquals;
30+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
31+
import static org.junit.jupiter.api.Assertions.assertTrue;
2432
import static org.mockito.Mockito.mock;
2533
import static org.mockito.Mockito.when;
2634

@@ -127,4 +135,41 @@ void performSearchByIdReturnsEmptyForInvalidId(String groundInvalidArXivId) thro
127135
void performSearchByIdReturnsCorrectEntryForIdentifier(String name, BibEntry bibEntry, String identifier) throws FetcherException {
128136
assertEquals(Optional.of(bibEntry), compositeIdFetcher.performSearchById(identifier));
129137
}
138+
139+
@Test
140+
void detectsValidDOI() {
141+
Optional<Identifier> result = Identifier.from("10.1109/MCOM.2010.5673082");
142+
assertTrue(result.isPresent());
143+
assertInstanceOf(DOI.class, result.get());
144+
assertTrue(DOI.isValid(result.get().asString()));
145+
}
146+
147+
@Test
148+
void rejectsInvalidDOI() {
149+
Optional<Identifier> result = Identifier.from("123456789");
150+
boolean isInvalid = result.isEmpty() || (result.get() instanceof DOI && !DOI.isValid(result.get().asString()));
151+
assertTrue(isInvalid);
152+
}
153+
154+
@Test
155+
void detectsValidISBN() {
156+
Optional<Identifier> result = Identifier.from("9780134685991");
157+
assertTrue(result.isPresent());
158+
assertInstanceOf(ISBN.class, result.get());
159+
assertTrue(((ISBN) result.get()).isValid());
160+
}
161+
162+
@Test
163+
void detectsValidArXiv() {
164+
Optional<Identifier> result = Identifier.from("arXiv:1706.03762");
165+
assertTrue(result.isPresent());
166+
assertInstanceOf(ArXivIdentifier.class, result.get());
167+
}
168+
169+
@Test
170+
void detectsValidRFC() {
171+
Optional<Identifier> result = Identifier.from("rfc2616");
172+
assertTrue(result.isPresent());
173+
assertInstanceOf(RFC.class, result.get());
174+
}
130175
}

0 commit comments

Comments
 (0)