diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f69c33cfc78..8850f2b4c086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added +- We added support for displaying linked PDF annotations in the entry preview. [#4257](https://github.com/JabRef/jabref/issues/4257) - We added a new "Main" tab to the entry editor showing all fields of an entry in a single scrollable list, with one-click chips for adding optional fields and a free-form box for adding arbitrary fields. Identifiers, files and links, bibliometrics, comments, and meta fields (groups, owner, timestamps, special fields) live in collapsible sections — collapsed when empty — each offering chips for its unset fields. [#12711](https://github.com/JabRef/jabref/issues/12711) - We added auto-detection import for drag-and-dropped library files. [#15391](https://github.com/JabRef/jabref/issues/15391) - We added a preview style selection bar which shows the current preview style and allows to select a specific style without cycling through all of them. [#15820](https://github.com/JabRef/jabref/pull/15820) diff --git a/jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java b/jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java index 9b1228049c6f..b5904ca3b4ee 100644 --- a/jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java +++ b/jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java @@ -1,10 +1,12 @@ package org.jabref.gui.preview; import java.io.IOException; +import java.lang.reflect.Field; import java.net.MalformedURLException; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import javafx.beans.InvalidationListener; import javafx.beans.Observable; @@ -24,16 +26,22 @@ import org.jabref.gui.preferences.GuiPreferences; import org.jabref.gui.search.Highlighter; import org.jabref.gui.util.UiTaskExecutor; +import org.jabref.gui.util.WebViewStore; + import org.jabref.htmltonode.HtmlRenderOptions; import org.jabref.htmltonode.rich.RichHtmlView; +import org.jabref.logic.FilePreferences; import org.jabref.logic.l10n.Localization; -import org.jabref.logic.layout.format.Number; +import org.jabref.logic.pdf.EntryAnnotationImporter; +import org.jabref.logic.pdf.FileAnnotationPreview; import org.jabref.logic.preview.PreviewLayout; import org.jabref.logic.util.BackgroundTask; +import org.jabref.logic.util.DelayTaskThrottler; import org.jabref.logic.util.TaskExecutor; import org.jabref.logic.util.strings.StringUtil; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; +import org.jabref.model.pdf.FileAnnotation; import org.jabref.model.search.query.SearchQuery; import com.airhacks.afterburner.injection.Injector; @@ -60,6 +68,9 @@ public class PreviewViewer extends ScrollPane implements InvalidationListener { private final BookCoverFetcher bookCoverFetcher; + private final AtomicLong latestRequestId = new AtomicLong(0); + private final DelayTaskThrottler previewThrottler = new DelayTaskThrottler(400); + private @Nullable BibDatabaseContext databaseContext; private @Nullable BibEntry entry; private PreviewLayout layout; @@ -181,22 +192,45 @@ private void update() { databaseContext == null ? "null" : databaseContext, entry == null ? "null" : entry, layout == null ? "null" : layout); + previewThrottler.cancel(); + latestRequestId.incrementAndGet(); setPreviewText(""); return; } - Number.serialExportNumber = 1; - BibEntry currentEntry = entry; - - BackgroundTask.wrap(() -> layout.generatePreview(currentEntry, databaseContext)) - .onSuccess(previewText -> { - setPreviewText(previewText); - if (preferences.getPreviewPreferences().shouldDownloadCovers()) { - downloadCoverAndRefresh(currentEntry, previewText); - } - }) - .onFailure(e -> setPreviewText(formatError(currentEntry, e))) - .executeWith(taskExecutor); + previewThrottler.schedule(() -> { + long requestId = latestRequestId.incrementAndGet(); + BibEntry currentEntry = this.entry; + BibDatabaseContext currentDatabaseContext = this.databaseContext; + PreviewLayout currentLayout = this.layout; + var filePreferences = preferences.getFilePreferences(); + + BackgroundTask.wrap(() -> generatePreviewWithAnnotations(currentEntry, filePreferences)) + .onSuccess(htmlComplete -> { + if (requestId == latestRequestId.get() + && Objects.equals(currentEntry, this.entry) + && Objects.equals(currentDatabaseContext, this.databaseContext) + && (currentLayout == null || Objects.equals(currentLayout, this.layout))) { + setPreviewText(htmlComplete); + + if (preferences.getPreviewPreferences().shouldDownloadCovers()) { + downloadCoverAndRefresh(currentEntry, htmlComplete); + } + } else { + LOGGER.debug("Stale preview task discarded for entry: {}", + currentEntry != null ? currentEntry.getCitationKey().orElse("") : ""); + } + }) + .onFailure(exception -> { + if (requestId == latestRequestId.get() && currentEntry != null) { + String errorHtml = formatError(currentEntry, exception); + setPreviewText(errorHtml); + } else { + LOGGER.error("Error generating preview text", exception); + } + }) + .executeWith(taskExecutor); + }); } private void downloadCoverAndRefresh(BibEntry entry, String previewText) { @@ -375,4 +409,36 @@ public String getSelectionHtmlContent() { // The selection is plain text; only the whole-preview fallback carries markup return previewView.getSelectedText().orElseGet(() -> layoutText == null ? "" : layoutText); } + + private String generatePreviewWithAnnotations(BibEntry currentEntry, FilePreferences filePreferences) { + if (currentEntry == null || databaseContext == null || layout == null) { + return ""; + } + + try { + Field field = org.jabref.logic.layout.format.Number.class.getDeclaredField("serialExportNumber"); + field.setAccessible(true); + field.setInt(null, 1); + } catch (Exception e) { + LOGGER.warn("Could not reset Number.serialExportNumber", e); + } + + StringBuilder previewHtml = new StringBuilder(layout.generatePreview(currentEntry, databaseContext)); + + try { + EntryAnnotationImporter entryAnnotationImporter = new EntryAnnotationImporter(currentEntry); + java.util.Map> annotationsMap = + entryAnnotationImporter.importAnnotationsFromFiles(databaseContext, filePreferences); + + if (annotationsMap != null && !annotationsMap.isEmpty()) { + String annotationsHtml = FileAnnotationPreview.render(annotationsMap); + previewHtml.append(annotationsHtml); + } + } catch (Exception e) { + LOGGER.error("Failed to read PDF annotations for preview", e); + throw new RuntimeException("Error loading PDF annotations", e); + } + + return previewHtml.toString(); + } } diff --git a/jablib/src/main/java/org/jabref/logic/pdf/FileAnnotationPreview.java b/jablib/src/main/java/org/jabref/logic/pdf/FileAnnotationPreview.java new file mode 100644 index 000000000000..6866a445c5d1 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/pdf/FileAnnotationPreview.java @@ -0,0 +1,93 @@ +package org.jabref.logic.pdf; + +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.strings.StringUtil; +import org.jabref.model.pdf.FileAnnotation; + +import org.jspecify.annotations.Nullable; + +import static org.jabref.logic.util.strings.StringUtil.quoteForHTML; + +public class FileAnnotationPreview { + + public static String render(@Nullable Map> annotations) { + if (annotations == null || annotations.isEmpty()) { + return ""; + } + + StringBuilder html = new StringBuilder(); + html.append("

") + .append(Localization.lang("PDF Annotations")) + .append("
"); + + annotations.entrySet().stream() + .filter(entry -> entry.getKey() != null && entry.getKey().getFileName() != null) + .forEach(entry -> { + List fileAnnotations = entry.getValue(); + if (fileAnnotations == null || fileAnnotations.isEmpty()) { + return; + } + + String fileName = entry.getKey().getFileName().toString(); + html.append("
").append(quoteForHTML(fileName)).append("
"); + + fileAnnotations.stream() + .filter(Objects::nonNull) + .filter(annotation -> StringUtil.isNotBlank(annotation.getContent())) + .sorted(Comparator.comparingInt(FileAnnotation::getPage)) + .forEach(annotation -> renderAnnotation(html, annotation)); + }); + + return html.toString(); + } + + private static void renderAnnotation(StringBuilder html, FileAnnotation annotation) { + String typeStr; + + if (annotation.getAnnotationType() != null) { + String typeName = annotation.getAnnotationType().toString(); + + if ("HIGHLIGHT".equalsIgnoreCase(typeName)) { + typeStr = Localization.lang("highlight"); + } else if ("UNDERLINE".equalsIgnoreCase(typeName)) { + typeStr = Localization.lang("underline"); + } else if ("STRIKEOUT".equalsIgnoreCase(typeName)) { + typeStr = Localization.lang("strikeout"); + } else { + typeStr = typeName.toLowerCase(); + } + } else { + typeStr = Localization.lang("unknown"); + } + + int page = annotation.getPage(); + String content = annotation.getContent(); + String headerLabel = Localization.lang("%0 (page %1)", typeStr, String.valueOf(page)); + + html.append("") + .append(quoteForHTML(headerLabel)) + .append(" ") + .append(quoteForHTML(content)); + + if (annotation.hasLinkedAnnotation() && annotation.getLinkedFileAnnotation() != null) { + String noteContent = annotation.getLinkedFileAnnotation().getContent(); + + if (StringUtil.isNotBlank(noteContent)) { + String noteLabel = Localization.lang("Note"); + String formattedNote = " — " + noteLabel + ": " + noteContent; + + html.append("") + .append(quoteForHTML(formattedNote)) + .append(""); + } + } + + html.append("
"); + } +} diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index db4868b4c6dc..af208965648d 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -3576,3 +3576,11 @@ Specify\ a\ subcommand\ (consistency,\ integrity)\ or\ an\ input\ file.=Specify Specify\ a\ subcommand\ (reset,\ import,\ export).=Specify a subcommand (reset, import, export). Specify\ a\ subcommand\ (update).=Specify a subcommand (update). The\ format\ option\ must\ contain\ either\ 'xmp'\ or\ 'bibtex-attachment'.=The format option must contain either 'xmp' or 'bibtex-attachment'. + +Note=Note +PDF\ Annotations=PDF Annotations +unknown=unknown +highlight=highlight +strikeout=strikeout +underline=underline +%0\ (page\ %1)=%0 (page %1) diff --git a/jablib/src/test/java/org/jabref/logic/pdf/FileAnnotationPreviewTest.java b/jablib/src/test/java/org/jabref/logic/pdf/FileAnnotationPreviewTest.java new file mode 100644 index 000000000000..c663ecea51b1 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/pdf/FileAnnotationPreviewTest.java @@ -0,0 +1,116 @@ +package org.jabref.logic.pdf; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.jabref.logic.l10n.Language; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.pdf.FileAnnotation; +import org.jabref.model.pdf.FileAnnotationType; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.jabref.logic.util.strings.StringUtil.quoteForHTML; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class FileAnnotationPreviewTest { + + @BeforeAll + static void setUp() { + Localization.setLanguage(Language.ENGLISH); + } + + private FileAnnotation createMockAnnotation(FileAnnotationType type, int page, String content, boolean hasLinked) { + FileAnnotation annotation = mock(FileAnnotation.class); + when(annotation.getAnnotationType()).thenReturn(type); + when(annotation.getPage()).thenReturn(page); + when(annotation.getContent()).thenReturn(content); + when(annotation.hasLinkedAnnotation()).thenReturn(hasLinked); + return annotation; + } + + private String escape(String text) { + return quoteForHTML(text); + } + + @Nested + class EdgeCasesAndFilteringTests { + + @Test + void renderReturnsEmptyStringWhenMapIsEmpty() { + assertEquals("", FileAnnotationPreview.render(Map.of()), + "An empty map must produce an empty string output"); + } + + @Test + void renderFiltersOutNullAnnotationsAndEmptyContent() { + Path path = Path.of("test.pdf"); + FileAnnotation blankAnnotation = createMockAnnotation(FileAnnotationType.TEXT, 1, "", false); + + Map> annotations = Map.of(path, Arrays.asList((FileAnnotation) null, blankAnnotation)); + + String expectedHtml = "

PDF Annotations

" + escape("test.pdf") + "
"; + + assertEquals(expectedHtml, FileAnnotationPreview.render(annotations)); + } + } + + @Nested + class HtmlContentFormattingAndOrderingTests { + + @Test + void renderFormatsValidAnnotationsCorrectlyWithHtmlEscaping() { + Path path = Path.of("article.pdf"); + FileAnnotation annotation = createMockAnnotation(FileAnnotationType.HIGHLIGHT, 3, "This & That", false); + Map> annotations = Map.of(path, List.of(annotation)); + + String expectedHeader = "highlight (page 3)"; + String expectedHtml = "

PDF Annotations

" + escape("article.pdf") + "
" + + "" + escape(expectedHeader) + " " + escape("This & That") + "
"; + + assertEquals(expectedHtml, FileAnnotationPreview.render(annotations)); + } + + @Test + void renderOrdersAnnotationsByPageNumberInAscendingOrder() { + Path path = Path.of("book.pdf"); + FileAnnotation page10 = createMockAnnotation(FileAnnotationType.TEXT, 10, "Late", false); + FileAnnotation page2 = createMockAnnotation(FileAnnotationType.TEXT, 2, "Early", false); + Map> annotations = new LinkedHashMap<>(); + annotations.put(path, List.of(page10, page2)); + + String headerPage2 = "text (page 2)"; + String headerPage10 = "text (page 10)"; + + String expectedHtml = "

PDF Annotations

" + escape("book.pdf") + "
" + + "" + escape(headerPage2) + " " + escape("Early") + "
" + + "" + escape(headerPage10) + " " + escape("Late") + "
"; + + assertEquals(expectedHtml, FileAnnotationPreview.render(annotations)); + } + + @Test + void renderAppendsSecondaryLinkedNoteCommentsWhenPresent() { + Path path = Path.of("document.pdf"); + FileAnnotation mainAnnotation = createMockAnnotation(FileAnnotationType.TEXT, 1, "Main", true); + FileAnnotation linkedAnnotation = createMockAnnotation(FileAnnotationType.TEXT, 1, "Comment", false); + when(mainAnnotation.getLinkedFileAnnotation()).thenReturn(linkedAnnotation); + Map> annotations = Map.of(path, List.of(mainAnnotation)); + + String expectedHeader = "text (page 1)"; + String expectedNote = " — " + Localization.lang("Note") + ": Comment"; + + String expectedHtml = "

PDF Annotations

" + escape("document.pdf") + "
" + + "" + escape(expectedHeader) + " " + escape("Main") + "" + escape(expectedNote) + "
"; + + assertEquals(expectedHtml, FileAnnotationPreview.render(annotations)); + } + } +}