diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f063f9488..7d4095e184f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added - We added support for downloading full-text PDFs from Wiley journals via the Wiley TDM API. [#13404](https://github.com/JabRef/jabref/issues/13404) +- We added "Enter URL" tab in New Entry dialog to create a `@Misc` entry from any URL. [#15411](https://github.com/JabRef/jabref/issues/15411) - We added `--key-patterns` option to CLI parameters to allows users to set a citation key's pattern for a specific entry type. [#14707](https://github.com/JabRef/jabref/issues/14707) - We added a CLI option `--field-formatters` to the `convert` and `generate-bib-from-aux` commands to apply field formatters during export. [#11520](https://github.com/JabRef/jabref/issues/11520) - We added a preference to skip the import dialog for entries received from browser extensions, allowing direct import into the current library. The import dialog is shown by default; users can enable direct import in Preferences. diff --git a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryDialogTab.java b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryDialogTab.java index f603d084bb9..9a288bfbd0d 100644 --- a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryDialogTab.java +++ b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryDialogTab.java @@ -5,4 +5,5 @@ public enum NewEntryDialogTab { ENTER_IDENTIFIER, INTERPRET_CITATIONS, SPECIFY_BIBTEX, + ENTER_URL, } diff --git a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java index 2583a04a3ae..e89f7e28bcc 100644 --- a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java +++ b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java @@ -128,6 +128,9 @@ public class NewEntryView extends BaseDialog { @FXML private TextArea bibtexText; + @FXML private Tab tabEnterUrl; + @FXML private TextField urlText; + private BibEntry result; public NewEntryView(NewEntryDialogTab initialApproach, GuiPreferences preferences, LibraryTab libraryTab, DialogService dialogService) { @@ -206,12 +209,17 @@ private void finalizeTabs() { tabs.getSelectionModel().select(tabSpecifyBibtex); switchSpecifyBibtex(); break; + case NewEntryDialogTab.ENTER_URL: + tabs.getSelectionModel().select(tabEnterUrl); + switchEnterUrl(); + break; } tabAddEntry.setOnSelectionChanged(_ -> switchAddEntry()); tabLookupIdentifier.setOnSelectionChanged(_ -> switchLookupIdentifier()); tabInterpretCitations.setOnSelectionChanged(_ -> switchInterpretCitations()); tabSpecifyBibtex.setOnSelectionChanged(_ -> switchSpecifyBibtex()); + tabEnterUrl.setOnSelectionChanged(_ -> switchEnterUrl()); } @FXML @@ -234,6 +242,7 @@ public void initialize() { initializeLookupIdentifier(); initializeInterpretCitations(); initializeSpecifyBibTeX(); + initializeEnterUrl(); } private void initializeAddEntry() { @@ -472,6 +481,31 @@ private void switchSpecifyBibtex() { } } + @FXML + private void switchEnterUrl() { + if (!tabEnterUrl.isSelected()) { + return; + } + + currentApproach = NewEntryDialogTab.ENTER_URL; + newEntryPreferences.setLatestApproach(NewEntryDialogTab.ENTER_URL); + + if (urlText != null) { + Platform.runLater(() -> urlText.requestFocus()); + } + + if (generateButton != null) { + generateButton.disableProperty().bind( + viewModel.urlTextValidatorProperty().not() + ); + generateButton.setText(Localization.lang("Search")); + } + } + + private void initializeEnterUrl() { + urlText.textProperty().bindBidirectional(viewModel.urlTextProperty()); + } + private void onEntryTypeSelected(EntryType type) { newEntryPreferences.setLatestImmediateType(type); result = new BibEntry(type); @@ -507,6 +541,11 @@ private void execute() { viewModel.executeSpecifyBibtex(); switchSpecifyBibtex(); break; + case NewEntryDialogTab.ENTER_URL: + generateButton.setText(Localization.lang("Searching...")); + viewModel.executeEnterUrl(); + switchEnterUrl(); + break; } } diff --git a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java index 98f12b54c1b..f5aa2dd0aaa 100644 --- a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -33,6 +34,7 @@ import org.jabref.logic.importer.IdBasedFetcher; import org.jabref.logic.importer.ParseException; import org.jabref.logic.importer.WebFetchers; +import org.jabref.logic.importer.fetcher.GenericUrlBasedFetcher; import org.jabref.logic.importer.fileformat.BibtexParser; import org.jabref.logic.importer.plaincitation.PlainCitationParser; import org.jabref.logic.importer.plaincitation.PlainCitationParserChoice; @@ -40,6 +42,7 @@ import org.jabref.logic.l10n.Localization; import org.jabref.logic.layout.LayoutFormatter; import org.jabref.logic.layout.format.DOIStrip; +import org.jabref.logic.util.URLUtil; import org.jabref.logic.util.strings.StringUtil; import org.jabref.model.TransferInformation; import org.jabref.model.TransferMode; @@ -91,6 +94,9 @@ public class NewEntryViewModel { private final StringProperty bibtexText; private final Validator bibtexTextValidator; private Task>> bibtexWorker; + private final StringProperty urlText; + private final Validator urlTextValidator; + private Task>> urlWorker; private final Map doiCache; private BibEntry duplicateEntry; @@ -150,6 +156,22 @@ public NewEntryViewModel(GuiPreferences preferences, StringUtil::isNotBlank, ValidationMessage.error(Localization.lang("You must specify a Bib(La)TeX source."))); bibtexWorker = null; + + urlText = new SimpleStringProperty(""); + urlTextValidator = new FunctionBasedValidator<>(urlText, input -> { + if (StringUtil.isBlank(input)) { + return false; + } + + String normalized = input.trim(); + String lower = normalized.toLowerCase(Locale.ROOT); + + if (!lower.startsWith("http://") && !lower.startsWith("https://")) { + normalized = "https://" + normalized; + } + + return URLUtil.isValidHttpUrl(normalized); + }, ValidationMessage.error(Localization.lang("Please enter a valid HTTP or HTTPS URL."))); } public void populateDOICache() { @@ -238,6 +260,14 @@ public ReadOnlyBooleanProperty bibtexTextValidatorProperty() { return bibtexTextValidator.getValidationStatus().validProperty(); } + public StringProperty urlTextProperty() { + return urlText; + } + + public ReadOnlyBooleanProperty urlTextValidatorProperty() { + return urlTextValidator.getValidationStatus().validProperty(); + } + private BibEntry withCoversDownloaded(BibEntry entry) { if (preferences.getPreviewPreferences().shouldDownloadCovers()) { bookCoverFetcher.downloadCoversForEntry(entry); @@ -469,6 +499,27 @@ protected Optional> call() throws ParseException { } } + private class WorkerLookupUrl extends Task>> { + @Override + protected Optional> call() throws FetcherException { + final String text = urlText.getValue(); + final boolean textValid = urlTextValidator.getValidationStatus().isValid(); + + if (text == null || !textValid) { + return Optional.empty(); + } + + GenericUrlBasedFetcher fetcher = new GenericUrlBasedFetcher(); + + List entries = fetcher.fetchEntryFromUrl(text); + + if (entries.isEmpty()) { + return Optional.empty(); + } + return Optional.of(entries); + } + } + public void executeSpecifyBibtex() { executing.setValue(true); @@ -534,6 +585,42 @@ public void executeSpecifyBibtex() { taskExecutor.execute(bibtexWorker); } + public void executeEnterUrl() { + executing.setValue(true); + + cancel(); + urlWorker = new WorkerLookupUrl(); + + urlWorker.setOnFailed(_ -> { + final Throwable exception = urlWorker.getException(); + final String exceptionMessage = Optional.ofNullable(exception.getMessage()).orElse(exception.toString()); + LOGGER.error("An exception occurred when looking up URL.", exception); + + dialogService.showInformationDialogAndWait(Localization.lang("Failed to look up URL"), Localization.lang("The following error occurred:\n%0", exceptionMessage)); + + executing.set(false); + }); + + urlWorker.setOnSucceeded(_ -> { + final Optional> result = urlWorker.getValue(); + + if (result.isEmpty()) { + dialogService.showWarningDialogAndWait(Localization.lang("Invalid result"), Localization.lang("No entry could be generated from the provided URL.\n" + "This entry may need to be added manually.")); + executing.set(false); + return; + } + + final ImportHandler handler = new ImportHandler(libraryTab.getBibDatabaseContext(), preferences, fileUpdateMonitor, libraryTab.getUndoManager(), stateManager, dialogService, taskExecutor); + + handler.importEntriesWithDuplicateCheck(null, result.get()); + + executedSuccessfully.set(true); + executing.set(false); + }); + + taskExecutor.execute(urlWorker); + } + public void cancel() { if (idLookupWorker != null) { idLookupWorker.cancel(); @@ -544,5 +631,9 @@ public void cancel() { if (bibtexWorker != null) { bibtexWorker.cancel(); } + + if (urlWorker != null) { + urlWorker.cancel(); + } } } diff --git a/jabgui/src/main/resources/org/jabref/gui/newentry/NewEntry.fxml b/jabgui/src/main/resources/org/jabref/gui/newentry/NewEntry.fxml index 18b2369f08a..4f7598bb0a1 100644 --- a/jabgui/src/main/resources/org/jabref/gui/newentry/NewEntry.fxml +++ b/jabgui/src/main/resources/org/jabref/gui/newentry/NewEntry.fxml @@ -244,6 +244,34 @@ wrapText="true"/> + + + + + + + + + + + + fetchEntryFromUrl(String url) throws FetcherException { + if (url == null || url.trim().isEmpty()) { + return List.of(); + } + + String trimmedUrl = url.trim(); + if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://")) { + trimmedUrl = "https://" + trimmedUrl; + } + + BibEntry entry = new BibEntry(StandardEntryType.Misc) + .withField(StandardField.URL, trimmedUrl); + return List.of(entry); + } +} diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index 6062011870f..e58a5f4528d 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -3009,6 +3009,12 @@ Enter\ Bib(La)TeX\ sources\ to\ generate\ entries\ from.=Enter Bib(La)TeX source Enter\ Identifier=Enter Identifier Enter\ identifier...=Enter identifier... Enter\ identifier=Enter identifier +Enter\ URL=Enter URL +Enter\ a\ URL\ to\ generate\ an\ entry\ from.=Enter a URL to generate an entry from. +Failed\ to\ look\ up\ URL=Failed to look up URL +No\ entry\ could\ be\ generated\ from\ the\ provided\ URL.\nThis\ entry\ may\ need\ to\ be\ added\ manually.=No entry could be generated from the provided URL.\nThis entry may need to be added manually. +URL=URL +Please\ enter\ a\ valid\ HTTP\ or\ HTTPS\ URL.=Please enter a valid HTTP or HTTPS URL. Enter\ plain\ citations\ to\ parse,\ separated\ by\ blank\ lines.=Enter plain citations to parse, separated by blank lines. Enter\ the\ reference\ identifier\ to\ search\ for.=Enter the reference identifier to search for. Entry\ already\ exists\ in\ a\ library=Entry already exists in a library diff --git a/jablib/src/test/java/org/jabref/logic/importer/fetcher/GenericUrlBasedFetcherTest.java b/jablib/src/test/java/org/jabref/logic/importer/fetcher/GenericUrlBasedFetcherTest.java new file mode 100644 index 00000000000..cdf203afaa4 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/importer/fetcher/GenericUrlBasedFetcherTest.java @@ -0,0 +1,50 @@ +package org.jabref.logic.importer.fetcher; + +import java.util.List; +import java.util.Optional; + +import org.jabref.logic.importer.FetcherException; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.entry.types.StandardEntryType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GenericUrlBasedFetcherTest { + private static final String TEST_URL = "https://gi-radar.de/397-coding-unterstuetzung-im-lauf-der-zeit/"; + private GenericUrlBasedFetcher fetcher; + + @BeforeEach + void setUp() { + fetcher = new GenericUrlBasedFetcher(); + } + + @Test + void getNameReturnsCorrectName() { + assertEquals("Generic URL Fetcher", fetcher.getName()); + } + + @Test + void fetchEntryFromUrlWithValidUrlCreatesCorrectEntry() throws FetcherException { + List results = fetcher.fetchEntryFromUrl(TEST_URL); + assertEquals(1, results.size()); + BibEntry entry = results.get(0); + assertEquals(Optional.of(TEST_URL), entry.getField(StandardField.URL)); + assertEquals(StandardEntryType.Misc, entry.getType()); + } + + @Test + void fetchEntryFromUrlWithBlankUrlReturnsEmptyList() throws FetcherException { + List results = fetcher.fetchEntryFromUrl(" "); + assertTrue(results.isEmpty()); + } + + @Test + void fetchEntryFromUrlTrimsUrl() throws FetcherException { + List results = fetcher.fetchEntryFromUrl(" " + TEST_URL + " "); + assertEquals(1, results.size()); + assertEquals(Optional.of(TEST_URL), results.get(0).getField(StandardField.URL)); + } +}