Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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 @@ -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)
Expand Down
92 changes: 79 additions & 13 deletions jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / Unit tests – jabgui

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / Unit tests – jablib

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / Database tests

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / JavaDoc

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / Modernizer

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / Dependency Scopes

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / macOS-silicon installer and portable version

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / linux-arm installer and portable version

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / linux-amd64 installer and portable version

cannot find symbol

Check failure on line 29 in jabgui/src/main/java/org/jabref/gui/preview/PreviewViewer.java

View workflow job for this annotation

GitHub Actions / Checkstyle

cannot find symbol

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;
Expand All @@ -60,6 +68,9 @@

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;
Expand Down Expand Up @@ -181,22 +192,45 @@
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;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
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);
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
});
}

private void downloadCoverAndRefresh(BibEntry entry, String previewText) {
Expand Down Expand Up @@ -375,4 +409,36 @@
// 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<java.nio.file.Path, List<FileAnnotation>> 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);
}
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

return previewHtml.toString();
}
}
Original file line number Diff line number Diff line change
@@ -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<Path, List<FileAnnotation>> annotations) {
if (annotations == null || annotations.isEmpty()) {
return "";
}

StringBuilder html = new StringBuilder();
html.append("<br><br><b>")
.append(Localization.lang("PDF Annotations"))
.append("</b><br>");

annotations.entrySet().stream()
.filter(entry -> entry.getKey() != null && entry.getKey().getFileName() != null)
.forEach(entry -> {
List<FileAnnotation> fileAnnotations = entry.getValue();
if (fileAnnotations == null || fileAnnotations.isEmpty()) {
return;
}

String fileName = entry.getKey().getFileName().toString();
html.append("<br><i>").append(quoteForHTML(fileName)).append("</i><br>");

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();
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
String headerLabel = Localization.lang("%0 (page %1)", typeStr, String.valueOf(page));

html.append("<b>")
.append(quoteForHTML(headerLabel))
.append("</b> ")
.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("<i>")
.append(quoteForHTML(formattedNote))
.append("</i>");
}
}

html.append("<br>");
}
}
8 changes: 8 additions & 0 deletions jablib/src/main/resources/l10n/JabRef_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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() {
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
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<Path, List<FileAnnotation>> annotations = Map.of(path, Arrays.asList((FileAnnotation) null, blankAnnotation));

String expectedHtml = "<br><br><b>PDF Annotations</b><br><br><i>" + escape("test.pdf") + "</i><br>";

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<Path, List<FileAnnotation>> annotations = Map.of(path, List.of(annotation));

String expectedHeader = "highlight (page 3)";
String expectedHtml = "<br><br><b>PDF Annotations</b><br><br><i>" + escape("article.pdf") + "</i><br>"
+ "<b>" + escape(expectedHeader) + "</b> " + escape("This & That") + "<br>";

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<Path, List<FileAnnotation>> annotations = new LinkedHashMap<>();
annotations.put(path, List.of(page10, page2));

String headerPage2 = "text (page 2)";
String headerPage10 = "text (page 10)";

String expectedHtml = "<br><br><b>PDF Annotations</b><br><br><i>" + escape("book.pdf") + "</i><br>"
+ "<b>" + escape(headerPage2) + "</b> " + escape("Early") + "<br>"
+ "<b>" + escape(headerPage10) + "</b> " + escape("Late") + "<br>";

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<Path, List<FileAnnotation>> annotations = Map.of(path, List.of(mainAnnotation));

String expectedHeader = "text (page 1)";
String expectedNote = " — " + Localization.lang("Note") + ": Comment";

String expectedHtml = "<br><br><b>PDF Annotations</b><br><br><i>" + escape("document.pdf") + "</i><br>"
+ "<b>" + escape(expectedHeader) + "</b> " + escape("Main") + "<i>" + escape(expectedNote) + "</i><br>";

assertEquals(expectedHtml, FileAnnotationPreview.render(annotations));
}
}
}
Loading