-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Fix merge dialog overwriting user's citation key choice during duplicate import #15406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2f45437
1260fc0
88519aa
8587087
a729561
a3bc76d
453708b
9e91dd5
3ba665b
0b4eeb8
cabdce5
8c34e61
cbaedd0
644fe5e
aac7510
f2452bf
64fe4fd
83eb174
302acd4
e5007dd
a01bb5b
81e8196
13a092e
576da74
d0aaef1
2d0d757
e0b521e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,7 @@ | |
| import org.jabref.model.entry.BibEntryTypesManager; | ||
| import org.jabref.model.entry.BibtexString; | ||
| import org.jabref.model.entry.LinkedFile; | ||
| import org.jabref.model.entry.field.InternalField; | ||
| import org.jabref.model.entry.field.StandardField; | ||
| import org.jabref.model.groups.ExplicitGroup; | ||
| import org.jabref.model.groups.GroupEntryChanger; | ||
|
|
@@ -240,8 +241,20 @@ public void importEntries(List<BibEntry> entries) { | |
| } | ||
|
|
||
| public void importCleanedEntries(@Nullable TransferInformation transferInformation, List<BibEntry> entries) { | ||
| importCleanedEntries(transferInformation, entries, false); | ||
| } | ||
|
|
||
| /// Imports cleaned entries into the database. | ||
| /// | ||
| /// @param transferInformation optional transfer information for adjusting linked files | ||
| /// @param entries the entries to import | ||
| /// @param skipKeyGeneration if true, skip citation key generation (e.g., when the key was already generated before the merge dialog so the user could choose/edit it) | ||
|
|
||
| private void importCleanedEntries(@Nullable TransferInformation transferInformation, List<BibEntry> entries, boolean skipKeyGeneration) { | ||
| targetBibDatabaseContext.getDatabase().insertEntries(entries); | ||
| generateKeys(entries); | ||
| if (!skipKeyGeneration) { | ||
| generateKeys(entries); | ||
| } | ||
| setAutomaticFields(entries); | ||
| addToGroups(entries, stateManager.getSelectedGroups(targetBibDatabaseContext)); | ||
| addToImportEntriesGroup(entries); | ||
|
|
@@ -279,17 +292,25 @@ private void importEntryWithDuplicateCheck(@Nullable TransferInformation transfe | |
| LOGGER.error("Error in duplicate search", e); | ||
| }) | ||
| .onSuccess(existingDuplicateInLibrary -> { | ||
| // Generate citation key string WITHOUT modifying the entry, | ||
| // so the "From import" column shows the original key | ||
| Optional<String> generatedKey = generateKeyString(entryToInsert); | ||
|
|
||
| BibEntry finalEntry = entryToInsert; | ||
| if (existingDuplicateInLibrary.isPresent()) { | ||
| Optional<BibEntry> duplicateHandledEntry = handleDuplicates(entryToInsert, existingDuplicateInLibrary.get(), decision); | ||
| Optional<BibEntry> duplicateHandledEntry = handleDuplicates(entryToInsert, existingDuplicateInLibrary.get(), decision, generatedKey); | ||
| if (duplicateHandledEntry.isEmpty()) { | ||
| tracker.markSkipped(); | ||
| return; | ||
| } | ||
| finalEntry = duplicateHandledEntry.get(); | ||
| } else { | ||
| // No duplicate: apply the generated key directly | ||
| generatedKey.ifPresent(key -> entryToInsert.setCitationKey(key)); | ||
| } | ||
|
|
||
| importCleanedEntries(transferInformation, List.of(finalEntry)); | ||
| // Skip key generation since it was already handled | ||
| importCleanedEntries(transferInformation, List.of(finalEntry), true); | ||
|
|
||
| tracker.markImported(finalEntry); | ||
| }).executeWith(taskExecutor); | ||
|
|
@@ -308,12 +329,28 @@ public Optional<BibEntry> findDuplicate(BibEntry entryToCheck) { | |
| } | ||
|
|
||
| public Optional<BibEntry> handleDuplicates(BibEntry originalEntry, BibEntry duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult decision) { | ||
| DuplicateDecisionResult decisionResult = getDuplicateDecision(originalEntry, duplicateEntry, decision); | ||
| return handleDuplicates(originalEntry, duplicateEntry, decision, Optional.empty()); | ||
| } | ||
|
|
||
| public DuplicateDecisionResult getDuplicateDecision(BibEntry originalEntry, BibEntry duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult decision) { | ||
| return getDuplicateDecision(originalEntry, duplicateEntry, decision, Optional.empty()); | ||
| } | ||
|
|
||
| public Optional<BibEntry> handleDuplicates(BibEntry originalEntry, BibEntry duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult decision, Optional<String> generatedKey) { | ||
| DuplicateDecisionResult decisionResult = getDuplicateDecision(originalEntry, duplicateEntry, decision, generatedKey); | ||
| switch (decisionResult.decision()) { | ||
| case KEEP_RIGHT: | ||
| targetBibDatabaseContext.getDatabase().removeEntry(duplicateEntry); | ||
| if (originalEntry.getCitationKey().isEmpty()) { | ||
| generatedKey.ifPresent(key -> originalEntry.setCitationKey(key)); | ||
| } | ||
| break; | ||
| case KEEP_BOTH: | ||
| Optional<String> originalKey = originalEntry.getCitationKey(); | ||
| Optional<String> duplicateKey = duplicateEntry.getCitationKey(); | ||
| if (originalKey.isEmpty() || originalKey.equals(duplicateKey)) { | ||
| generatedKey.ifPresent(key -> originalEntry.setCitationKey(key)); | ||
| } | ||
| break; | ||
|
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
|
||
| case KEEP_MERGE: | ||
| targetBibDatabaseContext.getDatabase().removeEntry(duplicateEntry); | ||
|
|
@@ -327,8 +364,9 @@ public Optional<BibEntry> handleDuplicates(BibEntry originalEntry, BibEntry dupl | |
| return Optional.of(originalEntry); | ||
| } | ||
|
|
||
| public DuplicateDecisionResult getDuplicateDecision(BibEntry originalEntry, BibEntry duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult decision) { | ||
| public DuplicateDecisionResult getDuplicateDecision(BibEntry originalEntry, BibEntry duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult decision, Optional<String> generatedKey) { | ||
| DuplicateResolverDialog dialog = new DuplicateResolverDialog(duplicateEntry, originalEntry, DuplicateResolverDialog.DuplicateResolverType.IMPORT_CHECK, stateManager, dialogService, preferences); | ||
| generatedKey.ifPresent(key -> dialog.setMergedFieldValue(InternalField.KEY_FIELD, key)); | ||
| if (decision == BREAK) { | ||
|
Comment on lines
+367
to
+370
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Merged key not displayed The generated citation key is injected into the merge dialog via an internal field row that is filtered out of the three-way merge view, so the “Merged entry” column will not show the suggested key and the user cannot edit it there. Agent Prompt
|
||
| decision = dialogService.showCustomDialogAndWait(dialog).orElse(BREAK); | ||
| } | ||
|
|
@@ -391,6 +429,22 @@ private void generateKeys(List<BibEntry> entries) { | |
| entries.forEach(keyGenerator::generateAndSetKey); | ||
| } | ||
|
|
||
| /// Generates a citation key string for the given entry WITHOUT modifying it. | ||
| /// | ||
| /// @param entry the entry to generate a key for | ||
| /// @return the generated key, or empty if key generation is disabled | ||
| private Optional<String> generateKeyString(BibEntry entry) { | ||
| if (!preferences.getImporterPreferences().shouldGenerateNewKeyOnImport()) { | ||
| return Optional.empty(); | ||
| } | ||
| CitationKeyGenerator keyGenerator = new CitationKeyGenerator( | ||
| targetBibDatabaseContext.getMetaData().getCiteKeyPatterns(preferences.getCitationKeyPatternPreferences() | ||
| .getKeyPatterns()), | ||
| targetBibDatabaseContext.getDatabase(), | ||
| preferences.getCitationKeyPatternPreferences()); | ||
| return Optional.of(keyGenerator.generateKey(entry)); | ||
| } | ||
|
|
||
| public @NonNull List<@NonNull BibEntry> handleBibTeXData(@NonNull String entries) { | ||
| if (!entries.contains("@")) { | ||
| LOGGER.debug("Seems not to be BibTeX data: {}", entries); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package org.jabref.gui.externalfiles; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import javax.swing.undo.UndoManager; | ||
|
|
||
|
|
@@ -11,16 +12,23 @@ | |
| import org.jabref.gui.duplicationFinder.DuplicateResolverDialog; | ||
| import org.jabref.gui.preferences.GuiPreferences; | ||
| import org.jabref.logic.FilePreferences; | ||
| import org.jabref.logic.LibraryPreferences; | ||
| import org.jabref.logic.bibtex.FieldPreferences; | ||
| import org.jabref.logic.citationkeypattern.CitationKeyPatternPreferences; | ||
| import org.jabref.logic.citationkeypattern.GlobalCitationKeyPatterns; | ||
| import org.jabref.logic.database.DuplicateCheck; | ||
| import org.jabref.logic.importer.ImportFormatPreferences; | ||
| import org.jabref.logic.importer.ImporterPreferences; | ||
| import org.jabref.logic.preferences.OwnerPreferences; | ||
| import org.jabref.logic.preferences.TimestampPreferences; | ||
| import org.jabref.logic.util.CurrentThreadTaskExecutor; | ||
| import org.jabref.model.database.BibDatabase; | ||
| import org.jabref.model.database.BibDatabaseContext; | ||
| import org.jabref.model.database.BibDatabaseMode; | ||
| import org.jabref.model.entry.BibEntry; | ||
| import org.jabref.model.entry.field.StandardField; | ||
| import org.jabref.model.entry.types.StandardEntryType; | ||
| import org.jabref.model.metadata.MetaData; | ||
| import org.jabref.model.util.DummyFileUpdateMonitor; | ||
|
|
||
| import org.junit.jupiter.api.BeforeEach; | ||
|
|
@@ -142,7 +150,7 @@ void handleDuplicatesKeepRightTest() { | |
| mock(DialogService.class), | ||
| new CurrentThreadTaskExecutor())); | ||
| // Mock the behavior of getDuplicateDecision to return KEEP_RIGHT | ||
| Mockito.doReturn(decisionResult).when(importHandler).getDuplicateDecision(testEntry, duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK); | ||
| Mockito.doReturn(decisionResult).when(importHandler).getDuplicateDecision(testEntry, duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK, Optional.empty()); | ||
|
|
||
| // Act | ||
| BibEntry result = importHandler.handleDuplicates(testEntry, duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK).get(); | ||
|
|
@@ -173,7 +181,7 @@ void handleDuplicatesKeepBothTest() { | |
| mock(DialogService.class), | ||
| new CurrentThreadTaskExecutor())); | ||
| // Mock the behavior of getDuplicateDecision to return KEEP_BOTH | ||
| Mockito.doReturn(decisionResult).when(importHandler).getDuplicateDecision(testEntry, duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK); | ||
| Mockito.doReturn(decisionResult).when(importHandler).getDuplicateDecision(testEntry, duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK, Optional.empty()); | ||
|
|
||
| // Act | ||
| BibEntry result = importHandler.handleDuplicates(testEntry, duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK).get(); | ||
|
|
@@ -207,7 +215,7 @@ void handleDuplicatesKeepMergeTest() { | |
| mock(DialogService.class), | ||
| new CurrentThreadTaskExecutor())); | ||
| // Mock the behavior of getDuplicateDecision to return KEEP_MERGE | ||
| Mockito.doReturn(decisionResult).when(importHandler).getDuplicateDecision(testEntry, duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK); | ||
| Mockito.doReturn(decisionResult).when(importHandler).getDuplicateDecision(testEntry, duplicateEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK, Optional.empty()); | ||
|
|
||
| // Act | ||
| // create and return a default BibEntry or do other computations | ||
|
|
@@ -218,4 +226,125 @@ void handleDuplicatesKeepMergeTest() { | |
| assertFalse(bibDatabase.getEntries().contains(duplicateEntry)); // Assert that the duplicate entry was removed from the database | ||
| assertEquals(mergedEntry, result); // Assert that the merged entry is returned | ||
| } | ||
|
|
||
| @Test | ||
| void handleDuplicatesKeepBothWithCollidingKeysAppliesGeneratedKey() { | ||
| // Arrange | ||
| BibEntry duplicateEntry = new BibEntry(StandardEntryType.Article) | ||
| .withCitationKey("SameKey2023") | ||
| .withField(StandardField.AUTHOR, "Duplicate Author"); | ||
|
|
||
| BibEntry originalEntry = new BibEntry(StandardEntryType.Article) | ||
| .withCitationKey("SameKey2023") | ||
| .withField(StandardField.AUTHOR, "Original Author"); | ||
|
|
||
| BibDatabase bibDatabase = bibDatabaseContext.getDatabase(); | ||
| bibDatabase.insertEntry(duplicateEntry); | ||
|
|
||
| DuplicateDecisionResult decisionResult = new DuplicateDecisionResult( | ||
| DuplicateResolverDialog.DuplicateResolverResult.KEEP_BOTH, null); | ||
|
Comment on lines
+244
to
+245
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. duplicatedecisionresult constructed with null The new test passes null for DuplicateDecisionResult's mergedEntry, which violates the no-null-passing rule and can lead to NPEs if the value is accessed. Use a real BibEntry instance (or refactor the type to model absence explicitly) instead of passing null. Agent Prompt
|
||
| importHandler = Mockito.spy(new ImportHandler( | ||
| bibDatabaseContext, | ||
| preferences, | ||
| new DummyFileUpdateMonitor(), | ||
| mock(UndoManager.class), | ||
| mock(StateManager.class), | ||
| mock(DialogService.class), | ||
| new CurrentThreadTaskExecutor())); | ||
|
|
||
| String generatedKey = "UniqueKey2023"; | ||
| Mockito.doReturn(decisionResult).when(importHandler) | ||
| .getDuplicateDecision(originalEntry, duplicateEntry, | ||
| DuplicateResolverDialog.DuplicateResolverResult.BREAK, | ||
| Optional.of(generatedKey)); | ||
|
|
||
| // Act | ||
| BibEntry result = importHandler.handleDuplicates(originalEntry, duplicateEntry, | ||
| DuplicateResolverDialog.DuplicateResolverResult.BREAK, | ||
| Optional.of(generatedKey)).get(); | ||
|
|
||
| // Assert: the colliding key should be replaced by the generated one | ||
| assertEquals(generatedKey, result.getCitationKey().orElse(""), | ||
| "When keeping both entries with the same key, the imported entry should get the generated key"); | ||
| assertEquals("SameKey2023", duplicateEntry.getCitationKey().orElse(""), | ||
| "The existing entry should keep its original key"); | ||
| } | ||
|
|
||
| @Test | ||
| void importWithDuplicateCheckPreservesMergedCitationKey() { | ||
| // Arrange: set up key generation with pattern [auth][year] | ||
| ImporterPreferences importerPreferences = mock(ImporterPreferences.class); | ||
| when(importerPreferences.shouldGenerateNewKeyOnImport()).thenReturn(true); | ||
| when(preferences.getImporterPreferences()).thenReturn(importerPreferences); | ||
|
|
||
| GlobalCitationKeyPatterns patterns = GlobalCitationKeyPatterns.fromPattern("[auth][year]"); | ||
| CitationKeyPatternPreferences citationKeyPatternPreferences = mock(CitationKeyPatternPreferences.class); | ||
| when(citationKeyPatternPreferences.getKeyPatterns()).thenReturn(patterns); | ||
| when(citationKeyPatternPreferences.getUnwantedCharacters()).thenReturn(""); | ||
| when(citationKeyPatternPreferences.getKeywordDelimiter()).thenReturn(','); | ||
| when(citationKeyPatternPreferences.getKeySuffix()).thenReturn(CitationKeyPatternPreferences.KeySuffix.SECOND_WITH_B); | ||
| when(preferences.getCitationKeyPatternPreferences()).thenReturn(citationKeyPatternPreferences); | ||
|
|
||
| MetaData metaData = mock(MetaData.class); | ||
| when(metaData.getCiteKeyPatterns(any())).thenReturn(patterns); | ||
| when(bibDatabaseContext.getMetaData()).thenReturn(metaData); | ||
|
|
||
| StateManager stateManager = mock(StateManager.class); | ||
| when(stateManager.getSelectedGroups(any())).thenReturn(FXCollections.observableArrayList()); | ||
|
|
||
| LibraryPreferences libraryPreferences = mock(LibraryPreferences.class); | ||
| when(libraryPreferences.isAddImportedEntriesEnabled()).thenReturn(false); | ||
| when(preferences.getLibraryPreferences()).thenReturn(libraryPreferences); | ||
|
|
||
| when(preferences.getOwnerPreferences()).thenReturn(mock(OwnerPreferences.class)); | ||
| when(preferences.getTimestampPreferences()).thenReturn(mock(TimestampPreferences.class)); | ||
|
|
||
| FilePreferences filePreferences = mock(FilePreferences.class); | ||
| when(filePreferences.shouldDownloadLinkedFiles()).thenReturn(false); | ||
| when(preferences.getFilePreferences()).thenReturn(filePreferences); | ||
|
|
||
| importHandler = Mockito.spy(new ImportHandler( | ||
| bibDatabaseContext, | ||
| preferences, | ||
| new DummyFileUpdateMonitor(), | ||
| mock(UndoManager.class), | ||
| stateManager, | ||
| mock(DialogService.class), | ||
| new CurrentThreadTaskExecutor())); | ||
|
|
||
| // Existing entry in the database | ||
| BibEntry existingEntry = new BibEntry(StandardEntryType.Article) | ||
| .withCitationKey("ExistingKey") | ||
| .withField(StandardField.AUTHOR, "Smith") | ||
| .withField(StandardField.YEAR, "2022") | ||
| .withField(StandardField.TITLE, "Some Title"); | ||
| BibDatabase bibDatabase = bibDatabaseContext.getDatabase(); | ||
| bibDatabase.insertEntry(existingEntry); | ||
|
|
||
| // User-chosen merged entry with a custom citation key | ||
| String userChosenKey = "MyCustomKey"; | ||
| BibEntry mergedEntry = new BibEntry(StandardEntryType.Article) | ||
| .withCitationKey(userChosenKey) | ||
| .withField(StandardField.AUTHOR, "Smith") | ||
| .withField(StandardField.YEAR, "2022") | ||
| .withField(StandardField.TITLE, "Some Title"); | ||
|
|
||
| // Entry to import | ||
| BibEntry importEntry = new BibEntry(StandardEntryType.Article) | ||
| .withField(StandardField.AUTHOR, "Smith") | ||
| .withField(StandardField.YEAR, "2022") | ||
| .withField(StandardField.TITLE, "Some Title"); | ||
| // Mock findDuplicate to return the existing entry as a duplicate | ||
| Mockito.doReturn(Optional.of(existingEntry)).when(importHandler).findDuplicate(importEntry); | ||
|
|
||
| // Mock handleDuplicates(4 params) to simulate user choosing KEEP_MERGE with their custom key | ||
| Mockito.doReturn(Optional.of(mergedEntry)).when(importHandler).handleDuplicates(importEntry, existingEntry, DuplicateResolverDialog.DuplicateResolverResult.BREAK, Optional.of("Smith2022")); | ||
|
|
||
| // Act | ||
| importHandler.importEntriesWithDuplicateCheck(null, List.of(importEntry)); | ||
|
|
||
| // Assert: the user's chosen citation key from the merge dialog must be preserved | ||
| assertEquals(userChosenKey, mergedEntry.getCitationKey().orElse(""), | ||
| "The citation key chosen by the user in the merge dialog should not be overwritten by key generation"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2. Duplicate key from pregen
🐞 Bug≡ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools