diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5c86709aa..211edebc04b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,12 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv - We added a drop-down menu to those custom fields in the main table for which content selector values exists. [#14087](https://github.com/JabRef/jabref/issues/14087) - We added a "Jump to Field" dialog (`Ctrl+J`) to quickly search for and navigate to any field across all tabs. [#12276](https://github.com/JabRef/jabref/issues/12276). -- We added "IEEE" as another option for parsing plain text citations. [#14233](github.com/JabRef/jabref/pull/14233) +- We made the "Configure API key" option in the Web Search preferences tab searchable via preferences search. [#13929](https://github.com/JabRef/jabref/issues/13929) +- We added the integrity check to the jabkit cli application. [#13848](https://github.com/JabRef/jabref/issues/13848) +- We added support for Cygwin-file paths on a Windows Operating System. [#13274](https://github.com/JabRef/jabref/issues/13274) +- We fixed an issue where "Print preview" would throw a `NullPointerException` if no printers were available. [#13708](https://github.com/JabRef/jabref/issues/13708) +- We added cover images for books, which will display in entry previews if available, and can be automatically downloaded when adding an entry via ISBN. [#10120](https://github.com/JabRef/jabref/issues/10120) +- We added "IEEE" as another option for parsing plain text citations. [#14233](https://github.com/JabRef/jabref/pull/14233) - We added automatic date-based groups that create year/month/day subgroups from an entry’s date fields. [#10822](https://github.com/JabRef/jabref/issues/10822) - We added `doi-to-bibtex` to `JabKit`. [#14244](https://github.com/JabRef/jabref/pull/14244) - We added `--provider=crossref` to `get-cited-works` at `JabKit`. [#14357](https://github.com/JabRef/jabref/pull/14357) diff --git a/jabgui/src/main/java/org/jabref/gui/externalfiles/ImportHandler.java b/jabgui/src/main/java/org/jabref/gui/externalfiles/ImportHandler.java index eff6b7bb250..e3ea184d79a 100644 --- a/jabgui/src/main/java/org/jabref/gui/externalfiles/ImportHandler.java +++ b/jabgui/src/main/java/org/jabref/gui/externalfiles/ImportHandler.java @@ -431,7 +431,7 @@ public List handleStringData(String data) throws FetcherException { LOGGER.trace("Checking if URL is a PDF: {}", data); if (URLUtil.isURL(data)) { - String fileName = data.substring(data.lastIndexOf('/') + 1); + String fileName = FileUtil.getFileNameFromUrl(data).orElse("downloaded.pdf"); if (FileUtil.isPDFFile(Path.of(fileName))) { try { return handlePdfUrl(data); @@ -498,7 +498,7 @@ private List handlePdfUrl(String pdfUrl) throws IOException { return List.of(); } URLDownload urlDownload = new URLDownload(pdfUrl); - String filename = URLUtil.getFileNameFromUrl(pdfUrl); + String filename = FileUtil.getFileNameFromUrl(pdfUrl).orElse("downloaded.pdf"); Path targetFile = targetDirectory.get().resolve(filename); try { urlDownload.toFile(targetFile); diff --git a/jabgui/src/main/java/org/jabref/gui/importer/BookCoverFetcher.java b/jabgui/src/main/java/org/jabref/gui/importer/BookCoverFetcher.java new file mode 100644 index 00000000000..ebee861b217 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/importer/BookCoverFetcher.java @@ -0,0 +1,126 @@ +package org.jabref.gui.importer; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.jabref.gui.externalfiletype.ExternalFileType; +import org.jabref.gui.externalfiletype.ExternalFileTypes; +import org.jabref.gui.externalfiletype.StandardExternalFileType; +import org.jabref.gui.frame.ExternalApplicationsPreferences; +import org.jabref.logic.importer.FetcherException; +import org.jabref.logic.net.URLDownload; +import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.identifier.ISBN; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides functions for downloading and retrieving book covers for entries. + */ +public class BookCoverFetcher { + + private static final Logger LOGGER = LoggerFactory.getLogger(BookCoverFetcher.class); + + private static final Pattern URL_JSON_PATTERN = Pattern.compile("^\\s*\\{\\s*\"url\"\\s*:\\s*\"([^\"]*)\"\\s*\\}\\s*$"); + + private static final String URL_FETCHER_URL = "https://bookcover.longitood.com/bookcover/"; + private static final String IMAGE_FALLBACK_URL = "https://covers.openlibrary.org/b/isbn/"; + private static final String IMAGE_FALLBACK_SUFFIX = "-L.jpg"; + + private final ExternalApplicationsPreferences externalApplicationsPreferences; + + public BookCoverFetcher(ExternalApplicationsPreferences externalApplicationsPreferences) { + this.externalApplicationsPreferences = externalApplicationsPreferences; + } + + public Optional getDownloadedCoverForEntry(BibEntry entry, String location) { + return entry.getISBN().flatMap(isbn -> findExistingImage("isbn-" + isbn.asString(), Path.of(location))); + } + + public void downloadCoversForEntry(BibEntry entry, String location) { + entry.getISBN().ifPresent(isbn -> downloadCoverForISBN(isbn, Path.of(location))); + } + + private void downloadCoverForISBN(ISBN isbn, Path directory) { + final String name = "isbn-" + isbn.asString(); + if (findExistingImage(name, directory).isEmpty()) { + final String url = getSourceForIsbn(isbn); + downloadCoverImage(url, name, directory); + } + } + + private void downloadCoverImage(String url, final String name, final Path directory) { + Optional extension = FileUtil.getFileNameFromUrl(url).flatMap(FileUtil::getFileExtension); + + try { + Files.createDirectories(directory); + } catch (IOException e) { + LOGGER.error("Could not access cover image directories", e); + return; + } + + try { + LOGGER.info("Downloading cover image file from {}", url); + + URLDownload download = new URLDownload(url); + Optional mime = download.getMimeType(); + + Optional inferedFromMime = mime.flatMap(m -> ExternalFileTypes.getExternalFileTypeByMimeType(m, externalApplicationsPreferences)).filter(t -> t.getMimeType().startsWith("image/")); + Optional inferedFromExtension = extension.flatMap(x -> ExternalFileTypes.getExternalFileTypeByExt(x, externalApplicationsPreferences)).filter(t -> t.getMimeType().startsWith("image/")); + + Optional destination = resolveNameWithType(directory, name, inferedFromMime.orElse(inferedFromExtension.orElse(StandardExternalFileType.JPG))); + if (destination.isPresent()) { + download.toFile(destination.get()); + } + } catch (FetcherException | MalformedURLException e) { + LOGGER.error("Error while downloading cover image file", e); + return; + } + } + + private static Optional resolveNameWithType(Path directory, String name, ExternalFileType filetype) { + try { + return Optional.of(directory.resolve(FileUtil.getValidFileName(name + "." + filetype.getExtension()))); + } catch (InvalidPathException e) { + return Optional.empty(); + } + } + + private Optional findExistingImage(final String name, final Path directory) { + return externalApplicationsPreferences.getExternalFileTypes().stream() + .filter(filetype -> filetype.getMimeType().startsWith("image/")) + .flatMap(filetype -> resolveNameWithType(directory, name, filetype).stream()) + .filter(Files::exists).findFirst(); + } + + private static String getSourceForIsbn(ISBN isbn) { + if (isbn.isIsbn13()) { + String url = URL_FETCHER_URL + isbn.asString(); + try { + LOGGER.info("Downloading book cover url from {}", url); + + URLDownload download = new URLDownload(url); + String json = download.asString(); + Matcher matches = URL_JSON_PATTERN.matcher(json); + + if (matches.find()) { + String coverUrlString = matches.group(1); + if (coverUrlString != null) { + return coverUrlString; + } + } + } catch (FetcherException | MalformedURLException e) { + LOGGER.error("Error while querying cover url, using fallback", e); + } + } + return IMAGE_FALLBACK_URL + isbn.asString() + IMAGE_FALLBACK_SUFFIX; + } +} 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 d30d1d3deae..5a0b202d07d 100644 --- a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java @@ -22,6 +22,7 @@ import org.jabref.gui.LibraryTab; import org.jabref.gui.StateManager; import org.jabref.gui.externalfiles.ImportHandler; +import org.jabref.gui.importer.BookCoverFetcher; import org.jabref.gui.preferences.GuiPreferences; import org.jabref.gui.util.UiTaskExecutor; import org.jabref.logic.ai.AiService; @@ -66,6 +67,8 @@ public class NewEntryViewModel { private final AiService aiService; private final FileUpdateMonitor fileUpdateMonitor; + private final BookCoverFetcher bookCoverFetcher; + private final BooleanProperty executing; private final BooleanProperty executedSuccessfully; @@ -104,6 +107,8 @@ public NewEntryViewModel(GuiPreferences preferences, this.aiService = aiService; this.fileUpdateMonitor = fileUpdateMonitor; + this.bookCoverFetcher = new BookCoverFetcher(preferences.getExternalApplicationsPreferences()); + executing = new SimpleBooleanProperty(false); executedSuccessfully = new SimpleBooleanProperty(false); doiCache = new HashMap<>(); @@ -234,6 +239,14 @@ public ReadOnlyBooleanProperty bibtexTextValidatorProperty() { return bibtexTextValidator.getValidationStatus().validProperty(); } + private BibEntry withCoversDownloaded(BibEntry entry) { + if (preferences.getFilePreferences().shouldDownloadCovers()) { + String location = preferences.getFilePreferences().coversDownloadLocation(); + bookCoverFetcher.downloadCoversForEntry(entry, location); + } + return entry; + } + private class WorkerLookupId extends Task> { @Override protected Optional call() throws FetcherException { @@ -244,7 +257,7 @@ protected Optional call() throws FetcherException { return Optional.empty(); } - return fetcher.performSearchById(text); + return fetcher.performSearchById(text).map(e -> withCoversDownloaded(e)); } } @@ -259,7 +272,7 @@ protected Optional call() throws FetcherException { return Optional.empty(); } - return fetcher.performSearchById(text); + return fetcher.performSearchById(text).map(e -> withCoversDownloaded(e)); } } diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/linkedfiles/LinkedFilesTab.java b/jabgui/src/main/java/org/jabref/gui/preferences/linkedfiles/LinkedFilesTab.java index f62182124fe..78ac8197000 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/linkedfiles/LinkedFilesTab.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/linkedfiles/LinkedFilesTab.java @@ -5,6 +5,7 @@ import javafx.scene.control.Button; import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; import javafx.scene.control.RadioButton; import javafx.scene.control.TextField; @@ -26,6 +27,11 @@ public class LinkedFilesTab extends AbstractPreferenceTabView highlightLayoutText()); + this.bookCoverFetcher = new BookCoverFetcher(preferences.getExternalApplicationsPreferences()); + setFitToHeight(true); setFitToWidth(true); previewView = WebViewStore.get(); @@ -222,17 +228,27 @@ private String formatError(BibEntry entry, Throwable exception) { } private void setPreviewText(String text) { + String coverIfAny = getCoverImageURL().map(url -> "
".formatted(url)).orElse(""); + layoutText = """ - - -
%s
- - - """.formatted(text); + + + %s
%s
+ + + """.formatted(coverIfAny, text); highlightLayoutText(); setHvalue(0); } + private Optional getCoverImageURL() { + if (entry != null) { + String location = preferences.getFilePreferences().coversDownloadLocation(); + return bookCoverFetcher.getDownloadedCoverForEntry(entry, location).map(p -> p.toUri().toString()); + } + return Optional.empty(); + } + private void highlightLayoutText() { if (layoutText == null) { return; @@ -285,7 +301,10 @@ public void copyPreviewPlainTextToClipBoard() { return; } - clipBoardManager.setContent((String) previewView.getEngine().executeScript("document.body.innerText")); + String plainText = (String) previewView.getEngine().executeScript("document.body.innerText"); + ClipboardContent content = new ClipboardContent(); + content.putString(plainText); + clipBoardManager.setContent(content); } public void copySelectionToClipBoard() { diff --git a/jabgui/src/main/java/org/jabref/gui/texparser/ParseLatexDialogViewModel.java b/jabgui/src/main/java/org/jabref/gui/texparser/ParseLatexDialogViewModel.java index 92909584765..98ba851c5f6 100644 --- a/jabgui/src/main/java/org/jabref/gui/texparser/ParseLatexDialogViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/texparser/ParseLatexDialogViewModel.java @@ -78,9 +78,8 @@ public ParseLatexDialogViewModel(BibDatabaseContext databaseContext, this.searchInProgress = new SimpleBooleanProperty(false); this.successfulSearch = new SimpleBooleanProperty(false); - Predicate isDirectory = path -> Path.of(path).toFile().isDirectory(); - latexDirectoryValidator = new FunctionBasedValidator<>(latexFileDirectory, isDirectory, - ValidationMessage.error(Localization.lang("Please enter a valid file path."))); + Predicate isDirectory = path -> Files.isDirectory(Path.of(path)); + latexDirectoryValidator = new FunctionBasedValidator<>(latexFileDirectory, isDirectory, ValidationMessage.error(Localization.lang("Please enter a valid file path."))); } public StringProperty latexFileDirectoryProperty() { @@ -152,7 +151,7 @@ private void handleFailure(Exception exception) { } private FileNodeViewModel searchDirectory(Path directory) throws IOException { - if ((directory == null) || !directory.toFile().isDirectory()) { + if ((directory == null) || !Files.isDirectory(directory)) { throw new IOException("Invalid directory for searching: %s".formatted(directory)); } @@ -160,7 +159,7 @@ private FileNodeViewModel searchDirectory(Path directory) throws IOException { Map> fileListPartition; try (Stream filesStream = Files.list(directory)) { - fileListPartition = filesStream.collect(Collectors.partitioningBy(path -> path.toFile().isDirectory())); + fileListPartition = filesStream.collect(Collectors.partitioningBy(Files::isDirectory)); } catch (IOException e) { LOGGER.error("Error searching files", e); return parent; @@ -195,7 +194,7 @@ private FileNodeViewModel searchDirectory(Path directory) throws IOException { public void parseButtonClicked() { List fileList = checkedFileList.stream() .map(item -> item.getValue().getPath()) - .filter(path -> path.toFile().isFile()) + .filter(Files::isRegularFile) .toList(); if (fileList.isEmpty()) { LOGGER.warn("There are no valid files checked"); diff --git a/jabgui/src/main/java/org/jabref/gui/util/FileNodeViewModel.java b/jabgui/src/main/java/org/jabref/gui/util/FileNodeViewModel.java index 253ea20a69f..3a45986f912 100644 --- a/jabgui/src/main/java/org/jabref/gui/util/FileNodeViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/util/FileNodeViewModel.java @@ -63,7 +63,7 @@ public static String formatDateTime(FileTime fileTime) { * Return a string for displaying a node name (and its number of children if it is a directory). */ public String getDisplayText() { - if (path.toFile().isDirectory()) { + if (Files.isDirectory(path)) { return "%s (%s)".formatted(path.getFileName(), Localization.lang("%0 file(s)", fileCount)); } return path.getFileName().toString(); @@ -74,7 +74,7 @@ public String getDisplayText() { * along with the last edited time */ public String getDisplayTextWithEditDate() { - if (path.toFile().isDirectory()) { + if (Files.isDirectory(path)) { return "%s (%s)".formatted(path.getFileName(), Localization.lang("%0 file(s)", fileCount)); } FileTime lastEditedTime = null; diff --git a/jabgui/src/main/java/org/jabref/gui/welcome/quicksettings/viewmodel/PushApplicationDialogViewModel.java b/jabgui/src/main/java/org/jabref/gui/welcome/quicksettings/viewmodel/PushApplicationDialogViewModel.java index e0bcca3e793..33efcebd147 100644 --- a/jabgui/src/main/java/org/jabref/gui/welcome/quicksettings/viewmodel/PushApplicationDialogViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/welcome/quicksettings/viewmodel/PushApplicationDialogViewModel.java @@ -1,5 +1,6 @@ package org.jabref.gui.welcome.quicksettings.viewmodel; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; @@ -138,7 +139,7 @@ public boolean isValidConfiguration() { } String pathText = pathProperty.get().trim(); Path path = Path.of(pathText); - return !pathText.isEmpty() && path.isAbsolute() && path.toFile().exists(); + return !pathText.isEmpty() && path.isAbsolute() && Files.exists(path); } public void saveSettings() { diff --git a/jabgui/src/main/java/org/jabref/gui/welcome/quicksettings/viewmodel/ThemeDialogViewModel.java b/jabgui/src/main/java/org/jabref/gui/welcome/quicksettings/viewmodel/ThemeDialogViewModel.java index b755e41be63..2379f644186 100644 --- a/jabgui/src/main/java/org/jabref/gui/welcome/quicksettings/viewmodel/ThemeDialogViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/welcome/quicksettings/viewmodel/ThemeDialogViewModel.java @@ -1,5 +1,6 @@ package org.jabref.gui.welcome.quicksettings.viewmodel; +import java.nio.file.Files; import java.nio.file.Path; import javafx.beans.property.ObjectProperty; @@ -80,8 +81,7 @@ public void browseForThemeFile() { public boolean isValidConfiguration() { if (selectedThemeProperty.get() == ThemeTypes.CUSTOM) { - return !customPathProperty.get().trim().isEmpty() && - Path.of(customPathProperty.get()).toFile().exists(); + return !customPathProperty.get().trim().isEmpty() && Files.exists(Path.of(customPathProperty.get())); } return selectedThemeProperty.get() != null; } diff --git a/jabgui/src/main/resources/org/jabref/gui/preferences/linkedfiles/LinkedFilesTab.fxml b/jabgui/src/main/resources/org/jabref/gui/preferences/linkedfiles/LinkedFilesTab.fxml index e8bf2ba0a39..229475c5e1e 100644 --- a/jabgui/src/main/resources/org/jabref/gui/preferences/linkedfiles/LinkedFilesTab.fxml +++ b/jabgui/src/main/resources/org/jabref/gui/preferences/linkedfiles/LinkedFilesTab.fxml @@ -44,6 +44,13 @@ text="%When downloading files, or moving linked files to the file directory, use the bib file location."/> + + + + +