Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
b4f1af6
Extract text about papers from "related work" sections
pluto-han Mar 11, 2026
0f5e61f
Update comments
pluto-han Mar 11, 2026
a39f109
small refactor
pluto-han Mar 11, 2026
b29e400
Merge remote-tracking branch 'origin/fix-for-issue-14085' into fix-fo…
pluto-han Mar 11, 2026
08df1f3
small refactor of comments
pluto-han Mar 12, 2026
2e16150
Merge branch 'main' into fix-for-issue-14085
pluto-han Mar 12, 2026
960a263
Support more citation format
pluto-han Mar 12, 2026
89c8f94
Merge branch 'main' into fix-for-issue-14085
pluto-han Mar 12, 2026
b9cc731
Modify logic for UI
pluto-han Mar 14, 2026
a294b9e
Add UI
pluto-han Mar 14, 2026
dd186e1
make `RelatedWorkAction` inline
pluto-han Mar 16, 2026
8e7c5d1
add empty check before getting optional value
pluto-han Mar 16, 2026
dd0ace9
Merge branch 'main' into fix-for-issue-14085
koppor Mar 16, 2026
71adb96
add javadoc and example to RelatedWorkSnippet.java
pluto-han Mar 16, 2026
98175dc
Update jabgui/src/main/java/org/jabref/gui/relatedwork/RelatedWorkRes…
pluto-han Mar 16, 2026
1ec6337
Merge remote-tracking branch 'origin/fix-for-issue-14085' into fix-fo…
pluto-han Mar 16, 2026
f3b12b2
Fix comment style
pluto-han Apr 4, 2026
3aebab4
Add backticks around `comment-{username}``
pluto-han Apr 4, 2026
703995b
Add @NullMarked
pluto-han Apr 4, 2026
ff0491b
Change parameter order
pluto-han Apr 4, 2026
a95837d
Change variable order
pluto-han Apr 4, 2026
f564f63
remove copyOf
pluto-han Apr 4, 2026
0bf0af3
remove copyOf
pluto-han Apr 5, 2026
fa587bd
remove `skipped`
pluto-han Apr 5, 2026
b1dca63
change comment style
pluto-han Apr 5, 2026
846875b
add comment
pluto-han Apr 5, 2026
22543d7
Add backticks around `comment-{username}`
pluto-han Apr 5, 2026
be7c4ee
Add more explanation in the comment
pluto-han Apr 5, 2026
9688e8b
Add "undefined" to `getCitationKey`
pluto-han Apr 5, 2026
0957641
get owner from preference
pluto-han Apr 5, 2026
9944d01
Add action helper to check if PDF file is present
pluto-han Apr 6, 2026
b2fe2a6
Hihglight non-editabel fileds differently
pluto-han Apr 6, 2026
d62b984
Update CHANGELOG.md
pluto-han Apr 6, 2026
f773117
Fix failed test
pluto-han Apr 6, 2026
1c65b9c
Focus related work text field, auto insert clipboard content
pluto-han Apr 6, 2026
559fb01
Use sealed interface instead of enum
pluto-han Apr 6, 2026
66dfb99
Merge branch 'main' into fix-for-issue-14085
pluto-han Apr 6, 2026
72a01c5
Fix failed tests
pluto-han Apr 6, 2026
5be52f3
Merge remote-tracking branch 'origin/fix-for-issue-14085' into fix-fo…
pluto-han Apr 6, 2026
8294c6f
Add logger
pluto-han Apr 6, 2026
a9226c3
Trigger CI
pluto-han Apr 7, 2026
39a2943
Replace all page separators to "-"
pluto-han Apr 7, 2026
3c2c6a3
Split RelatedWorkService to matcher and inserter
pluto-han Apr 7, 2026
5a0e3b3
Replace "[^a-z0-9]" as a constant NON_ALPHANUMERIC
pluto-han Apr 8, 2026
0ac6dc8
Merge branch 'main' into fix-for-issue-14085
pluto-han Apr 10, 2026
43d579d
use switch to replace ifelse
pluto-han Apr 10, 2026
6fd763e
use mock to create filepreference
pluto-han Apr 10, 2026
0cfce22
Merge remote-tracking branch 'origin/fix-for-issue-14085' into fix-fo…
pluto-han Apr 10, 2026
5d85486
Normalize linebreak
pluto-han Apr 10, 2026
b3a76a6
Add requirement
pluto-han Apr 10, 2026
1d95e03
Add requirement
pluto-han Apr 10, 2026
6540124
Fix md format fail
pluto-han Apr 10, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.jabref.logic.relatedwork;

import java.util.Optional;

import org.jabref.model.FieldChange;

import org.jspecify.annotations.NullMarked;

@NullMarked
public record RelatedWorkInsertionResult(
RelatedWorkMatchResult matchResult,
RelatedWorkInsertionStatus status,
Optional<FieldChange> fieldChange
) {
public boolean success() {
return status == RelatedWorkInsertionStatus.INSERTED || status == RelatedWorkInsertionStatus.UNCHANGED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.jabref.logic.relatedwork;

/// INSERTED: Insert successfully
/// UNCHANGED: [citation-key]: xxxxx already exists, do not insert
/// SKIPPED: Cannot find a matched related work
public enum RelatedWorkInsertionStatus {
INSERTED,
UNCHANGED,
SKIPPED,
}
Comment thread
pluto-han marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.jabref.logic.relatedwork;

import java.util.Optional;

import org.jabref.model.entry.BibEntry;

import org.jspecify.annotations.NullMarked;

@NullMarked
/// parsedReference: parsed bib entry in Reference section
/// matchedLibraryBibEntry: matched bib entry in the library
public record RelatedWorkMatchResult(
String contextText,
String citationKey,
BibEntry parsedReference,
Optional<BibEntry> matchedLibraryBibEntry
) {
public boolean hasMatchedLibraryEntry() {
return matchedLibraryBibEntry.isPresent();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package org.jabref.logic.relatedwork;

import java.io.IOException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.jabref.logic.FilePreferences;
import org.jabref.logic.importer.ParserResult;
import org.jabref.logic.importer.fileformat.pdf.RuleBasedBibliographyPdfImporter;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.LinkedFile;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.jspecify.annotations.NullMarked;

@NullMarked
public class RelatedWorkReferenceResolver {

private final RuleBasedBibliographyPdfImporter bibliographyPdfImporter;

public RelatedWorkReferenceResolver() {
this.bibliographyPdfImporter = new RuleBasedBibliographyPdfImporter();
}

/// Parse references from the given PDF file
///
/// @param linkedFile The attached PDF file of the selected bib entry
/// @return Map from citation key to the corresponding reference entries
public Map<String, BibEntry> parseReferences(LinkedFile linkedFile,
BibDatabaseContext databaseContext,
FilePreferences filePreferences) throws IOException {
Path pdfPath = resolvePdfPath(linkedFile, databaseContext, filePreferences);
List<BibEntry> parsedEntries = parseReferenceEntries(pdfPath);

Map<String, BibEntry> entriesByMarker = new HashMap<>();
for (BibEntry parsedEntry : parsedEntries) {
Optional<String> citationKey = parsedEntry.getCitationKey();
if (citationKey.isPresent()) {
Comment thread
pluto-han marked this conversation as resolved.
Outdated
String citationMarker = "[" + citationKey.get() + "]";
Comment thread
pluto-han marked this conversation as resolved.
Outdated
entriesByMarker.putIfAbsent(citationMarker, parsedEntry);
}
}

return entriesByMarker;
}

/// Get absolute path of PDF file
private Path resolvePdfPath(LinkedFile linkedFile,
Comment thread
pluto-han marked this conversation as resolved.
Outdated
BibDatabaseContext databaseContext,
FilePreferences filePreferences) {
Optional<Path> resolvedPath = linkedFile.findIn(databaseContext, filePreferences);

return resolvedPath.get();
}
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated

/// Locate Reference section and parse references
///
/// @param pdfPath Absolute path of PDF file
/// @return List of reference entries
public List<BibEntry> parseReferenceEntries(Path pdfPath) throws IOException {
try (PDDocument document = Loader.loadPDF(pdfPath.toFile())) {
ParserResult parserResult = bibliographyPdfImporter.importDatabase(pdfPath, document);

return List.copyOf(parserResult.getDatabase().getEntries());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package org.jabref.logic.relatedwork;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;

import org.jabref.logic.FilePreferences;
import org.jabref.logic.database.DuplicateCheck;
import org.jabref.logic.os.OS;
import org.jabref.model.FieldChange;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.LinkedFile;
import org.jabref.model.entry.field.UserSpecificCommentField;

public class RelatedWorkService {
Comment thread
calixtus marked this conversation as resolved.
Outdated
Comment thread
calixtus marked this conversation as resolved.
Outdated

private final RelatedWorkTextParser relatedWorkTextParser;
private final RelatedWorkReferenceResolver relatedWorkReferenceResolver;
private final DuplicateCheck duplicateCheck;

public RelatedWorkService(RelatedWorkTextParser relatedWorkTextParser, RelatedWorkReferenceResolver relatedWorkReferenceResolver, DuplicateCheck duplicateCheck) {
this.relatedWorkTextParser = relatedWorkTextParser;
this.relatedWorkReferenceResolver = relatedWorkReferenceResolver;
this.duplicateCheck = duplicateCheck;
}

/// Try to find a matched bib entry in library
///
/// @param sourceEntry selected bib entry in library
/// @return List of matched result
public List<RelatedWorkMatchResult> matchRelatedWork(BibEntry sourceEntry,
String relatedWorkText,
LinkedFile linkedFile,
BibDatabaseContext databaseContext,
Comment thread
calixtus marked this conversation as resolved.
Outdated
FilePreferences filePreferences) throws IOException {
Comment thread
calixtus marked this conversation as resolved.
Outdated
List<RelatedWorkSnippet> relatedWorkSnippets = relatedWorkTextParser.parseRelatedWork(relatedWorkText);
if (relatedWorkSnippets.isEmpty()) {
return List.of();
}

Map<String, BibEntry> parsedReferencesByMarker = relatedWorkReferenceResolver.parseReferences(linkedFile, databaseContext, filePreferences);

return createMatchResults(sourceEntry, relatedWorkSnippets, parsedReferencesByMarker, databaseContext);
}

/// Insert {comment-username} to bib entry
Comment thread
calixtus marked this conversation as resolved.
Outdated
///
/// @return List of insertion result
public List<RelatedWorkInsertionResult> insertMatchedRelatedWork(BibEntry sourceEntry,
List<RelatedWorkMatchResult> matchResults,
String userName) {
String sourceCitationKey = sourceEntry.getCitationKey().get();

UserSpecificCommentField userSpecificCommentField = new UserSpecificCommentField(normalizeOwner(userName));
List<RelatedWorkInsertionResult> insertionResults = new ArrayList<>(matchResults.size());

for (RelatedWorkMatchResult matchResult : matchResults) {
if (matchResult.matchedLibraryBibEntry().isEmpty()) {
insertionResults.add(new RelatedWorkInsertionResult(
matchResult,
RelatedWorkInsertionStatus.SKIPPED,
Optional.empty()
));
continue;
}

Optional<FieldChange> fieldChange = appendRelatedWorkComment(
sourceCitationKey,
matchResult.contextText(),
matchResult.matchedLibraryBibEntry().get(),
userSpecificCommentField
);
insertionResults.add(new RelatedWorkInsertionResult(
matchResult,
fieldChange.isPresent() ? RelatedWorkInsertionStatus.INSERTED : RelatedWorkInsertionStatus.UNCHANGED,
fieldChange
));
}

return insertionResults;
}

private List<RelatedWorkMatchResult> createMatchResults(BibEntry sourceEntry,
List<RelatedWorkSnippet> relatedWorkSnippets,
Map<String, BibEntry> referencesByMarker,
BibDatabaseContext databaseContext) {
List<RelatedWorkMatchResult> matchResults = new ArrayList<>(relatedWorkSnippets.size());

for (RelatedWorkSnippet relatedWorkSnippet : relatedWorkSnippets) {
BibEntry parsedReference = referencesByMarker.get(relatedWorkSnippet.citationMarker());
Optional<BibEntry> matchedLibraryEntry = findDuplicateBibEntry(sourceEntry, parsedReference, databaseContext);
matchResults.add(new RelatedWorkMatchResult(
relatedWorkSnippet.contextText(),
relatedWorkSnippet.citationMarker(),
parsedReference,
matchedLibraryEntry
));
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
}

return matchResults;
}

/// Find duplicate entry in library by parsedReference
private Optional<BibEntry> findDuplicateBibEntry(BibEntry sourceEntry,
BibEntry parsedReference,
BibDatabaseContext databaseContext) {
return duplicateCheck.containsDuplicate(databaseContext.getDatabase(), parsedReference, databaseContext.getMode())
.filter(duplicateEntry -> !duplicateEntry.getId().equals(sourceEntry.getId()));
}

/// Insert comment-{username} to target bib entry
Comment thread
calixtus marked this conversation as resolved.
Outdated
/// If comment-{username} not exist, then create a new field; Otherwise append it and separate by an empty line.
///
/// @param userSpecificCommentField comment-{username}
/// @return FieldChange represents if the field is changed
private Optional<FieldChange> appendRelatedWorkComment(String sourceCitationKey,
String contextText,
BibEntry matchedLibraryEntry,
UserSpecificCommentField userSpecificCommentField) {
String formattedComment = "[%s]: %s".formatted(sourceCitationKey, contextText);
String updatedComment = matchedLibraryEntry.getField(userSpecificCommentField)
.filter(existingComment -> !existingComment.isBlank())
.map(existingComment -> existingComment.stripTrailing() + OS.NEWLINE + OS.NEWLINE + formattedComment)
.orElse(formattedComment);

return matchedLibraryEntry.setField(userSpecificCommentField, updatedComment);
}

/// John Doe -> john-doe
/// Test -> test
private String normalizeOwner(String userName) {
return userName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "-");
}
Comment thread
calixtus marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.jabref.logic.relatedwork;

public record RelatedWorkSnippet(
String contextText,
String citationMarker
) {
Comment thread
calixtus marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package org.jabref.logic.relatedwork;

import java.util.List;
import java.util.regex.MatchResult;
import java.util.regex.Pattern;

import org.jspecify.annotations.NullMarked;

@NullMarked
public class RelatedWorkTextParser {
private static final Pattern SINGLE_CITE_PATTERN = Pattern.compile("\\[(\\d{1,3})\\]");
private static final Pattern SEGMENT_SPLIT_PATTERN = Pattern.compile("(?<=[.!?])\\s+");
private static final Pattern HYPHENATED_LINE_BREAK_PATTERN = Pattern.compile("-\\R");
private static final Pattern NEWLINE_WITHOUT_SENTENCE_END_PATTERN = Pattern.compile("(?<![.:])\\R");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern SPACE_BEFORE_PUNCTUATION_PATTERN = Pattern.compile("\\s+([,.;:!?])");
private static final Pattern TRAILING_CONJUNCTION_WITH_PUNCTUATION_PATTERN = Pattern.compile("\\s+(?:and|or)([.?!])$", Pattern.CASE_INSENSITIVE);
private static final Pattern TRAILING_CONJUNCTION_PATTERN = Pattern.compile("\\s+(?:and|or)$", Pattern.CASE_INSENSITIVE);

/// This method first cuts long texts into separate sentence segments, and then parse these sentences.
public List<RelatedWorkSnippet> parseRelatedWork(String text) {
String normalizedText = getNormalText(text);

return SEGMENT_SPLIT_PATTERN.splitAsStream(normalizedText)
.filter(segment -> !segment.isBlank())
.map(this::parseTextSegment)
.flatMap(List::stream)
.toList();
}

/// Parse a sentence into citationKeys and text.
public List<RelatedWorkSnippet> parseTextSegment(String text) {
List<String> citationMarkers = SINGLE_CITE_PATTERN.matcher(text)
.results()
.map(MatchResult::group)
.toList();

if (citationMarkers.isEmpty()) {
return List.of();
}

String contextText = extractContextText(text);

return citationMarkers.stream()
.map(citationMarker -> new RelatedWorkSnippet(contextText, citationMarker))
.toList();
}

/// Remove citationKeys
private String extractContextText(String text) {
text = SINGLE_CITE_PATTERN.matcher(text).replaceAll(" ");
text = WHITESPACE_PATTERN.matcher(text.trim()).replaceAll(" ");
text = SPACE_BEFORE_PUNCTUATION_PATTERN.matcher(text).replaceAll("$1");
text = TRAILING_CONJUNCTION_WITH_PUNCTUATION_PATTERN.matcher(text).replaceAll("$1");
return TRAILING_CONJUNCTION_PATTERN.matcher(text).replaceAll("").trim();
}

/// Some long words may contain a hyphen and a newline symbol. This method transforms these words into normal ones.
public String getNormalText(String text) {
text = HYPHENATED_LINE_BREAK_PATTERN.matcher(text).replaceAll("");
text = NEWLINE_WITHOUT_SENTENCE_END_PATTERN.matcher(text).replaceAll(" ");
return WHITESPACE_PATTERN.matcher(text.trim()).replaceAll(" ");
}
}
Loading
Loading