diff --git a/CHANGELOG.md b/CHANGELOG.md
index f914018ec10a..8fc515a0a32c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We added Ctrl + C copy support for selected entries in the global search results window. [#17310](https://github.com/JabRef/jabref/issues/17310)
- We added a hint showing the effective file directory to empty directory fields in the library properties. [#17572](https://github.com/JabRef/jabref/issues/17572)
- We added Markdown publication exports with recent papers, grouped sections, and DOI, URL, and PDF links. [#12729](https://github.com/JabRef/jabref/issues/12729)
+- We added an option to automatically merge changes made to the library file outside JabRef into the open library. [#8431](https://github.com/JabRef/jabref/issues/8431)
### Changed
@@ -147,6 +148,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We fixed citation key generation to ignore "as" as a function word. [#17523](https://github.com/JabRef/jabref/issues/17523)
- We fixed missing PDF links and DOI persistence during full-text downloads. [#12947](https://github.com/JabRef/jabref/issues/12947)
- We fixed dollar signs and backslashes being mishandled in HTML paragraph formatting. [#17778](https://github.com/JabRef/jabref/issues/17778)
+- We fixed an issue where accepting an external change to an entry lost its table position, selection, and open entry editor. [#16813](https://github.com/JabRef/jabref/pull/16813)
### Removed
diff --git a/docs/requirements/ux.md b/docs/requirements/ux.md
index e8ebf193d883..a721d61bc9db 100644
--- a/docs/requirements/ux.md
+++ b/docs/requirements/ux.md
@@ -174,6 +174,15 @@ Since inspecting a library file means parsing it completely, the inspection is s
Needs: impl
+## Automatically merging external changes into a library
+`req~ux.external-library-changes.automatic-merge~1`
+
+While a local library is set to automatically merge external changes (a per-library setting with a global default, off unless enabled), changes made to its file outside JabRef must be merged into the in-memory library without asking, and unsaved in-memory changes must not be reported as external changes.
+A review is only required for an item (entry field, entry type, metadata, preamble, string) that was changed differently in memory and in the file, or that was deleted on one side and changed on the other.
+Fields changed only in the file are taken over even when other fields of the same entry were changed in memory.
+
+Needs: impl
+
## Bulk entry deletion must keep main table responsive
`req~ux.large-library.bulk-entry-removal~1`
diff --git a/jabgui/src/main/java/org/jabref/gui/collab/ChangeScanner.java b/jabgui/src/main/java/org/jabref/gui/collab/ChangeScanner.java
index f93f146b9da4..30d95b0b64c4 100644
--- a/jabgui/src/main/java/org/jabref/gui/collab/ChangeScanner.java
+++ b/jabgui/src/main/java/org/jabref/gui/collab/ChangeScanner.java
@@ -3,6 +3,8 @@
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
+import java.util.Optional;
+import java.util.function.BooleanSupplier;
import org.jabref.gui.DialogService;
import org.jabref.gui.StateManager;
@@ -10,6 +12,7 @@
import org.jabref.logic.importer.ImportFormatPreferences;
import org.jabref.logic.importer.OpenDatabase;
import org.jabref.logic.importer.ParserResult;
+import org.jabref.logic.sync.LibraryBaseline;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.util.DummyFileUpdateMonitor;
@@ -34,18 +37,32 @@ public ChangeScanner(BibDatabaseContext database,
}
public List scanForChanges() {
- if (database.getDatabasePath().isEmpty()) {
- return List.of();
- }
-
try {
- return getDatabaseChanges(database.getDatabasePath().get());
+ return scanForChanges(() -> true).orElse(List.of());
} catch (IOException e) {
LOGGER.warn("Error while parsing changed file.", e);
return List.of();
}
}
+ /// @param readyToParse consulted right before the file is parsed, e.g. to wait until a sync client has finished writing it; `false` abandons the scan
+ /// @return the changes, or empty when the scan was abandoned
+ /// @throws IOException when the file could not be read; unlike no changes, a failed read must be retried
+ public Optional> scanForChanges(BooleanSupplier readyToParse) throws IOException {
+ if (database.getDatabasePath().isEmpty()) {
+ return Optional.of(List.of());
+ }
+ if (!readyToParse.getAsBoolean()) {
+ return Optional.empty();
+ }
+ return Optional.of(getDatabaseChanges(database.getDatabasePath().get()));
+ }
+
+ /// @return the given external changes sorted by the side they happened on, see [ChangeTriage#triage]
+ public ChangeTriage.Triage triage(LibraryBaseline baseline, List changes) {
+ return ChangeTriage.triage(baseline, changes, database, databaseChangeResolverFactory);
+ }
+
public List getDatabaseChanges(Path fileToCompare) throws IOException {
ImportFormatPreferences importFormatPreferences = preferences.getImportFormatPreferences();
ParserResult result = OpenDatabase.loadDatabase(fileToCompare, importFormatPreferences, new DummyFileUpdateMonitor());
diff --git a/jabgui/src/main/java/org/jabref/gui/collab/ChangeTriage.java b/jabgui/src/main/java/org/jabref/gui/collab/ChangeTriage.java
new file mode 100644
index 000000000000..2864749cf21b
--- /dev/null
+++ b/jabgui/src/main/java/org/jabref/gui/collab/ChangeTriage.java
@@ -0,0 +1,263 @@
+package org.jabref.gui.collab;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.jabref.gui.collab.entryadd.EntryAdd;
+import org.jabref.gui.collab.entrychange.EntryChange;
+import org.jabref.gui.collab.entrydelete.EntryDelete;
+import org.jabref.gui.collab.groupchange.GroupChange;
+import org.jabref.gui.collab.metedatachange.MetadataChange;
+import org.jabref.gui.collab.preamblechange.PreambleChange;
+import org.jabref.gui.collab.stringadd.BibTexStringAdd;
+import org.jabref.gui.collab.stringchange.BibTexStringChange;
+import org.jabref.gui.collab.stringdelete.BibTexStringDelete;
+import org.jabref.gui.collab.stringrename.BibTexStringRename;
+import org.jabref.logic.citationkeypattern.GlobalCitationKeyPatterns;
+import org.jabref.logic.sync.LibraryBaseline;
+import org.jabref.logic.sync.LibraryBaseline.Side;
+import org.jabref.model.database.BibDatabaseContext;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.metadata.MetaData;
+
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/// Sorts the external changes of a library, as computed by [DatabaseChangeList#compareAndGetChanges], by the side
+/// that changed according to a [LibraryBaseline]. The policy lives in the baseline; this class only maps the GUI's
+/// [DatabaseChange] objects onto it.
+///
+/// Shared SQL libraries never come through here. [org.jabref.logic.shared.DBMSSynchronizer] pulls every newer
+/// database version of an entry straight into the library, because the database rather than a file is the source of
+/// truth; a local edit that a newer version overtook is refused on write
+/// ([org.jabref.logic.shared.event.UpdateRefusedEvent]) and offered for a merge as a whole entry. This triage instead
+/// merges a disk change into a locally edited entry field by field and only asks when the same field differs on both
+/// sides.
+@NullMarked
+public final class ChangeTriage {
+
+ /// Outcome of comparing external changes against the baseline.
+ ///
+ /// @param diskOnly changes to items untouched in memory; already accepted, to be applied without asking
+ /// @param bothSides changes to items that were modified in memory as well, in a way that cannot be merged automatically; need a review
+ /// @param memoryOnly not external changes at all: differences caused by unsaved in-memory edits; to be dropped
+ /// @param diskEntries the disk version of every entry changed in `diskOnly`, by in-memory entry id: its ancestor once the change is applied, which for a merged entry is not what memory holds then
+ public record Triage(List diskOnly, List bothSides, List memoryOnly, Map diskEntries) {
+ }
+
+ private ChangeTriage() {
+ }
+
+ /// Entries modified on both sides in different fields are merged field by field into a new, accepted [EntryChange].
+ public static Triage triage(LibraryBaseline baseline, List changes, BibDatabaseContext local, @Nullable DatabaseChangeResolverFactory resolverFactory) {
+ Triage triage = new Triage(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new HashMap<>());
+ // A group change is always accompanied by the metadata change it is part of, which precedes it in the list
+ Side metaDataSide = Side.BOTH;
+ LibraryBaseline.Lookup lookup = baseline.lookup();
+ Set idsInMemory = local.getDatabase().getEntries().stream().map(BibEntry::getId).collect(Collectors.toSet());
+ Set pairedBySimilarity = Collections.newSetFromMap(new IdentityHashMap<>());
+ for (DatabaseChange change : pairSplitEntries(baseline, lookup, changes, local, resolverFactory, pairedBySimilarity)) {
+ Side side = switch (change) {
+ // A pair that only similarity established is not certain enough to merge fields into: the user decides
+ case EntryChange entryChange when pairedBySimilarity.contains(entryChange) ->
+ Side.BOTH;
+ case EntryChange entryChange -> {
+ Side entrySide = baseline.sideOfEntry(entryChange.getOldEntry(), entryChange.getNewEntry());
+ if (entrySide == Side.BOTH) {
+ BibEntryMerge merge = mergeEntry(baseline, entryChange, local, resolverFactory);
+ change = merge.change();
+ entrySide = merge.side();
+ }
+ if (entrySide == Side.DISK) {
+ triage.diskEntries().put(entryChange.getOldEntry().getId(), entryChange.getNewEntry());
+ }
+ yield entrySide;
+ }
+ case EntryAdd entryAdd ->
+ lookup.sideOfAddedEntry(entryAdd.getAddedEntry(), idsInMemory::contains);
+ case EntryDelete entryDelete ->
+ baseline.sideOfDeletedEntry(entryDelete.getDeletedEntry());
+ case MetadataChange metadataChange -> {
+ keepLocalSettings(local.getMetaData(), metadataChange.getMetaDataDiff().getNewMetaData());
+ metaDataSide = baseline.sideOfMetaData(local.getMetaData(), metadataChange.getMetaDataDiff().getNewMetaData());
+ yield metaDataSide;
+ }
+ case GroupChange _ ->
+ metaDataSide;
+ case PreambleChange preambleChange ->
+ baseline.sideOfPreamble(local.getDatabase().getPreamble().orElse(null), preambleChange.getPreambleDiff().getNewPreamble());
+ case BibTexStringAdd stringAdd ->
+ baseline.sideOfAddedString(stringAdd.getAddedString().getName(), stringAdd.getAddedString().getContent(),
+ name -> local.getDatabase().getStringByName(name).isPresent());
+ case BibTexStringDelete stringDelete ->
+ baseline.sideOfString(stringDelete.getDeletedString().getName(), stringDelete.getDeletedString().getContent(), null);
+ case BibTexStringChange stringChange ->
+ baseline.sideOfString(stringChange.getOldString().getName(), stringChange.getOldString().getContent(), stringChange.getNewString().getContent());
+ case BibTexStringRename stringRename ->
+ baseline.sideOfStringRename(stringRename.getOldString().getName(), stringRename.getOldString().getContent(), stringRename.getNewString().getName(),
+ name -> local.getDatabase().getStringByName(name).isPresent());
+ };
+ switch (side) {
+ case DISK -> {
+ change.accept();
+ triage.diskOnly().add(change);
+ }
+ case BOTH ->
+ triage.bothSides().add(change);
+ case MEMORY ->
+ triage.memoryOnly().add(change);
+ }
+ }
+ return triage;
+ }
+
+ /// Carries over the baseline of every item whose external change was not applied, so that the next scan sees
+ /// the same divergence again instead of mistaking the current in-memory version for the common ancestor.
+ public static void keepUnresolved(LibraryBaseline updated, LibraryBaseline previous, List unresolved) {
+ LibraryBaseline.Lookup previousLookup = previous.lookup();
+ for (DatabaseChange change : unresolved) {
+ switch (change) {
+ case EntryChange entryChange ->
+ updated.keepEntry(previous, entryChange.getOldEntry().getId());
+ case EntryDelete entryDelete ->
+ updated.keepEntry(previous, entryDelete.getDeletedEntry().getId());
+ case EntryAdd entryAdd ->
+ updated.keepEntryFor(previous, previousLookup, entryAdd.getAddedEntry());
+ case MetadataChange _,
+ GroupChange _ ->
+ updated.keepMetaData(previous);
+ case PreambleChange _ ->
+ updated.keepPreamble(previous);
+ case BibTexStringAdd stringAdd ->
+ updated.keepString(previous, stringAdd.getAddedString().getName());
+ case BibTexStringDelete stringDelete ->
+ updated.keepString(previous, stringDelete.getDeletedString().getName());
+ case BibTexStringChange stringChange ->
+ updated.keepString(previous, stringChange.getOldString().getName());
+ case BibTexStringRename stringRename -> {
+ updated.keepString(previous, stringRename.getOldString().getName());
+ updated.keepString(previous, stringRename.getNewString().getName());
+ }
+ }
+ }
+ }
+
+ /// After a review, every judged item has the disk version as its ancestor, accepted or declined: accepted, memory
+ /// holds it too (or, merged by hand, holds more, which is an unsaved edit from then on); declined, memory keeps its
+ /// own version, an unsaved edit as well, which is not reported again. Items the review did not cover keep the
+ /// ancestor they had.
+ ///
+ /// @param scanned the changes as scanned, whose entry changes hold the disk version; a merge by hand replaces such a change by one holding the result
+ /// @param resolved the changes as judged
+ public static void advance(LibraryBaseline baseline, List scanned, List resolved) {
+ Map diskEntries = new HashMap<>();
+ for (DatabaseChange change : scanned) {
+ if (change instanceof EntryChange entryChange) {
+ diskEntries.put(entryChange.getOldEntry().getId(), entryChange.getNewEntry());
+ }
+ }
+ for (DatabaseChange change : resolved) {
+ switch (change) {
+ case EntryChange entryChange -> {
+ String id = entryChange.getOldEntry().getId();
+ baseline.recordEntry(id, diskEntries.getOrDefault(id, entryChange.getNewEntry()));
+ }
+ case EntryAdd entryAdd ->
+ baseline.recordEntry(entryAdd.getAddedEntry().getId(), entryAdd.getAddedEntry());
+ case EntryDelete entryDelete ->
+ baseline.forgetEntry(entryDelete.getDeletedEntry().getId());
+ case MetadataChange metadataChange ->
+ baseline.recordMetaData(metadataChange.getMetaDataDiff().getNewMetaData());
+ case GroupChange groupChange ->
+ baseline.recordGroups(groupChange.getGroupDiff().getNewGroupRoot());
+ case PreambleChange preambleChange ->
+ baseline.recordPreamble(preambleChange.getPreambleDiff().getNewPreamble());
+ case BibTexStringAdd stringAdd ->
+ baseline.recordString(stringAdd.getAddedString().getName(), stringAdd.getAddedString().getContent());
+ case BibTexStringDelete stringDelete ->
+ baseline.recordString(stringDelete.getDeletedString().getName(), null);
+ case BibTexStringChange stringChange ->
+ baseline.recordString(stringChange.getOldString().getName(), stringChange.getNewString().getContent());
+ case BibTexStringRename stringRename -> {
+ baseline.recordString(stringRename.getOldString().getName(), null);
+ baseline.recordString(stringRename.getNewString().getName(), stringRename.getNewString().getContent());
+ }
+ }
+ }
+ }
+
+ /// Whether scanned changes show the library and its file to be the same apart from the synchronization setting,
+ /// which is never synchronized and turns up as a metadata change without a visible difference.
+ public static boolean matchesFile(List changes, GlobalCitationKeyPatterns citationKeyPatterns) {
+ return changes.stream().allMatch(change -> change instanceof MetadataChange metadataChange
+ && metadataChange.getMetaDataDiff().getDifferences(citationKeyPatterns).isEmpty()
+ && metadataChange.getMetaDataDiff().getGroupDifferences().isEmpty());
+ }
+
+ /// Applying a metadata change installs the parsed metadata as a whole, so the settings that are never synchronized
+ /// from the file are carried over from memory first.
+ private static void keepLocalSettings(MetaData local, MetaData fromDisk) {
+ local.getSynchronizeWithFile().ifPresentOrElse(fromDisk::setSynchronizeWithFile, fromDisk::clearSynchronizeWithFile);
+ }
+
+ private record BibEntryMerge(Side side, DatabaseChange change) {
+ }
+
+ private static BibEntryMerge mergeEntry(LibraryBaseline baseline, EntryChange entryChange, BibDatabaseContext local, @Nullable DatabaseChangeResolverFactory resolverFactory) {
+ return baseline.mergeEntry(entryChange.getOldEntry(), entryChange.getNewEntry())
+ .map(merged -> new BibEntryMerge(Side.DISK, new EntryChange(entryChange.getOldEntry(), merged, local, resolverFactory)))
+ .orElse(new BibEntryMerge(Side.BOTH, entryChange));
+ }
+
+ /// The two-way diff pairs entries by similarity, so an entry whose citation key changed on one side while fields
+ /// changed on the other can fall below the similarity threshold and show up as a deletion plus an addition. The
+ /// baseline knows both belong to the same entry. One pass over the changes: a paired addition becomes the change,
+ /// its deletion is dropped.
+ ///
+ /// @param pairedBySimilarity receives the pairs that were established by closeness rather than by identity
+ private static List pairSplitEntries(LibraryBaseline baseline, LibraryBaseline.Lookup lookup, List changes, BibDatabaseContext local, @Nullable DatabaseChangeResolverFactory resolverFactory, Set pairedBySimilarity) {
+ Map deletesByBaseId = new HashMap<>();
+ for (DatabaseChange change : changes) {
+ if (change instanceof EntryDelete entryDelete && baseline.hasEntry(entryDelete.getDeletedEntry().getId())) {
+ deletesByBaseId.put(entryDelete.getDeletedEntry().getId(), entryDelete);
+ }
+ }
+ if (deletesByBaseId.isEmpty()) {
+ return changes;
+ }
+ Map replacements = new HashMap<>();
+ Set pairedDeletes = new HashSet<>();
+ for (DatabaseChange change : changes) {
+ if (change instanceof EntryAdd entryAdd) {
+ // By identity first; an entry whose key and a field changed on disk matches neither, so among the
+ // entries deleted on disk the one closest in content is taken, if that is unambiguous
+ Optional byIdentity = lookup.baseIdOf(entryAdd.getAddedEntry());
+ Optional baseId = byIdentity.or(() -> baseline.closestOf(deletesByBaseId.keySet(), entryAdd.getAddedEntry()));
+ baseId.map(deletesByBaseId::remove).ifPresent(entryDelete -> {
+ pairedDeletes.add(entryDelete);
+ EntryChange pair = new EntryChange(entryDelete.getDeletedEntry(), entryAdd.getAddedEntry(), local, resolverFactory);
+ replacements.put(entryAdd, pair);
+ if (byIdentity.isEmpty()) {
+ pairedBySimilarity.add(pair);
+ }
+ });
+ }
+ }
+ List paired = new ArrayList<>(changes.size());
+ for (DatabaseChange change : changes) {
+ if (change instanceof EntryDelete entryDelete && pairedDeletes.contains(entryDelete)) {
+ continue;
+ }
+ paired.add(change instanceof EntryAdd entryAdd ? replacements.getOrDefault(entryAdd, entryAdd) : change);
+ }
+ return paired;
+ }
+}
diff --git a/jabgui/src/main/java/org/jabref/gui/collab/DatabaseChangeMonitor.java b/jabgui/src/main/java/org/jabref/gui/collab/DatabaseChangeMonitor.java
index 525211a1eb8d..89f631ad701f 100644
--- a/jabgui/src/main/java/org/jabref/gui/collab/DatabaseChangeMonitor.java
+++ b/jabgui/src/main/java/org/jabref/gui/collab/DatabaseChangeMonitor.java
@@ -4,23 +4,30 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
+import javafx.beans.value.ChangeListener;
+
import org.jabref.gui.DialogService;
import org.jabref.gui.LibraryTab;
import org.jabref.gui.Notifications;
import org.jabref.gui.StateManager;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.logic.l10n.Localization;
+import org.jabref.logic.shared.DatabaseLocation;
+import org.jabref.logic.sync.LibraryBaseline;
import org.jabref.logic.undo.UndoManager;
import org.jabref.logic.util.BackgroundTask;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.logic.util.io.FileSnapshot;
import org.jabref.model.database.BibDatabaseContext;
+import org.jabref.model.metadata.event.MetaDataChangedEvent;
import org.jabref.model.util.FileUpdateListener;
import org.jabref.model.util.FileUpdateMonitor;
import com.dlsc.gemsfx.infocenter.NotificationAction;
+import com.google.common.eventbus.Subscribe;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,6 +35,10 @@
public class DatabaseChangeMonitor implements FileUpdateListener {
private static final Logger LOGGER = LoggerFactory.getLogger(DatabaseChangeMonitor.class);
+ private static final int STABLE_FILE_ATTEMPTS = 20;
+ private static final long STABLE_FILE_INTERVAL_MILLIS = 250;
+ /// A writer pausing between two chunks must not pass as finished: the file has to look the same this many times in a row
+ private static final int STABLE_FILE_CONFIRMATIONS = 2;
private final BibDatabaseContext database;
private final FileUpdateMonitor fileMonitor;
@@ -47,6 +58,18 @@ public class DatabaseChangeMonitor implements FileUpdateListener {
/// `synchronized (database)`; `null` when unknown, in which case the next event triggers a full scan.
@Nullable private FileSnapshot knownDiskState;
+ /// The library as of the last point where it was known to match the disk, for telling external changes apart
+ /// from unsaved in-memory edits. Guarded like [#knownDiskState]; `null` while synchronizing is off, in which case
+ /// every external change is offered for review.
+ @Nullable private LibraryBaseline baseline;
+
+ /// Counts started scans and invalidations, so that a scan overtaken by a later one (whose result reflects the
+ /// newer file state), by a save, by switching synchronization off, or by closing the tab discards its result
+ /// instead of applying stale content.
+ private volatile int scanGeneration;
+
+ private final ChangeListener synchronizingListener = (_, _, _) -> onSynchronizingChanged(isSynchronizing());
+
public DatabaseChangeMonitor(BibDatabaseContext database,
FileUpdateMonitor fileMonitor,
TaskExecutor taskExecutor,
@@ -69,18 +92,25 @@ public DatabaseChangeMonitor(BibDatabaseContext database,
monitoredPath.ifPresent(path -> {
knownDiskState = FileSnapshot.read(path);
+ baseline = captureBaseline();
try {
fileMonitor.addListenerForFile(path, this);
} catch (IOException e) {
LOGGER.error("Error while trying to monitor {}", path, e);
}
+ // Registered once per monitor; a tab replacing its monitor calls unregister() on the old one first, which
+ // removes this listener again, so the preference never accumulates listeners
+ if (database.getLocation() == DatabaseLocation.LOCAL) {
+ preferences.getLibraryPreferences().synchronizeWithFileProperty().addListener(synchronizingListener);
+ database.getMetaData().registerListener(this);
+ }
});
addListener(this::notifyExternalChanges);
}
void notifyExternalChanges(List changes) {
- Optional.ofNullable(activeNotification).ifPresent(ExternalLibraryChangeNotification::remove);
+ withdrawActiveNotification();
ExternalLibraryChangeNotification notification = new ExternalLibraryChangeNotification(changes);
dialogService.notify(notification);
@@ -114,9 +144,9 @@ public ExternalLibraryChangeNotification(List changes) {
Localization.lang("External Changes Resolver"));
Optional areAllChangesResolved = dialogService.showCustomDialogAndWait(databaseChangesResolverDialog);
if (areAllChangesResolved.orElse(false)) {
- applyResolvedChanges(
- databaseChangesResolverDialog.getResolvedChanges(),
- databaseChangesResolverDialog.resolvedChangesMatchDisk());
+ List resolved = databaseChangesResolverDialog.getResolvedChanges();
+ applyResolvedChanges(resolved, databaseChangesResolverDialog.resolvedChangesMatchDisk());
+ rebaseAfterReview(changes, resolved);
clearActiveNotification(this);
return OnClickBehaviour.REMOVE;
@@ -167,9 +197,94 @@ public void markConsistentWithDisk(@Nullable FileSnapshot diskState) {
} else {
monitoredPath.ifPresent(path -> knownDiskState = FileSnapshot.read(path));
}
+ baseline = captureBaseline();
+ scanGeneration++;
+ }
+ }
+
+ /// Synchronizing (silently merging external changes) is decided by the library itself, or, if it does not, by the
+ /// global preference.
+ private boolean isSynchronizing() {
+ return database.getLocation() == DatabaseLocation.LOCAL
+ && database.getMetaData().getSynchronizeWithFile().orElseGet(() -> preferences.getLibraryPreferences().shouldSynchronizeWithFile());
+ }
+
+ /// The library's own setting is part of its metadata, which the library properties dialog changes.
+ @Subscribe
+ public void listen(MetaDataChangedEvent event) {
+ onSynchronizingChanged(isSynchronizing());
+ }
+
+ /// Synchronization switched on for an open library needs a baseline right away. An unmodified library still
+ /// matches its file. A modified one is compared with the file first: the same apart from the setting just switched
+ /// on, it becomes the baseline; otherwise the differences are offered for review, and the baseline follows from
+ /// the review or from the next save. Whether the library is modified says nothing about the undo journal: a
+ /// declined external change, a migration on load, or an undone edit after a save leave it modified without a step.
+ private void onSynchronizingChanged(boolean enabled) {
+ boolean captured = false;
+ boolean verify = false;
+ synchronized (database) {
+ if (!enabled) {
+ if (baseline != null) {
+ baseline = null;
+ scanGeneration++;
+ }
+ } else if (baseline == null) {
+ if (libraryTab.isModified()) {
+ verify = true;
+ } else {
+ baseline = captureBaseline();
+ captured = baseline != null;
+ }
+ }
+ }
+ if (captured || verify) {
+ // A review offered while synchronization was off holds changes computed against an older state; from now
+ // on the scan decides, so the pending review is withdrawn and the file is looked at again
+ withdrawActiveNotification();
+ activeNotification = null;
+ synchronized (database) {
+ if (verify) {
+ scanToVerifyMatch();
+ } else {
+ scanForChanges();
+ }
+ }
+ }
+ }
+
+ /// Compares the modified library with its file: the same apart from the synchronization setting, the library
+ /// becomes the baseline; otherwise the differences are offered for review, as without synchronization.
+ private void scanToVerifyMatch() {
+ ChangeScanner scanner = new ChangeScanner(database, dialogService, preferences, stateManager);
+ int generation = ++scanGeneration;
+ BackgroundTask.wrap(() -> scanner.scanForChanges(() -> awaitStableLibraryFile(generation)))
+ .onSuccess(changes -> changes.ifPresent(scanned -> {
+ if (ChangeTriage.matchesFile(scanned, preferences.getCitationKeyPatternPreferences().getKeyPatterns())) {
+ captureIfCurrent(generation);
+ } else {
+ offerReview(generation, scanned);
+ }
+ }))
+ .onFailure(e -> forgetDiskState("Error while comparing with the library file", e))
+ .executeWith(taskExecutor);
+ }
+
+ private void captureIfCurrent(int generation) {
+ synchronized (database) {
+ if (generation == scanGeneration && baseline == null) {
+ baseline = captureBaseline();
+ }
}
}
+ private @Nullable LibraryBaseline captureBaseline() {
+ if (!isSynchronizing()) {
+ return null;
+ }
+ return LibraryBaseline.of(database, preferences.getCitationKeyPatternPreferences().getKeyPatterns());
+ }
+
/// A full scan parses the whole library file, so it is skipped when size and modification time show that the file
/// has not actually changed since the last known-consistent state — e.g. for events caused by JabRef's own save.
private void scanIfFileChanged() {
@@ -185,27 +300,156 @@ private void scanIfFileChanged() {
/// there are such changes.
private void scanForChanges() {
ChangeScanner scanner = new ChangeScanner(database, dialogService, preferences, stateManager);
- BackgroundTask.wrap(scanner::scanForChanges)
- .onSuccess(changes -> {
- if (!changes.isEmpty()) {
- listeners.forEach(listener -> listener.databaseChanged(changes));
- }
- })
- .onFailure(e -> LOGGER.error("Error while watching for changes", e))
+ LibraryBaseline scannedBaseline = baseline;
+ int generation = ++scanGeneration;
+ if (scannedBaseline != null && isSynchronizing()) {
+ // [impl->req~ux.external-library-changes.automatic-merge~1]
+ BackgroundTask.wrap(() -> scanner.scanForChanges(() -> awaitStableLibraryFile(generation)))
+ .onSuccess(changes -> changes.ifPresent(scanned -> onScannedForSynchronization(generation, scanner, scannedBaseline, scanned)))
+ .onFailure(e -> forgetDiskState("Error while synchronizing with the library file", e))
+ .executeWith(taskExecutor);
+ return;
+ }
+ BackgroundTask.wrap(() -> scanner.scanForChanges(() -> awaitStableLibraryFile(generation)))
+ .onSuccess(changes -> changes.filter(scanned -> !scanned.isEmpty())
+ .ifPresent(scanned -> offerReview(generation, scanned)))
+ .onFailure(e -> forgetDiskState("Error while watching for changes", e))
.executeWith(taskExecutor);
}
+ /// A scan that failed to read the file must not leave its state recorded as handled: forgetting it makes the next
+ /// file event scan again.
+ private void forgetDiskState(String message, Exception exception) {
+ LOGGER.error(message, exception);
+ synchronized (database) {
+ knownDiskState = null;
+ }
+ }
+
+ /// A notification that was never shown (no dialog service showed it) has no group to be removed from.
+ private void withdrawActiveNotification() {
+ if (activeNotification != null && activeNotification.getGroup() != null) {
+ activeNotification.remove();
+ }
+ }
+
+ @Nullable ExternalLibraryChangeNotification getActiveNotification() {
+ return activeNotification;
+ }
+
+ /// A scan overtaken by a newer file change, a save, or the tab closing must not replace the current review either.
+ private void offerReview(int generation, List changes) {
+ synchronized (database) {
+ if (generation != scanGeneration) {
+ LOGGER.debug("Discarding review of a scan overtaken by a newer file change");
+ return;
+ }
+ listeners.forEach(listener -> listener.databaseChanged(changes));
+ }
+ }
+
+ /// Sorting the changes on the FX thread right before applying them leaves no window for a user edit to slip in
+ /// between classification and application.
+ private void onScannedForSynchronization(int generation, ChangeScanner scanner, LibraryBaseline scannedBaseline, List changes) {
+ // The lock keeps a file event from starting a newer scan between the check and the application
+ synchronized (database) {
+ if (generation != scanGeneration) {
+ LOGGER.debug("Discarding result of a scan overtaken by a newer file change");
+ return;
+ }
+ synchronize(scannedBaseline, scanner.triage(scannedBaseline, changes));
+ }
+ }
+
+ /// Sync clients and editors may write the file in several steps. A file that is still growing must not be parsed:
+ /// half of a library parses fine and would look like every later entry had been deleted. Waits (bounded) until
+ /// size and modification time have stopped changing for a while. The state seen becomes the known disk state, so
+ /// that the events of the write just waited for do not trigger another scan; only for the current scan, since a
+ /// scan already overtaken will not apply what it sees, and recording it would make the next event look handled.
+ ///
+ /// @return `false` when the wait was interrupted or the file did not settle; it may still be incomplete, so the scan must not go on
+ private boolean awaitStableLibraryFile(int generation) {
+ Path path = monitoredPath.orElse(null);
+ if (path == null) {
+ return true;
+ }
+ FileSnapshot last = FileSnapshot.read(path);
+ int unchanged = 0;
+ for (int attempt = 0; attempt < STABLE_FILE_ATTEMPTS && unchanged < STABLE_FILE_CONFIRMATIONS; attempt++) {
+ try {
+ Thread.sleep(STABLE_FILE_INTERVAL_MILLIS);
+ } catch (InterruptedException e) {
+ LOGGER.debug("Interrupted while waiting for {} to stop changing; the scan is abandoned", path, e);
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ FileSnapshot current = FileSnapshot.read(path);
+ unchanged = Objects.equals(current, last) ? unchanged + 1 : 0;
+ last = current;
+ }
+ if (unchanged < STABLE_FILE_CONFIRMATIONS) {
+ // Still being written when the budget ran out: the writer's next events start a fresh scan
+ LOGGER.debug("{} kept changing; the scan is abandoned", path);
+ return false;
+ }
+ synchronized (database) {
+ if (generation == scanGeneration) {
+ knownDiskState = last;
+ }
+ }
+ return true;
+ }
+
+ /// Applies what changed on disk only, and offers the review for what changed on both sides.
+ private void synchronize(LibraryBaseline scannedBaseline, ChangeTriage.Triage triage) {
+ List unresolved = new ArrayList<>(triage.bothSides());
+ unresolved.addAll(triage.memoryOnly());
+ if (!triage.diskOnly().isEmpty()) {
+ applyResolvedChanges(triage.diskOnly(), unresolved.isEmpty());
+ dialogService.notify(Localization.lang("Merged %0 change(s) from the library file", String.valueOf(triage.diskOnly().size())));
+ }
+ synchronized (database) {
+ LibraryBaseline updated = captureBaseline();
+ if (updated != null) {
+ ChangeTriage.keepUnresolved(updated, scannedBaseline, unresolved);
+ // A merged entry holds more than the file: what it holds beyond is an unsaved edit, not the ancestor
+ triage.diskEntries().forEach(updated::recordEntry);
+ }
+ baseline = updated;
+ }
+ if (!triage.bothSides().isEmpty()) {
+ listeners.forEach(listener -> listener.databaseChanged(triage.bothSides()));
+ }
+ }
+
+ /// After a review, every judged item has the disk version as its ancestor ([ChangeTriage#advance]); everything
+ /// else, unsaved edits made since the scan included, keeps the ancestor it had. Without a baseline so far
+ /// (synchronization switched on while the library was modified, the review settling the difference), the library
+ /// is taken as the ancestor first.
+ private void rebaseAfterReview(List scanned, List resolved) {
+ synchronized (database) {
+ LibraryBaseline updated = baseline == null ? captureBaseline() : baseline.copy();
+ if (updated != null) {
+ ChangeTriage.advance(updated, scanned, resolved);
+ }
+ baseline = updated;
+ }
+ }
+
/// Applies the accepted external changes and updates the library's dirty state.
///
/// @param resolvedChanges the externally resolved changes to apply to the in-memory database
- /// @param resolvedChangesMatchDisk `true` if the accepted result now matches the file on disk, so the library can be marked clean; `false` if the resolved result differs from disk and still needs saving
+ /// @param resolvedChangesMatchDisk `true` if the accepted result now matches the file on disk for the resolved items, so the library can be marked clean unless it was modified elsewhere; `false` if the resolved result differs from disk and still needs saving
void applyResolvedChanges(List resolvedChanges, boolean resolvedChangesMatchDisk) {
+ // Decided before applying: the changes cover the differences to the file only, an unsaved edit elsewhere
+ // (not an external change, so never part of them) keeps the library modified
+ boolean matchesDisk = resolvedChangesMatchDisk && !libraryTab.isModified();
undoManager.addEdit(Localization.lang("Merged external changes"), edit ->
resolvedChanges.stream()
.filter(DatabaseChange::isAccepted)
.forEach(change -> change.applyChange(edit)));
- if (resolvedChangesMatchDisk) {
+ if (matchesDisk) {
libraryTab.resetChangedProperties();
} else {
// Nothing on the stack describes a denied change - denying one records nothing - but the
@@ -219,6 +463,18 @@ public void addListener(DatabaseChangeListener listener) {
}
public void unregister() {
- monitoredPath.ifPresent(path -> fileMonitor.removeListener(path, this));
+ synchronized (database) {
+ scanGeneration++;
+ }
+ // A review offered by this monitor must not outlive it: its changes belong to the file it watched
+ withdrawActiveNotification();
+ activeNotification = null;
+ monitoredPath.ifPresent(path -> {
+ fileMonitor.removeListener(path, this);
+ // Unconditionally: the library may have been converted to a shared one since the listener was added,
+ // and removing a listener that was never added is a no-op
+ preferences.getLibraryPreferences().synchronizeWithFileProperty().removeListener(synchronizingListener);
+ database.getMetaData().unregisterListener(this);
+ });
}
}
diff --git a/jabgui/src/main/java/org/jabref/gui/collab/entrychange/EntryChange.java b/jabgui/src/main/java/org/jabref/gui/collab/entrychange/EntryChange.java
index 512326589b2b..dcaaad72b506 100644
--- a/jabgui/src/main/java/org/jabref/gui/collab/entrychange/EntryChange.java
+++ b/jabgui/src/main/java/org/jabref/gui/collab/entrychange/EntryChange.java
@@ -1,13 +1,20 @@
package org.jabref.gui.collab.entrychange;
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.SequencedSet;
+
import org.jabref.gui.collab.DatabaseChange;
import org.jabref.gui.collab.DatabaseChangeResolverFactory;
import org.jabref.logic.l10n.Localization;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.field.Field;
import org.jabref.model.undo.CompoundEdit;
-import org.jabref.model.undo.UndoableInsertEntries;
-import org.jabref.model.undo.UndoableRemoveEntries;
+import org.jabref.model.undo.UndoableChangeType;
+import org.jabref.model.undo.UndoableChangedFlag;
+import org.jabref.model.undo.UndoableCommentsChange;
+import org.jabref.model.undo.UndoableFieldChange;
public final class EntryChange extends DatabaseChange {
private final BibEntry oldEntry;
@@ -33,9 +40,31 @@ public BibEntry getNewEntry() {
return newEntry;
}
+ /// Changes the entry in place rather than replacing it, so it keeps its identity: table position, selection, and
+ /// an open entry editor stay as they are.
+ ///
+ /// The changes are collected in a nested step named after this change, so a failure while applying them names
+ /// the entry it belongs to instead of only the enclosing merge.
@Override
public void applyChange(CompoundEdit undoEdit) {
- undoEdit.applyEdit(new UndoableRemoveEntries(databaseContext.getDatabase(), oldEntry));
- undoEdit.applyEdit(new UndoableInsertEntries(databaseContext.getDatabase(), newEntry));
+ CompoundEdit entryEdit = new CompoundEdit(getName());
+ // First in the compound, so that undo restores it after the field edits have marked the entry changed again
+ entryEdit.applyEdit(UndoableChangedFlag.marking(oldEntry));
+ if (!Objects.equals(oldEntry.getType(), newEntry.getType())) {
+ entryEdit.applyEdit(new UndoableChangeType(oldEntry, oldEntry.getType(), newEntry.getType()));
+ }
+ SequencedSet fields = new LinkedHashSet<>(oldEntry.getFields());
+ fields.addAll(newEntry.getFields());
+ for (Field field : fields) {
+ String before = oldEntry.getField(field).orElse(null);
+ String after = newEntry.getField(field).orElse(null);
+ if (!Objects.equals(before, after)) {
+ entryEdit.applyEdit(new UndoableFieldChange(oldEntry, field, before, after));
+ }
+ }
+ if (!oldEntry.getUserComments().equals(newEntry.getUserComments())) {
+ entryEdit.applyEdit(new UndoableCommentsChange(oldEntry, oldEntry.getUserComments(), newEntry.getUserComments()));
+ }
+ undoEdit.addEdit(entryEdit);
}
}
diff --git a/jabgui/src/main/java/org/jabref/gui/libraryproperties/saving/SavingPropertiesView.java b/jabgui/src/main/java/org/jabref/gui/libraryproperties/saving/SavingPropertiesView.java
index 38ca34e3fdb2..fe72aad62898 100644
--- a/jabgui/src/main/java/org/jabref/gui/libraryproperties/saving/SavingPropertiesView.java
+++ b/jabgui/src/main/java/org/jabref/gui/libraryproperties/saving/SavingPropertiesView.java
@@ -10,12 +10,15 @@
import org.jabref.gui.commonfxcontrols.SaveOrderConfigPanel;
import org.jabref.gui.libraryproperties.AbstractPropertiesTabView;
import org.jabref.gui.libraryproperties.PropertiesTab;
+import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.logic.cleanup.CleanupPreferences;
import org.jabref.logic.journals.AbbreviationType;
import org.jabref.logic.l10n.Localization;
import org.jabref.model.database.BibDatabaseContext;
import com.airhacks.afterburner.views.ViewLoader;
+import jakarta.inject.Inject;
+import org.jspecify.annotations.Nullable;
public class SavingPropertiesView extends AbstractPropertiesTabView implements PropertiesTab {
@@ -23,6 +26,9 @@ public class SavingPropertiesView extends AbstractPropertiesTabView journalAbbreviationOnSave;
+ @FXML private ComboBox<@Nullable Boolean> synchronizeWithFile;
+
+ @Inject private GuiPreferences preferences;
public SavingPropertiesView(BibDatabaseContext databaseContext) {
this.databaseContext = databaseContext;
@@ -79,5 +85,26 @@ public AbbreviationType fromString(String string) {
}
});
journalAbbreviationOnSave.valueProperty().bindBidirectional(viewModel.journalAbbreviationOnSaveProperty());
+
+ synchronizeWithFile.setItems(FXCollections.observableArrayList(null, Boolean.TRUE, Boolean.FALSE));
+ synchronizeWithFile.setConverter(new StringConverter<>() {
+ @Override
+ public String toString(@Nullable Boolean synchronize) {
+ if (synchronize == null) {
+ return Localization.lang("Use global setting (%0)", onOrOff(preferences.getLibraryPreferences().shouldSynchronizeWithFile()));
+ }
+ return onOrOff(synchronize);
+ }
+
+ @Override
+ public @Nullable Boolean fromString(String string) {
+ return null;
+ }
+ });
+ synchronizeWithFile.valueProperty().bindBidirectional(viewModel.synchronizeWithFileProperty());
+ }
+
+ private static String onOrOff(boolean on) {
+ return on ? Localization.lang("On") : Localization.lang("Off");
}
}
diff --git a/jabgui/src/main/java/org/jabref/gui/libraryproperties/saving/SavingPropertiesViewModel.java b/jabgui/src/main/java/org/jabref/gui/libraryproperties/saving/SavingPropertiesViewModel.java
index 30f897aec74a..1a4b003e4824 100644
--- a/jabgui/src/main/java/org/jabref/gui/libraryproperties/saving/SavingPropertiesViewModel.java
+++ b/jabgui/src/main/java/org/jabref/gui/libraryproperties/saving/SavingPropertiesViewModel.java
@@ -25,6 +25,8 @@
import org.jabref.model.metadata.MetaData;
import org.jabref.model.metadata.SaveOrder;
+import org.jspecify.annotations.Nullable;
+
public class SavingPropertiesViewModel implements PropertiesTabViewModel {
private static final SaveOrder UI_DEFAULT_SAVE_ORDER = new SaveOrder(SaveOrder.OrderType.ORIGINAL, List.of(
@@ -51,6 +53,8 @@ public class SavingPropertiesViewModel implements PropertiesTabViewModel {
// Journal abbreviation on save
private final ObjectProperty journalAbbreviationOnSaveProperty = new SimpleObjectProperty<>();
+ /// `null` = follow the global preference
+ private final ObjectProperty<@Nullable Boolean> synchronizeWithFileProperty = new SimpleObjectProperty<>();
private final FieldFormatterCleanupActions defaultSaveActions;
@@ -104,6 +108,7 @@ public void setValues(MetaData metaData) {
});
journalAbbreviationOnSaveProperty.setValue(metaData.getLibraryAbbreviationType().orElse(null));
+ synchronizeWithFileProperty.setValue(metaData.getSynchronizeWithFile().orElse(null));
}
@Override
@@ -141,6 +146,13 @@ public void storeSettings(MetaData metaData) {
}
}
+ Boolean synchronize = synchronizeWithFileProperty.getValue();
+ if (synchronize == null) {
+ metaData.clearSynchronizeWithFile();
+ } else {
+ metaData.setSynchronizeWithFile(synchronize);
+ }
+
AbbreviationType abbreviationType = journalAbbreviationOnSaveProperty.getValue();
if (abbreviationType != null) {
metaData.setLibraryAbbreviationType(abbreviationType);
@@ -192,4 +204,8 @@ public ListProperty cleanupsProperty() {
public ObjectProperty journalAbbreviationOnSaveProperty() {
return journalAbbreviationOnSaveProperty;
}
+
+ public ObjectProperty<@Nullable Boolean> synchronizeWithFileProperty() {
+ return synchronizeWithFileProperty;
+ }
}
diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/general/GeneralTab.java b/jabgui/src/main/java/org/jabref/gui/preferences/general/GeneralTab.java
index 7a40aaed8039..5d5557d83e80 100644
--- a/jabgui/src/main/java/org/jabref/gui/preferences/general/GeneralTab.java
+++ b/jabgui/src/main/java/org/jabref/gui/preferences/general/GeneralTab.java
@@ -124,6 +124,7 @@ private void buildView() {
.checkbox(Localization.lang("Always reformat library on save and export"), viewModel.alwaysReformatBibProperty())
.checkbox(Localization.lang("Autosave local libraries"), viewModel.autosaveLocalLibrariesProperty(),
autosave -> autosave.help(HelpFile.AUTOSAVE))
+ .checkbox(Localization.lang("Automatically merge external changes (libraries can override this)"), viewModel.synchronizeLocalLibrariesProperty())
.checkWithField(Localization.lang("Create backup"), viewModel.createBackupProperty(),
viewModel.backupDirectoryProperty(),
directory -> directory
diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/general/GeneralTabViewModel.java b/jabgui/src/main/java/org/jabref/gui/preferences/general/GeneralTabViewModel.java
index e7742d903347..b53f44b50e71 100644
--- a/jabgui/src/main/java/org/jabref/gui/preferences/general/GeneralTabViewModel.java
+++ b/jabgui/src/main/java/org/jabref/gui/preferences/general/GeneralTabViewModel.java
@@ -90,6 +90,7 @@ public class GeneralTabViewModel implements PreferenceTabViewModel {
private final BooleanProperty alwaysReformatBibProperty = new SimpleBooleanProperty();
private final BooleanProperty autosaveLocalLibraries = new SimpleBooleanProperty();
+ private final BooleanProperty synchronizeLocalLibraries = new SimpleBooleanProperty();
private final BooleanProperty createBackupProperty = new SimpleBooleanProperty();
private final StringProperty backupDirectoryProperty = new SimpleStringProperty("");
@@ -240,6 +241,7 @@ public void setValues() {
alwaysReformatBibProperty.setValue(libraryPreferences.shouldAlwaysReformatOnSave());
autosaveLocalLibraries.setValue(libraryPreferences.shouldAutoSave());
+ synchronizeLocalLibraries.setValue(libraryPreferences.shouldSynchronizeWithFile());
createBackupProperty.setValue(filePreferences.shouldCreateBackup());
backupDirectoryProperty.setValue(filePreferences.getBackupDirectory().toString());
@@ -289,6 +291,7 @@ public void storeSettings() {
libraryPreferences.setAlwaysReformatOnSave(alwaysReformatBibProperty.getValue());
libraryPreferences.setAutoSave(autosaveLocalLibraries.getValue());
+ libraryPreferences.setSynchronizeWithFile(synchronizeLocalLibraries.getValue());
filePreferences.setCreateBackup(createBackupProperty.getValue());
filePreferences.setBackupDirectory(Path.of(backupDirectoryProperty.getValue()));
@@ -492,6 +495,10 @@ public BooleanProperty autosaveLocalLibrariesProperty() {
return autosaveLocalLibraries;
}
+ public BooleanProperty synchronizeLocalLibrariesProperty() {
+ return synchronizeLocalLibraries;
+ }
+
public BooleanProperty createBackupProperty() {
return this.createBackupProperty;
}
diff --git a/jabgui/src/main/resources/org/jabref/gui/libraryproperties/saving/SavingProperties.fxml b/jabgui/src/main/resources/org/jabref/gui/libraryproperties/saving/SavingProperties.fxml
index 3ea357039e60..c3de90233374 100644
--- a/jabgui/src/main/resources/org/jabref/gui/libraryproperties/saving/SavingProperties.fxml
+++ b/jabgui/src/main/resources/org/jabref/gui/libraryproperties/saving/SavingProperties.fxml
@@ -21,6 +21,12 @@
+
+
+
+
+
+
diff --git a/jabgui/src/test/java/org/jabref/gui/collab/ChangeTriageTest.java b/jabgui/src/test/java/org/jabref/gui/collab/ChangeTriageTest.java
new file mode 100644
index 000000000000..da87a803d7bc
--- /dev/null
+++ b/jabgui/src/test/java/org/jabref/gui/collab/ChangeTriageTest.java
@@ -0,0 +1,386 @@
+package org.jabref.gui.collab;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Optional;
+
+import org.jabref.gui.collab.entryadd.EntryAdd;
+import org.jabref.gui.collab.entrychange.EntryChange;
+import org.jabref.gui.collab.entrydelete.EntryDelete;
+import org.jabref.gui.collab.groupchange.GroupChange;
+import org.jabref.gui.collab.metedatachange.MetadataChange;
+import org.jabref.gui.collab.stringadd.BibTexStringAdd;
+import org.jabref.logic.citationkeypattern.GlobalCitationKeyPatterns;
+import org.jabref.logic.groups.GroupsFactory;
+import org.jabref.logic.sync.LibraryBaseline;
+import org.jabref.model.database.BibDatabase;
+import org.jabref.model.database.BibDatabaseContext;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.BibtexString;
+import org.jabref.model.entry.field.StandardField;
+import org.jabref.model.entry.types.StandardEntryType;
+import org.jabref.model.groups.ExplicitGroup;
+import org.jabref.model.groups.GroupHierarchyType;
+import org.jabref.model.groups.GroupTreeNode;
+import org.jabref.model.undo.CompoundEdit;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ChangeTriageTest {
+
+ private static final GlobalCitationKeyPatterns PATTERNS = GlobalCitationKeyPatterns.fromPattern("[auth][year]");
+
+ private BibEntry local;
+ private BibEntry other;
+ private BibDatabaseContext localContext;
+ private BibEntry disk;
+ private BibEntry otherOnDisk;
+ private BibDatabaseContext diskContext;
+ private LibraryBaseline baseline;
+
+ @BeforeEach
+ void setUp() {
+ local = new BibEntry(StandardEntryType.Article).withCitationKey("Key")
+ .withField(StandardField.TITLE, "Title")
+ .withField(StandardField.YEAR, "2020");
+ // A second, mostly untouched entry keeps the file from becoming empty, which the two-way diff cannot handle
+ other = new BibEntry(StandardEntryType.Book).withCitationKey("Other").withField(StandardField.TITLE, "Other");
+ localContext = new BibDatabaseContext(new BibDatabase(List.of(local, other)));
+ baseline = LibraryBaseline.of(localContext, PATTERNS);
+ // the file as it would be parsed: same content, different entry objects
+ disk = new BibEntry(local);
+ otherOnDisk = new BibEntry(other);
+ diskContext = new BibDatabaseContext(new BibDatabase(List.of(disk, otherOnDisk)));
+ }
+
+ private ChangeTriage.Triage triage() {
+ return ChangeTriage.triage(baseline, DatabaseChangeList.compareAndGetChanges(localContext, diskContext, null), localContext, null);
+ }
+
+ @Test
+ void changeOnDiskOnlyIsAccepted() {
+ disk.setField(StandardField.TITLE, "Disk title");
+
+ ChangeTriage.Triage triage = triage();
+
+ assertEquals(1, triage.diskOnly().size());
+ assertTrue(triage.diskOnly().getFirst().isAccepted());
+ assertEquals(List.of(), triage.bothSides());
+ assertEquals(List.of(), triage.memoryOnly());
+ }
+
+ @Test
+ void unsavedChangeInMemoryIsNotAnExternalChange() {
+ local.setField(StandardField.TITLE, "Memory title");
+
+ ChangeTriage.Triage triage = triage();
+
+ assertEquals(1, triage.memoryOnly().size());
+ assertEquals(List.of(), triage.diskOnly());
+ assertEquals(List.of(), triage.bothSides());
+ }
+
+ @Test
+ void differentFieldsChangedOnBothSidesAreMerged() {
+ local.setField(StandardField.TITLE, "Memory title");
+ disk.setField(StandardField.YEAR, "2021");
+ disk.setField(StandardField.AUTHOR, "Disk author");
+
+ ChangeTriage.Triage triage = triage();
+
+ assertEquals(List.of(), triage.bothSides());
+ EntryChange merged = assertInstanceOf(EntryChange.class, triage.diskOnly().getFirst());
+ assertTrue(merged.isAccepted());
+ assertEquals(new BibEntry(StandardEntryType.Article).withCitationKey("Key")
+ .withField(StandardField.TITLE, "Memory title")
+ .withField(StandardField.YEAR, "2021")
+ .withField(StandardField.AUTHOR, "Disk author"),
+ merged.getNewEntry());
+ }
+
+ @Test
+ void sameFieldChangedDifferentlyNeedsReview() {
+ local.setField(StandardField.TITLE, "Memory title");
+ disk.setField(StandardField.TITLE, "Disk title");
+
+ ChangeTriage.Triage triage = triage();
+
+ assertEquals(1, triage.bothSides().size());
+ assertEquals(List.of(), triage.diskOnly());
+ }
+
+ @Test
+ void citationKeyChangedInMemoryStillMergesDiskChange() {
+ local.setCitationKey("NewKey");
+ disk.setField(StandardField.TITLE, "Disk title");
+
+ ChangeTriage.Triage triage = triage();
+
+ EntryChange merged = assertInstanceOf(EntryChange.class, triage.diskOnly().getFirst());
+ assertEquals("NewKey", merged.getNewEntry().getCitationKey().orElseThrow());
+ assertEquals("Disk title", merged.getNewEntry().getField(StandardField.TITLE).orElseThrow());
+ }
+
+ @Test
+ void citationKeyChangedOnDiskIsMergedIntoTheSameEntry() {
+ disk.setCitationKey("Renamed");
+ disk.setField(StandardField.YEAR, "2021");
+
+ ChangeTriage.Triage triage = triage();
+
+ EntryChange merged = assertInstanceOf(EntryChange.class, triage.diskOnly().getFirst());
+ assertEquals(local, merged.getOldEntry());
+ assertEquals("Renamed", merged.getNewEntry().getCitationKey().orElseThrow());
+ assertEquals(1, triage.diskOnly().size());
+ }
+
+ @Test
+ void encodingChangedOnDiskIsAccepted() {
+ localContext.getMetaData().setEncoding(StandardCharsets.UTF_8);
+ baseline = LibraryBaseline.of(localContext, PATTERNS);
+ diskContext.getMetaData().setEncoding(StandardCharsets.ISO_8859_1);
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(MetadataChange.class, triage.diskOnly().getFirst());
+ assertEquals(List.of(), triage.memoryOnly());
+ }
+
+ @Test
+ void duplicateCitationKeysAreMatchedByContent() {
+ BibEntry duplicate = new BibEntry(StandardEntryType.Article).withCitationKey("Key").withField(StandardField.TITLE, "Duplicate");
+ localContext.getDatabase().insertEntry(duplicate);
+ diskContext.getDatabase().insertEntry(new BibEntry(duplicate));
+ baseline = LibraryBaseline.of(localContext, PATTERNS);
+ localContext.getDatabase().removeEntry(local);
+
+ ChangeTriage.Triage triage = triage();
+
+ // The entry deleted in memory is still unchanged on disk, so nothing is reported; the duplicate must not be mistaken for it
+ assertInstanceOf(EntryAdd.class, triage.memoryOnly().getFirst());
+ assertEquals(List.of(), triage.bothSides());
+ }
+
+ @Test
+ void commentChangedOnDiskIsAcceptedAndApplied() {
+ disk.setCommentsBeforeEntry("% from disk");
+
+ ChangeTriage.Triage triage = triage();
+
+ EntryChange change = assertInstanceOf(EntryChange.class, triage.diskOnly().getFirst());
+ local.setChanged(false);
+ change.applyChange(new CompoundEdit("test"));
+ assertEquals("% from disk", local.getUserComments());
+ // otherwise a save would write the parsed serialization with the old comment
+ assertTrue(local.hasChanged());
+ }
+
+ @Test
+ void undoOfAppliedChangeRestoresTheParsedState() {
+ disk.setField(StandardField.TITLE, "Disk title");
+ ChangeTriage.Triage triage = triage();
+ EntryChange change = assertInstanceOf(EntryChange.class, triage.diskOnly().getFirst());
+ local.setChanged(false);
+ CompoundEdit edit = new CompoundEdit("test");
+
+ change.applyChange(edit);
+ assertTrue(local.hasChanged());
+ edit.toChangeSet().inverted().apply();
+
+ assertEquals(Optional.of("Title"), local.getField(StandardField.TITLE));
+ assertFalse(local.hasChanged());
+ }
+
+ @Test
+ void pairEstablishedBySimilarityOnlyGoesToReview() {
+ // memory edits the entry, disk replaces it by a similar one under another key
+ local.setField(StandardField.YEAR, "2021");
+ diskContext.getDatabase().removeEntry(disk);
+ diskContext.getDatabase().insertEntry(new BibEntry(StandardEntryType.Article).withCitationKey("Renamed").withField(StandardField.TITLE, "Alpha title").withField(StandardField.YEAR, "2020"));
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(EntryChange.class, triage.bothSides().getFirst());
+ assertEquals(List.of(), triage.diskOnly());
+ }
+
+ @Test
+ void metadataChangeKeepsTheLocalSynchronizationSetting() {
+ localContext.getMetaData().setSynchronizeWithFile(true);
+ baseline = LibraryBaseline.of(localContext, PATTERNS);
+ diskContext.getMetaData().setSynchronizeWithFile(false);
+ diskContext.getMetaData().setEncoding(StandardCharsets.ISO_8859_1);
+
+ ChangeTriage.Triage triage = triage();
+ MetadataChange change = assertInstanceOf(MetadataChange.class, triage.diskOnly().getFirst());
+ change.applyChange(new CompoundEdit("test"));
+
+ assertEquals(Optional.of(true), localContext.getMetaData().getSynchronizeWithFile());
+ assertEquals(Optional.of(StandardCharsets.ISO_8859_1), localContext.getMetaData().getEncoding());
+ }
+
+ @Test
+ void entryAddedOnDiskIsAccepted() {
+ diskContext.getDatabase().insertEntry(new BibEntry().withCitationKey("New").withField(StandardField.TITLE, "New on disk"));
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(EntryAdd.class, triage.diskOnly().getFirst());
+ assertEquals(List.of(), triage.bothSides());
+ }
+
+ @Test
+ void entryDeletedInMemoryStaysDeletedWhenUnchangedOnDisk() {
+ localContext.getDatabase().removeEntry(local);
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(EntryAdd.class, triage.memoryOnly().getFirst());
+ assertEquals(List.of(), triage.diskOnly());
+ }
+
+ @Test
+ void entryDeletedInMemoryButChangedOnDiskNeedsReview() {
+ localContext.getDatabase().removeEntry(local);
+ disk.setField(StandardField.TITLE, "Disk title");
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(EntryAdd.class, triage.bothSides().getFirst());
+ }
+
+ @Test
+ void entryDeletedOnDiskIsAcceptedWhenUnchangedInMemory() {
+ diskContext.getDatabase().removeEntry(disk);
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(EntryDelete.class, triage.diskOnly().getFirst());
+ }
+
+ @Test
+ void entryDeletedOnDiskButChangedInMemoryNeedsReview() {
+ diskContext.getDatabase().removeEntry(disk);
+ local.setField(StandardField.TITLE, "Memory title");
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(EntryDelete.class, triage.bothSides().getFirst());
+ }
+
+ @Test
+ void entryAddedInMemoryIsNotAnExternalChange() {
+ localContext.getDatabase().insertEntry(new BibEntry().withCitationKey("New").withField(StandardField.TITLE, "New in memory"));
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(EntryDelete.class, triage.memoryOnly().getFirst());
+ assertEquals(List.of(), triage.diskOnly());
+ }
+
+ @Test
+ void stringAddedOnDiskIsAccepted() {
+ diskContext.getDatabase().addString(new BibtexString("jan", "January"));
+
+ ChangeTriage.Triage triage = triage();
+
+ assertInstanceOf(BibTexStringAdd.class, triage.diskOnly().getFirst());
+ }
+
+ @Test
+ void keepUnresolvedPreservesBaselineOfUnsavedMemoryChange() {
+ local.setField(StandardField.TITLE, "Memory title");
+ ChangeTriage.Triage first = triage();
+ LibraryBaseline updated = LibraryBaseline.of(localContext, PATTERNS);
+ ChangeTriage.keepUnresolved(updated, baseline, first.memoryOnly());
+ baseline = updated;
+
+ // The file now changes the same field: this must be reported as a conflict, not taken over silently
+ disk.setField(StandardField.TITLE, "Disk title");
+ ChangeTriage.Triage second = triage();
+
+ assertEquals(1, second.bothSides().size());
+ assertEquals(List.of(), second.diskOnly());
+ }
+
+ @Test
+ void mergedEntryHasTheDiskVersionAsAncestor() {
+ local.setField(StandardField.TITLE, "Memory title");
+ disk.setField(StandardField.YEAR, "2021");
+ ChangeTriage.Triage first = triage();
+ assertInstanceOf(EntryChange.class, first.diskOnly().getFirst()).applyChange(new CompoundEdit("test"));
+ LibraryBaseline updated = LibraryBaseline.of(localContext, PATTERNS);
+ first.diskEntries().forEach(updated::recordEntry);
+ baseline = updated;
+
+ // The file changes elsewhere while the merged entry stays as it is on disk: the title kept from memory is
+ // still an unsaved edit, not a disk change to take over
+ otherOnDisk.setField(StandardField.TITLE, "Other on disk");
+ ChangeTriage.Triage second = triage();
+
+ assertEquals(1, second.diskOnly().size());
+ assertEquals(1, second.memoryOnly().size());
+ assertEquals(List.of(), second.bothSides());
+ }
+
+ @Test
+ void reviewKeepsTheAncestorOfAnUnsavedMemoryChange() {
+ // "Other" is changed differently on both sides and reviewed; the unsaved edit of "Key" is not part of the review
+ local.setField(StandardField.TITLE, "Memory title");
+ other.setField(StandardField.TITLE, "Other in memory");
+ otherOnDisk.setField(StandardField.TITLE, "Other on disk");
+ ChangeTriage.Triage first = triage();
+ assertEquals(1, first.bothSides().size());
+ first.bothSides().getFirst().accept();
+ first.bothSides().getFirst().applyChange(new CompoundEdit("test"));
+ LibraryBaseline updated = baseline.copy();
+ ChangeTriage.advance(updated, first.bothSides(), first.bothSides());
+ baseline = updated;
+
+ // The file now changes the field edited in memory: a conflict, not a change to take over silently
+ disk.setField(StandardField.TITLE, "Disk title");
+ ChangeTriage.Triage second = triage();
+
+ assertEquals(1, second.bothSides().size());
+ assertEquals(List.of(), second.diskOnly());
+ }
+
+ @Test
+ void metadataAcceptedAndGroupsDeclinedAreNotReportedAgain() {
+ localContext.getMetaData().setEncoding(StandardCharsets.ISO_8859_1);
+ GroupTreeNode root = new GroupTreeNode(GroupsFactory.createAllEntriesGroup());
+ root.addSubgroup(new ExplicitGroup("Group", GroupHierarchyType.INDEPENDENT, ','));
+ diskContext.getMetaData().setGroups(root);
+ ChangeTriage.Triage first = triage();
+ MetadataChange metadataChange = assertInstanceOf(MetadataChange.class, first.bothSides().getFirst());
+ assertInstanceOf(GroupChange.class, first.bothSides().get(1));
+ metadataChange.accept();
+ metadataChange.applyChange(new CompoundEdit("test"));
+ // Applying installed the disk metadata object without its groups; the file itself still has them
+ diskContext.getMetaData().setGroups(root);
+ LibraryBaseline updated = baseline.copy();
+ ChangeTriage.advance(updated, first.bothSides(), first.bothSides());
+ baseline = updated;
+
+ ChangeTriage.Triage second = triage();
+
+ assertEquals(List.of(), second.bothSides());
+ assertEquals(List.of(), second.diskOnly());
+ assertEquals(2, second.memoryOnly().size());
+ }
+
+ @Test
+ void onlyTheSynchronizationSettingDifferingCountsAsMatchingTheFile() {
+ localContext.getMetaData().setSynchronizeWithFile(true);
+ assertTrue(ChangeTriage.matchesFile(DatabaseChangeList.compareAndGetChanges(localContext, diskContext, null), PATTERNS));
+
+ disk.setField(StandardField.TITLE, "Disk title");
+ assertFalse(ChangeTriage.matchesFile(DatabaseChangeList.compareAndGetChanges(localContext, diskContext, null), PATTERNS));
+ }
+}
diff --git a/jabgui/src/test/java/org/jabref/gui/collab/DatabaseChangeMonitorTest.java b/jabgui/src/test/java/org/jabref/gui/collab/DatabaseChangeMonitorTest.java
index e1290c4ed390..adcb18cb6d9b 100644
--- a/jabgui/src/test/java/org/jabref/gui/collab/DatabaseChangeMonitorTest.java
+++ b/jabgui/src/test/java/org/jabref/gui/collab/DatabaseChangeMonitorTest.java
@@ -22,16 +22,20 @@
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.field.StandardField;
+import org.jabref.model.metadata.MetaData;
import org.jabref.model.util.FileUpdateListener;
import org.jabref.model.util.FileUpdateMonitor;
import com.dlsc.gemsfx.infocenter.NotificationGroup;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -44,6 +48,30 @@
class DatabaseChangeMonitorTest {
+ @Test
+ void unregisterWithdrawsTheActiveReview(@TempDir Path tempDir) throws Exception {
+ Path library = tempDir.resolve("library.bib");
+ Files.writeString(library, "@Article{a, title = {A}}");
+ BibDatabaseContext databaseContext = mock(BibDatabaseContext.class);
+ when(databaseContext.getDatabasePath()).thenReturn(Optional.of(library));
+ when(databaseContext.getMetaData()).thenReturn(new MetaData());
+ DatabaseChangeMonitor monitor = new DatabaseChangeMonitor(
+ databaseContext,
+ mock(FileUpdateMonitor.class),
+ mock(TaskExecutor.class),
+ mock(DialogService.class),
+ mock(GuiPreferences.class, Answers.RETURNS_DEEP_STUBS),
+ mock(UndoManager.class),
+ mock(StateManager.class),
+ mock(LibraryTab.class));
+ monitor.notifyExternalChanges(List.of());
+ assertNotNull(monitor.getActiveNotification());
+
+ monitor.unregister();
+
+ assertNull(monitor.getActiveNotification());
+ }
+
@Test
void unregisterRemovesListenerFromOriginallyMonitoredPath(@TempDir Path tempDir) throws Exception {
Path originalPath = tempDir.resolve("original.bib");
@@ -51,6 +79,7 @@ void unregisterRemovesListenerFromOriginallyMonitoredPath(@TempDir Path tempDir)
BibDatabaseContext databaseContext = mock(BibDatabaseContext.class);
when(databaseContext.getDatabasePath()).thenReturn(Optional.of(originalPath), Optional.of(newPath));
+ when(databaseContext.getMetaData()).thenReturn(new MetaData());
FileUpdateMonitor fileUpdateMonitor = mock(FileUpdateMonitor.class);
@@ -59,7 +88,7 @@ void unregisterRemovesListenerFromOriginallyMonitoredPath(@TempDir Path tempDir)
fileUpdateMonitor,
mock(TaskExecutor.class),
mock(DialogService.class),
- mock(GuiPreferences.class),
+ mock(GuiPreferences.class, Answers.RETURNS_DEEP_STUBS),
mock(UndoManager.class),
mock(StateManager.class),
mock(LibraryTab.class));
@@ -279,6 +308,33 @@ void applyResolvedChangesAppliesDiskVersionAndKeepsLibraryClean() {
verify(libraryTab).resetChangedProperties();
}
+ @Test
+ void applyResolvedChangesKeepsTheLibraryModifiedWhenItHasUnsavedEditsElsewhere() {
+ BibEntry oldEntry = new BibEntry().withCitationKey("Key")
+ .withField(StandardField.TITLE, "Old title");
+ BibDatabaseContext databaseContext = new BibDatabaseContext(new BibDatabase(List.of(oldEntry)));
+ EntryChange diskChange = new EntryChange(oldEntry, new BibEntry(oldEntry).withField(StandardField.TITLE, "Disk title"), databaseContext);
+ diskChange.accept();
+
+ JabRefUndoManager undoManager = new JabRefUndoManager();
+ LibraryTab libraryTab = mock(LibraryTab.class);
+ when(libraryTab.isModified()).thenReturn(true);
+ DatabaseChangeMonitor monitor = new DatabaseChangeMonitor(
+ databaseContext,
+ mock(FileUpdateMonitor.class),
+ mock(TaskExecutor.class),
+ mock(DialogService.class),
+ mock(GuiPreferences.class),
+ undoManager,
+ mock(StateManager.class),
+ libraryTab);
+
+ monitor.applyResolvedChanges(List.of(diskChange), true);
+
+ assertTrue(undoManager.hasChanged());
+ verify(libraryTab, never()).resetChangedProperties();
+ }
+
@Test
@SuppressWarnings("unchecked")
void notifyExternalChangesReplacesPreviousNotification(@TempDir Path tempDir) throws Exception {
diff --git a/jabgui/src/test/java/org/jabref/gui/groups/GroupTreeSharedDatabaseProfileTest.java b/jabgui/src/test/java/org/jabref/gui/groups/GroupTreeSharedDatabaseProfileTest.java
index db1bc933182f..c556fad54f4e 100644
--- a/jabgui/src/test/java/org/jabref/gui/groups/GroupTreeSharedDatabaseProfileTest.java
+++ b/jabgui/src/test/java/org/jabref/gui/groups/GroupTreeSharedDatabaseProfileTest.java
@@ -185,7 +185,7 @@ private GroupTreeViewModel createGroupTree(BibDatabaseContext context) throws In
GuiPreferences preferences = mock(GuiPreferences.class);
when(preferences.getLibraryPreferences()).thenReturn(new LibraryPreferences(
- context.getMode(), false, false, false, "Imported entries"));
+ context.getMode(), false, false, false, false, "Imported entries"));
when(preferences.getGroupsPreferences()).thenReturn(new GroupsPreferences(
EnumSet.noneOf(GroupViewMode.class), true, true, false,
GroupHierarchyType.INDEPENDENT, false));
diff --git a/jabgui/src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java
index 9a1846aea745..ee1d7a017ae9 100644
--- a/jabgui/src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java
+++ b/jabgui/src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java
@@ -97,6 +97,7 @@ void setUp() {
false,
false,
false,
+ false,
"Imported entries"
));
when(preferences.getGroupsPreferences()).thenReturn(new GroupsPreferences(
diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java
index a5f546823e0f..259fde5c19ba 100644
--- a/jablib/src/main/java/module-info.java
+++ b/jablib/src/main/java/module-info.java
@@ -39,6 +39,7 @@
exports org.jabref.logic.shared.notifications;
exports org.jabref.logic.util;
exports org.jabref.logic.util.io;
+ exports org.jabref.logic.sync;
exports org.jabref.logic.xmp;
exports org.jabref.model.database;
exports org.jabref.model.entry;
diff --git a/jablib/src/main/java/org/jabref/logic/LibraryPreferences.java b/jablib/src/main/java/org/jabref/logic/LibraryPreferences.java
index cde9bb1ebeae..8959b8503da6 100644
--- a/jablib/src/main/java/org/jabref/logic/LibraryPreferences.java
+++ b/jablib/src/main/java/org/jabref/logic/LibraryPreferences.java
@@ -15,17 +15,20 @@ public class LibraryPreferences {
private final ObjectProperty defaultBibDatabaseMode;
private final BooleanProperty alwaysReformatOnSave;
private final BooleanProperty autoSave;
+ private final BooleanProperty synchronizeWithFile;
private final BooleanProperty addImportedEntries;
private final StringProperty addImportedEntriesGroupName;
public LibraryPreferences(BibDatabaseMode defaultBibDatabaseMode,
boolean alwaysReformatOnSave,
boolean autoSave,
+ boolean synchronizeWithFile,
boolean addImportedEntries,
String addImportedEntriesGroupName) {
this.defaultBibDatabaseMode = new SimpleObjectProperty<>(defaultBibDatabaseMode);
this.alwaysReformatOnSave = new SimpleBooleanProperty(alwaysReformatOnSave);
this.autoSave = new SimpleBooleanProperty(autoSave);
+ this.synchronizeWithFile = new SimpleBooleanProperty(synchronizeWithFile);
this.addImportedEntries = new SimpleBooleanProperty(addImportedEntries);
this.addImportedEntriesGroupName = new SimpleStringProperty(addImportedEntriesGroupName);
}
@@ -35,6 +38,7 @@ private LibraryPreferences() {
BibDatabaseMode.BIBTEX,
false, // alwaysReformatOnSave
false, // autoSave
+ false, // synchronizeWithFile
false, // addImportedEntries
Localization.lang("Imported entries") // addImportedEntriesGroupName
);
@@ -80,6 +84,19 @@ public void setAutoSave(boolean shouldAutoSave) {
this.autoSave.set(shouldAutoSave);
}
+ /// The default for libraries that do not decide it themselves (see `MetaData.getSynchronizeWithFile`)
+ public boolean shouldSynchronizeWithFile() {
+ return synchronizeWithFile.get();
+ }
+
+ public BooleanProperty synchronizeWithFileProperty() {
+ return synchronizeWithFile;
+ }
+
+ public void setSynchronizeWithFile(boolean synchronizeWithFile) {
+ this.synchronizeWithFile.set(synchronizeWithFile);
+ }
+
public boolean shouldAddImportedEntries() {
return addImportedEntries.get();
}
diff --git a/jablib/src/main/java/org/jabref/logic/exporter/MetaDataSerializer.java b/jablib/src/main/java/org/jabref/logic/exporter/MetaDataSerializer.java
index fdc6a8910a36..2e0a888a58ff 100644
--- a/jablib/src/main/java/org/jabref/logic/exporter/MetaDataSerializer.java
+++ b/jablib/src/main/java/org/jabref/logic/exporter/MetaDataSerializer.java
@@ -45,6 +45,7 @@ public static Map getSerializedStringMap(MetaData metaData,
if (metaData.isProtected()) {
stringyMetaData.put(MetaData.PROTECTED_FLAG_META, List.of("true"));
}
+ metaData.getSynchronizeWithFile().ifPresent(synchronize -> stringyMetaData.put(MetaData.SYNCHRONIZE_WITH_FILE, List.of(String.valueOf(synchronize))));
if (metaData.isGitAutoPull()) {
stringyMetaData.put(MetaData.GIT_AUTO_PULL, List.of("true"));
}
diff --git a/jablib/src/main/java/org/jabref/logic/git/merge/BibFileMerger.java b/jablib/src/main/java/org/jabref/logic/git/merge/BibFileMerger.java
index 87036521e461..9710f5cd86c2 100644
--- a/jablib/src/main/java/org/jabref/logic/git/merge/BibFileMerger.java
+++ b/jablib/src/main/java/org/jabref/logic/git/merge/BibFileMerger.java
@@ -33,6 +33,9 @@
/// The result is written by JabRef's `.bib` writer, which reproduces only what the parser
/// understood. [MergePreconditions] therefore refuses the merge when writing would lose content,
/// and `current` is left untouched then.
+///
+/// A library open in JabRef whose file changes on disk is merged the same way, with an in-memory ancestor instead
+/// of a merge-base commit: see [org.jabref.logic.sync.LibraryBaseline].
@NullMarked
public final class BibFileMerger {
diff --git a/jablib/src/main/java/org/jabref/logic/importer/util/MetaDataParser.java b/jablib/src/main/java/org/jabref/logic/importer/util/MetaDataParser.java
index 1356e55f1b67..9da749fbc0a3 100644
--- a/jablib/src/main/java/org/jabref/logic/importer/util/MetaDataParser.java
+++ b/jablib/src/main/java/org/jabref/logic/importer/util/MetaDataParser.java
@@ -155,6 +155,8 @@ public MetaData parse(MetaData metaData, Map data, Character key
} else {
metaData.markAsNotProtected();
}
+ } else if (MetaData.SYNCHRONIZE_WITH_FILE.equals(entry.getKey())) {
+ metaData.setSynchronizeWithFile(Boolean.parseBoolean(getSingleItem(values)));
} else if (MetaData.GIT_AUTO_PULL.equals(entry.getKey())) {
metaData.setGitAutoPull(Boolean.parseBoolean(getSingleItem(values)));
} else if (MetaData.GIT_AUTO_COMMIT.equals(entry.getKey())) {
diff --git a/jablib/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java b/jablib/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java
index 534b0529f4f2..334e5a5117a9 100644
--- a/jablib/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java
+++ b/jablib/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java
@@ -160,6 +160,7 @@ public class JabRefCliPreferences implements CliPreferences {
public static final String LIBRARY_BIBLATEX_DEFAULT_MODE = "biblatexMode";
public static final String LIBRARY_REFORMAT_ON_SAVE_AND_EXPORT = "reformatFileOnSaveAndExport";
public static final String LIBRARY_AUTO_SAVE = "localAutoSave";
+ public static final String LIBRARY_SYNCHRONIZE_WITH_FILE = "synchronizeWithFile";
public static final String LIBRARY_ADD_IMPORTED_ENTRIES = "addImportedEntries";
public static final String LIBRARY_ADD_IMPORTED_ENTRIES_GROUP_NAME = "addImportedEntriesGroupName";
// endregion
@@ -1491,6 +1492,7 @@ public LibraryPreferences getLibraryPreferences() {
getBoolean(LIBRARY_BIBLATEX_DEFAULT_MODE, defaultValues.getDefaultBibDatabaseMode() == BibDatabaseMode.BIBLATEX) ? BibDatabaseMode.BIBLATEX : BibDatabaseMode.BIBTEX,
getBoolean(LIBRARY_REFORMAT_ON_SAVE_AND_EXPORT, defaultValues.shouldAlwaysReformatOnSave()),
getBoolean(LIBRARY_AUTO_SAVE, defaultValues.shouldAutoSave()),
+ getBoolean(LIBRARY_SYNCHRONIZE_WITH_FILE, defaultValues.shouldSynchronizeWithFile()),
getBoolean(LIBRARY_ADD_IMPORTED_ENTRIES, defaultValues.shouldAddImportedEntries()),
get(LIBRARY_ADD_IMPORTED_ENTRIES_GROUP_NAME, defaultValues.getAddImportedEntriesGroupName()));
@@ -1501,6 +1503,7 @@ public LibraryPreferences getLibraryPreferences() {
Map.entry(LIBRARY_BIBLATEX_DEFAULT_MODE, BibDatabaseMode.BIBLATEX));
bindBoolean(libraryPreferences.alwaysReformatOnSaveProperty(), LIBRARY_REFORMAT_ON_SAVE_AND_EXPORT, defaultValues.shouldAlwaysReformatOnSave());
bindBoolean(libraryPreferences.autoSaveProperty(), LIBRARY_AUTO_SAVE, defaultValues.shouldAutoSave());
+ bindBoolean(libraryPreferences.synchronizeWithFileProperty(), LIBRARY_SYNCHRONIZE_WITH_FILE, defaultValues.shouldSynchronizeWithFile());
bindBoolean(libraryPreferences.addImportedEntriesProperty(), LIBRARY_ADD_IMPORTED_ENTRIES, defaultValues.shouldAddImportedEntries());
bindString(libraryPreferences.addImportedEntriesGroupNameProperty(), LIBRARY_ADD_IMPORTED_ENTRIES_GROUP_NAME, defaultValues.getAddImportedEntriesGroupName());
diff --git a/jablib/src/main/java/org/jabref/logic/shared/DBMSSynchronizer.java b/jablib/src/main/java/org/jabref/logic/shared/DBMSSynchronizer.java
index 54c462320b47..ab0f5bea46af 100644
--- a/jablib/src/main/java/org/jabref/logic/shared/DBMSSynchronizer.java
+++ b/jablib/src/main/java/org/jabref/logic/shared/DBMSSynchronizer.java
@@ -92,6 +92,9 @@
/// value on, and says so, rather than overwriting the newer value
/// ([org.jabref.model.undo.BibChange#apply]); and the pulled change marks the library as needing a
/// save, because it arrives as [org.jabref.model.entry.event.EntriesEventSource#SHARED].
+///
+/// A local `.bib` file changed by another program is handled differently: by a three-way comparison with the
+/// library as it last matched the file, see [org.jabref.logic.sync.LibraryBaseline].
public class DBMSSynchronizer implements DatabaseSynchronizer {
private static final Logger LOGGER = LoggerFactory.getLogger(DBMSSynchronizer.class);
diff --git a/jablib/src/main/java/org/jabref/logic/sync/LibraryBaseline.java b/jablib/src/main/java/org/jabref/logic/sync/LibraryBaseline.java
new file mode 100644
index 000000000000..963262d1d9f9
--- /dev/null
+++ b/jablib/src/main/java/org/jabref/logic/sync/LibraryBaseline.java
@@ -0,0 +1,419 @@
+package org.jabref.logic.sync;
+
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+
+import org.jabref.logic.citationkeypattern.GlobalCitationKeyPatterns;
+import org.jabref.logic.exporter.MetaDataSerializer;
+import org.jabref.logic.git.merge.planning.util.ConflictRules;
+import org.jabref.logic.git.merge.planning.util.FieldPatchComputer;
+import org.jabref.model.database.BibDatabaseContext;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.BibtexString;
+import org.jabref.model.entry.field.Field;
+import org.jabref.model.entry.field.InternalField;
+import org.jabref.model.entry.types.EntryType;
+import org.jabref.model.groups.GroupTreeNode;
+import org.jabref.model.metadata.MetaData;
+
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/// The library as it last matched its file on disk: the common ancestor for a three-way comparison of the in-memory
+/// library with the file. For each item (entry, metadata, preamble, string) it tells which [Side] diverged, which
+/// decides whether an external change can be taken over silently or needs a review, and merges entries changed on
+/// both sides field by field with the Git merge rules.
+///
+/// Entries are keyed by the id of the in-memory entry they were taken from, so that a citation key change in memory
+/// does not break the association. The metadata is kept in its serialized form, which is the only way to snapshot it.
+///
+/// The Git merge ([org.jabref.logic.git.merge.BibFileMerger]) is the same three-way comparison with the same field
+/// rules, but takes its ancestor from the repository history: the merge-base commit
+/// ([org.jabref.logic.git.io.GitRevisionLocator#findMergeBase]), parsed into a library and matched by citation key.
+/// A library file outside a repository has no history, so this class remembers the ancestor itself, in memory, and
+/// renews it whenever library and file are known to match again: after loading, after saving, and after a review.
+/// The GUI maps its change objects onto it in `org.jabref.gui.collab.ChangeTriage`.
+@NullMarked
+public final class LibraryBaseline {
+
+ /// Which side diverged from the baseline. Only asked for items that differ between memory and disk, so at least
+ /// one side changed.
+ public enum Side {
+ /// Only the file changed: the change can be taken over without asking
+ DISK,
+ /// Only memory changed: not an external change at all, but an unsaved edit
+ MEMORY,
+ /// Both changed differently: needs a review
+ BOTH
+ }
+
+ private static final String ENCODING_KEY = "\0encoding";
+
+ /// What the comparison needs of an entry; a `BibEntry` copy would carry an event bus, caches, and properties per
+ /// entry, which for a large library adds up to far more than the bibliographic data itself.
+ /// The comments written before the entry in the file count as content: they are kept by the parser and written
+ /// back, so an external edit of them is an external change like any other.
+ private record EntrySnapshot(EntryType type, Map fields, String comments) {
+ static EntrySnapshot of(BibEntry entry) {
+ return new EntrySnapshot(entry.getType(), Map.copyOf(entry.getFieldMap()), entry.getUserComments());
+ }
+
+ /// Wraps the live field map without copying, for a comparison made right away
+ static EntrySnapshot view(BibEntry entry) {
+ return new EntrySnapshot(entry.getType(), entry.getFieldMap(), entry.getUserComments());
+ }
+
+ Optional citationKey() {
+ return Optional.ofNullable(fields.get(InternalField.KEY_FIELD));
+ }
+
+ /// The entry without its citation key, for finding an entry whose key changed
+ EntrySnapshot withoutKey() {
+ if (!fields.containsKey(InternalField.KEY_FIELD)) {
+ return this;
+ }
+ Map rest = new HashMap<>(fields);
+ rest.remove(InternalField.KEY_FIELD);
+ return new EntrySnapshot(type, rest, comments);
+ }
+
+ /// Only needed as the common ancestor of a field-level merge, which is the rare both-sides case
+ BibEntry toEntry() {
+ BibEntry entry = new BibEntry(type).withFields(fields);
+ entry.setCommentsBeforeEntry(comments);
+ return entry;
+ }
+ }
+
+ private final Map entriesById;
+ private final Map metaData;
+ private @Nullable String preamble;
+ private final Map strings;
+ private final GlobalCitationKeyPatterns citationKeyPatterns;
+
+ private LibraryBaseline(Map entriesById, Map metaData, @Nullable String preamble, Map strings, GlobalCitationKeyPatterns citationKeyPatterns) {
+ this.entriesById = entriesById;
+ this.metaData = metaData;
+ this.preamble = preamble;
+ this.strings = strings;
+ this.citationKeyPatterns = citationKeyPatterns;
+ }
+
+ public static LibraryBaseline of(BibDatabaseContext context, GlobalCitationKeyPatterns citationKeyPatterns) {
+ Map entries = new HashMap<>();
+ for (BibEntry entry : context.getDatabase().getEntries()) {
+ entries.put(entry.getId(), EntrySnapshot.of(entry));
+ }
+ Map strings = new HashMap<>();
+ for (BibtexString string : context.getDatabase().getStringValues()) {
+ strings.put(string.getName(), string.getContent());
+ }
+ return new LibraryBaseline(entries,
+ serialize(context.getMetaData(), citationKeyPatterns),
+ context.getDatabase().getPreamble().orElse(null),
+ strings,
+ citationKeyPatterns);
+ }
+
+ /// Lookup tables over the baseline entries for disk entries without in-memory counterpart. Built once per pass
+ /// over a set of changes, so that each lookup is O(1) instead of a scan over the library; the in-memory entries
+ /// must not change during the pass.
+ public final class Lookup {
+ private final Map>> byKey = new HashMap<>();
+ private final Map>> byContentExceptKey = new HashMap<>();
+ /// The baseline entries gone from memory, the same for every disk entry of a pass; collected on first use
+ private @Nullable List goneFromMemory;
+
+ private Lookup() {
+ for (Map.Entry entry : entriesById.entrySet()) {
+ entry.getValue().citationKey().ifPresent(key -> byKey.computeIfAbsent(key, _ -> new ArrayList<>()).add(entry));
+ byContentExceptKey.computeIfAbsent(entry.getValue().withoutKey(), _ -> new ArrayList<>()).add(entry);
+ }
+ }
+
+ /// The id of the in-memory entry the given disk entry was taken from: by citation key, or else by the
+ /// remaining content, which covers entries without a key as well as a key changed on disk. Among several
+ /// candidates (keys need not be unique, entries may be duplicated) the one with identical content wins;
+ /// when that leaves more than one, the entry is not associated at all rather than with the wrong one.
+ public Optional baseIdOf(BibEntry remote) {
+ return find(remote).map(Map.Entry::getKey);
+ }
+
+ /// A disk entry without in-memory counterpart is either new on disk, or was deleted in memory (and possibly
+ /// modified on disk as well). Only an entry whose baseline counterpart is gone from memory counts as deleted
+ /// there; a duplicate of an entry still in memory is an addition.
+ ///
+ /// @param existsInMemory whether the in-memory entry with the given id still exists
+ public Side sideOfAddedEntry(BibEntry remote, Predicate existsInMemory) {
+ Optional> base = find(remote).filter(found -> !existsInMemory.test(found.getKey()));
+ if (base.isPresent()) {
+ return base.get().getValue().equals(EntrySnapshot.view(remote)) ? Side.MEMORY : Side.BOTH;
+ }
+ // Neither key nor content match: an entry deleted in memory may still be the origin, with key and a
+ // field changed on disk; then the deletion and the change need a review
+ if (goneFromMemory == null) {
+ goneFromMemory = entriesById.keySet().stream().filter(id -> !existsInMemory.test(id)).toList();
+ }
+ return !goneFromMemory.isEmpty() && closestOf(goneFromMemory, remote).isPresent() ? Side.BOTH : Side.DISK;
+ }
+
+ /// Identical content under the same key is the entry itself; identical content under another key is the entry
+ /// renamed; only then does a same-key entry with other content count, and each only when unambiguous.
+ private Optional> find(BibEntry remote) {
+ EntrySnapshot snapshot = EntrySnapshot.view(remote);
+ List> byKeyCandidates = snapshot.citationKey().map(key -> byKey.getOrDefault(key, List.of())).orElse(List.of());
+ List> identicalWithKey = byKeyCandidates.stream().filter(entry -> entry.getValue().equals(snapshot)).toList();
+ if (!identicalWithKey.isEmpty()) {
+ return single(identicalWithKey);
+ }
+ List> sameContent = byContentExceptKey.getOrDefault(snapshot.withoutKey(), List.of());
+ if (!sameContent.isEmpty()) {
+ return single(sameContent);
+ }
+ return single(byKeyCandidates);
+ }
+
+ private static Optional> single(List> candidates) {
+ return candidates.size() == 1 ? Optional.of(candidates.getFirst()) : Optional.empty();
+ }
+ }
+
+ public Lookup lookup() {
+ return new Lookup();
+ }
+
+ public boolean hasEntry(String entryId) {
+ return entriesById.containsKey(entryId);
+ }
+
+ /// An entry present on both sides, whose baseline is looked up by the in-memory entry's id.
+ public Side sideOfEntry(BibEntry local, BibEntry remote) {
+ return sideOf(entriesById.get(local.getId()), EntrySnapshot.view(local), EntrySnapshot.view(remote));
+ }
+
+ /// An in-memory entry without counterpart on disk is either deleted on disk, or new in memory (and possibly
+ /// modified on disk as well).
+ public Side sideOfDeletedEntry(BibEntry local) {
+ EntrySnapshot base = entriesById.get(local.getId());
+ if (base == null) {
+ return Side.MEMORY;
+ }
+ return base.equals(EntrySnapshot.view(local)) ? Side.DISK : Side.BOTH;
+ }
+
+ /// Field-level merge of an entry modified on both sides: disk wins for every field memory left alone, memory wins
+ /// where disk left the field alone. Empty if the same field was changed differently on both sides.
+ public Optional mergeEntry(BibEntry local, BibEntry remote) {
+ EntrySnapshot base = entriesById.get(local.getId());
+ // Without a common ancestor (entry new on both sides), an empty entry of the in-memory type makes every field
+ // an addition, so only fields set differently on both sides count as conflicts; the in-memory type is kept
+ BibEntry ancestor = base == null ? new BibEntry(local.getType()) : base.toEntry();
+ boolean commentsChangedLocally = !ancestor.getUserComments().equals(local.getUserComments());
+ boolean commentsChangedRemotely = !ancestor.getUserComments().equals(remote.getUserComments());
+ boolean commentsConflict = commentsChangedLocally && commentsChangedRemotely && !local.getUserComments().equals(remote.getUserComments());
+ if (commentsConflict || ConflictRules.hasConflictingFields(ancestor, local, remote)) {
+ return Optional.empty();
+ }
+ BibEntry merged = new BibEntry(local);
+ if (base != null && !ancestor.getType().equals(remote.getType())) {
+ if (!ancestor.getType().equals(local.getType()) && !local.getType().equals(remote.getType())) {
+ return Optional.empty();
+ }
+ merged.setType(remote.getType());
+ }
+ if (!commentsChangedLocally) {
+ merged.setCommentsBeforeEntry(remote.getUserComments());
+ }
+ FieldPatchComputer.compute(ancestor, local, remote).forEach((field, value) -> {
+ if (value == null) {
+ merged.clearField(field);
+ } else {
+ merged.setField(field, value);
+ }
+ });
+ return Optional.of(merged);
+ }
+
+ public Side sideOfMetaData(MetaData local, MetaData remote) {
+ return sideOf(metaData, serialize(local, citationKeyPatterns), serialize(remote, citationKeyPatterns));
+ }
+
+ public Side sideOfPreamble(@Nullable String local, @Nullable String remote) {
+ return sideOf(preamble, local, remote);
+ }
+
+ /// @param local the content in memory, `null` when the string does not exist in memory
+ /// @param remote the content on disk, `null` when the string does not exist on disk
+ public Side sideOfString(String name, @Nullable String local, @Nullable String remote) {
+ return sideOf(strings.get(name), local, remote);
+ }
+
+ /// A string that exists on disk only. The two-way comparison cannot see that it is the renamed form of a baseline
+ /// string when memory deleted that string, so the content is matched against the baseline: a deletion in memory
+ /// against a rename on disk is a conflict.
+ ///
+ /// @param existsInMemory whether memory has a string of the given name
+ public Side sideOfAddedString(String name, String content, Predicate existsInMemory) {
+ if (strings.containsKey(name)) {
+ return sideOf(strings.get(name), null, content);
+ }
+ // Only an unambiguous source counts: with several equal-valued strings gone from memory, nothing says which one was renamed
+ long deletedWithSameContent = strings.entrySet().stream()
+ .filter(base -> base.getValue().equals(content) && !existsInMemory.test(base.getKey()))
+ .count();
+ return deletedWithSameContent == 1 ? Side.BOTH : Side.DISK;
+ }
+
+ /// A string renamed on disk is taken over only if memory neither touched the old string nor already uses the new name.
+ ///
+ /// @param existsInMemory whether memory has a string of the given name
+ public Side sideOfStringRename(String oldName, String oldContent, String newName, Predicate existsInMemory) {
+ boolean oldUntouchedInMemory = Objects.equals(strings.get(oldName), oldContent);
+ return oldUntouchedInMemory && !existsInMemory.test(newName) ? Side.DISK : Side.BOTH;
+ }
+
+ /// Among the given baseline entries, the one a disk entry most likely was taken from when neither key nor exact
+ /// content match anymore: same type and at most one field besides the citation key differing, and only when
+ /// exactly one candidate is that close.
+ public Optional closestOf(Collection candidateIds, BibEntry remote) {
+ EntrySnapshot snapshot = EntrySnapshot.view(remote).withoutKey();
+ List close = candidateIds.stream()
+ .filter(id -> entriesById.containsKey(id))
+ .filter(id -> differingFields(entriesById.get(id).withoutKey(), snapshot) <= 1)
+ .toList();
+ return close.size() == 1 ? Optional.of(close.getFirst()) : Optional.empty();
+ }
+
+ private static int differingFields(EntrySnapshot one, EntrySnapshot other) {
+ if (!one.type().equals(other.type())) {
+ return Integer.MAX_VALUE;
+ }
+ Set fields = new HashSet<>(one.fields().keySet());
+ fields.addAll(other.fields().keySet());
+ return (int) fields.stream().filter(field -> !Objects.equals(one.fields().get(field), other.fields().get(field))).count();
+ }
+
+ /// `null` stands for "absent" on that side.
+ private static Side sideOf(@Nullable T base, @Nullable T local, @Nullable T remote) {
+ boolean localChanged = !Objects.equals(base, local);
+ boolean remoteChanged = !Objects.equals(base, remote);
+ if (localChanged && remoteChanged) {
+ return Side.BOTH;
+ }
+ return remoteChanged ? Side.DISK : Side.MEMORY;
+ }
+
+ // Advancing the baseline after a scan or a review: an item whose disk version was applied, or judged and declined,
+ // has the disk version as its ancestor from then on. What memory still holds differently is an unsaved edit, not
+ // an external change.
+
+ /// A baseline to advance item by item, leaving this one as it is.
+ public LibraryBaseline copy() {
+ return new LibraryBaseline(new HashMap<>(entriesById), new HashMap<>(metaData), preamble, new HashMap<>(strings), citationKeyPatterns);
+ }
+
+ /// The disk version of the entry with the given in-memory id becomes its ancestor.
+ public void recordEntry(String entryId, BibEntry disk) {
+ entriesById.put(entryId, EntrySnapshot.of(disk));
+ }
+
+ /// The entry is gone from disk.
+ public void forgetEntry(String entryId) {
+ entriesById.remove(entryId);
+ }
+
+ /// The disk metadata becomes the ancestor, except for the groups, which are judged on their own.
+ public void recordMetaData(MetaData disk) {
+ record(serialize(disk, citationKeyPatterns), key -> !isGroupKey(key));
+ }
+
+ /// The given group tree, as found on disk, becomes the ancestor of the groups; `null` when disk has no groups.
+ public void recordGroups(@Nullable GroupTreeNode diskRoot) {
+ MetaData groupsOnly = new MetaData();
+ if (diskRoot != null) {
+ groupsOnly.setGroups(diskRoot);
+ }
+ record(serialize(groupsOnly, citationKeyPatterns), LibraryBaseline::isGroupKey);
+ }
+
+ private void record(Map serialized, Predicate keys) {
+ metaData.keySet().removeIf(keys);
+ serialized.forEach((key, value) -> {
+ if (keys.test(key)) {
+ metaData.put(key, value);
+ }
+ });
+ }
+
+ private static boolean isGroupKey(String key) {
+ return MetaData.GROUPSTREE.equals(key) || MetaData.GROUPSTREE_LEGACY.equals(key);
+ }
+
+ public void recordPreamble(@Nullable String disk) {
+ preamble = disk;
+ }
+
+ /// @param content the string's content on disk, `null` when the string does not exist on disk
+ public void recordString(String name, @Nullable String content) {
+ if (content == null) {
+ strings.remove(name);
+ } else {
+ strings.put(name, content);
+ }
+ }
+
+ // Carrying over items from a previous baseline: an external change that was not applied must be seen against the
+ // same ancestor next time, otherwise the current in-memory version would pass as the common ancestor.
+
+ public void keepEntry(LibraryBaseline previous, String entryId) {
+ EntrySnapshot base = previous.entriesById.get(entryId);
+ if (base == null) {
+ entriesById.remove(entryId);
+ } else {
+ entriesById.put(entryId, base);
+ }
+ }
+
+ /// Keeps the previous baseline of the in-memory entry a disk entry was taken from, if there is one.
+ public void keepEntryFor(LibraryBaseline previous, Lookup previousLookup, BibEntry remote) {
+ previousLookup.baseIdOf(remote).ifPresent(baseId -> entriesById.put(baseId, previous.entriesById.get(baseId)));
+ }
+
+ public void keepMetaData(LibraryBaseline previous) {
+ metaData.clear();
+ metaData.putAll(previous.metaData);
+ }
+
+ public void keepPreamble(LibraryBaseline previous) {
+ preamble = previous.preamble;
+ }
+
+ public void keepString(LibraryBaseline previous, String name) {
+ String base = previous.strings.get(name);
+ if (base == null) {
+ strings.remove(name);
+ } else {
+ strings.put(name, base);
+ }
+ }
+
+ /// The encoding is not part of the serialized metadata (it is written as a file header instead), but it is part
+ /// of what the two-way comparison reports.
+ private static Map serialize(MetaData metaData, GlobalCitationKeyPatterns citationKeyPatterns) {
+ Map serialized = new HashMap<>(MetaDataSerializer.getSerializedStringMap(metaData, citationKeyPatterns));
+ serialized.put(ENCODING_KEY, metaData.getEncoding().map(Charset::name).orElse(""));
+ // The synchronization setting itself is never synchronized: switching it on in memory must not be undone by
+ // the older value in the file, nor must the file switch it off under a running synchronization
+ serialized.remove(MetaData.SYNCHRONIZE_WITH_FILE);
+ return serialized;
+ }
+}
diff --git a/jablib/src/main/java/org/jabref/logic/sync/package-info.java b/jablib/src/main/java/org/jabref/logic/sync/package-info.java
new file mode 100644
index 000000000000..1ce1799d0b57
--- /dev/null
+++ b/jablib/src/main/java/org/jabref/logic/sync/package-info.java
@@ -0,0 +1,14 @@
+/// Keeping an in-memory library and its file the same in both directions without asking more than necessary.
+///
+/// [LibraryBaseline] is the common ancestor for the three-way comparison of the in-memory library with the file: for
+/// each item it tells which side diverged, which decides whether an external change is taken over silently or needs
+/// a review, and merges entries changed on both sides field by field with the rules of the Git merge
+/// (`org.jabref.logic.git.merge.planning.util`).
+///
+/// Detecting the changes themselves and presenting a review is the GUI's part (`org.jabref.gui.collab`).
+///
+/// See for the requirements this implements.
+@NullMarked
+package org.jabref.logic.sync;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/jablib/src/main/java/org/jabref/logic/undo/BibChangeDescriber.java b/jablib/src/main/java/org/jabref/logic/undo/BibChangeDescriber.java
index 2be4012bcbb0..e07f2a4b6035 100644
--- a/jablib/src/main/java/org/jabref/logic/undo/BibChangeDescriber.java
+++ b/jablib/src/main/java/org/jabref/logic/undo/BibChangeDescriber.java
@@ -5,6 +5,8 @@
import org.jabref.model.undo.BibChange;
import org.jabref.model.undo.ChangeSet;
import org.jabref.model.undo.UndoableChangeType;
+import org.jabref.model.undo.UndoableChangedFlag;
+import org.jabref.model.undo.UndoableCommentsChange;
import org.jabref.model.undo.UndoableFieldChange;
import org.jabref.model.undo.UndoableGroupTreeChange;
import org.jabref.model.undo.UndoableInsertEntries;
@@ -43,6 +45,10 @@ public static String describe(BibChange change) {
Localization.lang("Change field %0", FieldTextMapper.getDisplayName(fieldChange.field()));
case UndoableChangeType _ ->
Localization.lang("Change entry type");
+ case UndoableCommentsChange _ ->
+ Localization.lang("Change entry comments");
+ case UndoableChangedFlag _ ->
+ Localization.lang("Mark entry as changed");
case UndoableInsertEntries insert ->
insert.entries().size() == 1 ? Localization.lang("Insert entry") : Localization.lang("Insert entries");
case UndoableRemoveEntries remove ->
diff --git a/jablib/src/main/java/org/jabref/logic/undo/UndoManager.java b/jablib/src/main/java/org/jabref/logic/undo/UndoManager.java
index 20e625138591..e1a134de42f8 100644
--- a/jablib/src/main/java/org/jabref/logic/undo/UndoManager.java
+++ b/jablib/src/main/java/org/jabref/logic/undo/UndoManager.java
@@ -61,6 +61,9 @@ default ApplyResult applyEdit(BibChange change) {
/// so here, or the library looks saved. Saving is what clears it again.
void markChanged();
+ /// Whether there is an undoable step, i.e. whether the library was edited through the undo manager
+ boolean canUndo();
+
/// Suspends undo and redo for this library while the caller applies changes it has not yet
/// handed over. The undo UI reads the other end of this through `GuiUndoManager#suspendedBy`.
///
diff --git a/jablib/src/main/java/org/jabref/model/metadata/MetaData.java b/jablib/src/main/java/org/jabref/model/metadata/MetaData.java
index ae11e9ce3dee..91354a22a40b 100644
--- a/jablib/src/main/java/org/jabref/model/metadata/MetaData.java
+++ b/jablib/src/main/java/org/jabref/model/metadata/MetaData.java
@@ -65,6 +65,7 @@ public class MetaData {
public static final String BLG_FILE_PATH = "blgFilePath";
public static final String AI_LIBRARY_ID = "aiLibraryId";
+ public static final String SYNCHRONIZE_WITH_FILE = "synchronizeWithFile";
public static final String GIT_AUTO_PULL = "gitAutoPull";
public static final String GIT_AUTO_COMMIT = "gitAutoCommit";
public static final String GIT_AUTO_PUSH = "gitAutoPush";
@@ -85,6 +86,8 @@ public class MetaData {
@Nullable private FieldFormatterCleanupActions saveActions;
@Nullable private BibDatabaseMode mode;
@Nullable private AbbreviationType libraryAbbreviationType;
+ /// Absent when the library follows the global preference
+ @Nullable private Boolean synchronizeWithFile;
@Nullable private Character keywordSeparator;
private boolean isProtected;
@Nullable private String librarySpecificFileDirectory;
@@ -288,6 +291,28 @@ public void clearLibraryAbbreviationType() {
postChange();
}
+ /// Whether external changes of the library file are merged into the library without asking; absent if the
+ /// library does not decide this itself (then the global preference applies)
+ public Optional getSynchronizeWithFile() {
+ return Optional.ofNullable(synchronizeWithFile);
+ }
+
+ public void setSynchronizeWithFile(boolean synchronizeWithFile) {
+ if (Objects.equals(this.synchronizeWithFile, synchronizeWithFile)) {
+ return;
+ }
+ this.synchronizeWithFile = synchronizeWithFile;
+ postChange();
+ }
+
+ public void clearSynchronizeWithFile() {
+ if (this.synchronizeWithFile == null) {
+ return;
+ }
+ this.synchronizeWithFile = null;
+ postChange();
+ }
+
/// The separator used in this library's keyword fields; absent if the library does not declare one (then the global preference applies)
public Optional getKeywordSeparator() {
return Optional.ofNullable(keywordSeparator);
@@ -454,6 +479,7 @@ public void overwriteWith(@NonNull MetaData other, MetaDataChangeSource source)
saveActions = other.saveActions;
mode = other.mode;
libraryAbbreviationType = other.libraryAbbreviationType;
+ synchronizeWithFile = other.synchronizeWithFile;
keywordSeparator = other.keywordSeparator;
isProtected = other.isProtected;
librarySpecificFileDirectory = other.librarySpecificFileDirectory;
@@ -572,6 +598,7 @@ public boolean equals(Object o) {
&& Objects.equals(saveActions, that.saveActions)
&& (mode == that.mode)
&& (libraryAbbreviationType == that.libraryAbbreviationType)
+ && Objects.equals(synchronizeWithFile, that.synchronizeWithFile)
&& Objects.equals(keywordSeparator, that.keywordSeparator)
&& Objects.equals(librarySpecificFileDirectory, that.librarySpecificFileDirectory)
&& Objects.equals(contentSelectors, that.contentSelectors)
diff --git a/jablib/src/main/java/org/jabref/model/undo/BibChange.java b/jablib/src/main/java/org/jabref/model/undo/BibChange.java
index dc9cb28d44b5..82f24d5cac17 100644
--- a/jablib/src/main/java/org/jabref/model/undo/BibChange.java
+++ b/jablib/src/main/java/org/jabref/model/undo/BibChange.java
@@ -12,7 +12,7 @@
/// user acts in, as the name of the enclosing [ChangeSet].
@NullMarked
public sealed interface BibChange permits
- ChangeSet,
+ UndoableCommentsChange, UndoableChangedFlag, ChangeSet,
UndoableInsertEntries,
UndoableRemoveEntries,
UndoableChangeType,
diff --git a/jablib/src/main/java/org/jabref/model/undo/UndoableChangedFlag.java b/jablib/src/main/java/org/jabref/model/undo/UndoableChangedFlag.java
new file mode 100644
index 000000000000..bd3db447893f
--- /dev/null
+++ b/jablib/src/main/java/org/jabref/model/undo/UndoableChangedFlag.java
@@ -0,0 +1,68 @@
+package org.jabref.model.undo;
+
+import java.util.Map;
+import java.util.Objects;
+
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.field.Field;
+import org.jabref.model.entry.types.EntryType;
+
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/// Records an entry's "changed since parsing" flag around an in-place edit, so that undoing the edit also restores
+/// whether the entry's parsed serialization may be reused on save.
+///
+/// The mark is only taken back when the entry holds again what it held while unmarked: an edit made meanwhile, which
+/// the undo of the in-place edit refuses to take back, must not disappear behind a reused serialization on save.
+///
+/// @param unmarkedContent what the entry held while unmarked; `null` when it was marked already, as a mark is always safe to restore
+@NullMarked
+public record UndoableChangedFlag(BibEntry entry, boolean before, boolean after, @Nullable Content unmarkedContent) implements BibChange {
+
+ public record Content(EntryType type, Map fields, String comments) {
+ static Content of(BibEntry entry) {
+ return new Content(entry.getType(), Map.copyOf(entry.getFieldMap()), entry.getUserComments());
+ }
+
+ boolean matches(BibEntry entry) {
+ return type.equals(entry.getType()) && fields.equals(entry.getFieldMap()) && comments.equals(entry.getUserComments());
+ }
+ }
+
+ /// Marks the entry as changed, remembering its content as long as it was unmarked.
+ public static UndoableChangedFlag marking(BibEntry entry) {
+ boolean before = entry.hasChanged();
+ return new UndoableChangedFlag(entry, before, true, before ? null : Content.of(entry));
+ }
+
+ @Override
+ public UndoableChangedFlag inverted() {
+ return new UndoableChangedFlag(entry, after, before, unmarkedContent);
+ }
+
+ @Override
+ public ApplyResult apply() {
+ if (entry.hasChanged() != before) {
+ return ApplyResult.of(this, "entry is " + (entry.hasChanged() ? "" : "not ") + "marked changed, unlike recorded");
+ }
+ if (!after && unmarkedContent != null && !unmarkedContent.matches(entry)) {
+ return ApplyResult.of(this, "entry no longer holds what it held while unmarked");
+ }
+ entry.setChanged(after);
+ return ApplyResult.SUCCESS;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ return (object instanceof UndoableChangedFlag other)
+ && ChangeIdentity.same(entry, other.entry)
+ && before == other.before
+ && after == other.after;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(ChangeIdentity.hash(entry), before, after);
+ }
+}
diff --git a/jablib/src/main/java/org/jabref/model/undo/UndoableCommentsChange.java b/jablib/src/main/java/org/jabref/model/undo/UndoableCommentsChange.java
new file mode 100644
index 000000000000..9c97e22cbb40
--- /dev/null
+++ b/jablib/src/main/java/org/jabref/model/undo/UndoableCommentsChange.java
@@ -0,0 +1,40 @@
+package org.jabref.model.undo;
+
+import java.util.Objects;
+
+import org.jabref.model.entry.BibEntry;
+
+import org.jspecify.annotations.NullMarked;
+
+/// A change of the comments written before an entry in the library file.
+@NullMarked
+public record UndoableCommentsChange(BibEntry entry, String before, String after) implements BibChange {
+ @Override
+ public UndoableCommentsChange inverted() {
+ return new UndoableCommentsChange(entry, after, before);
+ }
+
+ @Override
+ public ApplyResult apply() {
+ if (!entry.getUserComments().equals(before)) {
+ return ApplyResult.of(this, "entry comments are not the recorded ones");
+ }
+ entry.setCommentsBeforeEntry(after);
+ // The setter is the parser's and leaves the flag alone; here the comment differs from what was parsed
+ entry.setChanged(true);
+ return ApplyResult.SUCCESS;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ return (object instanceof UndoableCommentsChange other)
+ && ChangeIdentity.same(entry, other.entry)
+ && before.equals(other.before)
+ && after.equals(other.after);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(ChangeIdentity.hash(entry), before, after);
+ }
+}
diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties
index 6e12f80122c8..4770990a99f6 100644
--- a/jablib/src/main/resources/l10n/JabRef_en.properties
+++ b/jablib/src/main/resources/l10n/JabRef_en.properties
@@ -103,6 +103,8 @@ Case\ sensitive=Case sensitive
Change\ case=Change case
Change\ entry\ type=Change entry type
+Change\ entry\ comments=Change entry comments
+Mark\ entry\ as\ changed=Mark entry as changed
Changed\ language\ to\ %0=Changed language to %0
@@ -736,6 +738,12 @@ The\ password\ of\ the\ removed\ connection\ is\ still\ stored\ in\ the\ credent
Saved\ connections=Saved connections
Saved\ selected\ to\ '%0'.=Saved selected to '%0'.
Autosave\ local\ libraries=Autosave local libraries
+Automatically\ merge\ external\ changes\ (libraries\ can\ override\ this)=Automatically merge external changes (libraries can override this)
+External\ changes=External changes
+Automatically\ merge\ external\ changes=Automatically merge external changes
+Use\ global\ setting\ (%0)=Use global setting (%0)
+On=On
+Off=Off
Enable\ MSC\ keyword\ descriptions=Enable MSC keyword descriptions
Automatically\ save\ the\ library\ to=Automatically save the library to
Please\ enter\ a\ valid\ file\ path.=Please enter a valid file path.
@@ -3605,6 +3613,7 @@ Group\ entry\ counts=Group entry counts
# Notifications
Background\ task=Background task
External\ changes\ detected=External changes detected
+Merged\ %0\ change(s)\ from\ the\ library\ file=Merged %0 change(s) from the library file
Files=Files
Info=Info
Reveal=Reveal
diff --git a/jablib/src/test/java/org/jabref/logic/importer/fileformat/BibtexParserTest.java b/jablib/src/test/java/org/jabref/logic/importer/fileformat/BibtexParserTest.java
index 528d6fdd082a..d79c9c3dee69 100644
--- a/jablib/src/test/java/org/jabref/logic/importer/fileformat/BibtexParserTest.java
+++ b/jablib/src/test/java/org/jabref/logic/importer/fileformat/BibtexParserTest.java
@@ -2160,6 +2160,14 @@ void integrationTestProtectedFlag() throws IOException {
assertTrue(result.getMetaData().isProtected());
}
+ @Test
+ void integrationTestSynchronizeWithFile() throws IOException {
+ ParserResult result = parser
+ .parse(Reader.of("@comment{jabref-meta: synchronizeWithFile:false;}"));
+
+ assertEquals(Optional.of(false), result.getMetaData().getSynchronizeWithFile());
+ }
+
@Test
void integrationTestGitAutoPull() throws IOException {
ParserResult result = parser
diff --git a/jablib/src/test/java/org/jabref/logic/sync/LibraryBaselineTest.java b/jablib/src/test/java/org/jabref/logic/sync/LibraryBaselineTest.java
new file mode 100644
index 000000000000..a5d1a5aa5d2e
--- /dev/null
+++ b/jablib/src/test/java/org/jabref/logic/sync/LibraryBaselineTest.java
@@ -0,0 +1,265 @@
+package org.jabref.logic.sync;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.jabref.logic.citationkeypattern.GlobalCitationKeyPatterns;
+import org.jabref.logic.sync.LibraryBaseline.Side;
+import org.jabref.model.database.BibDatabase;
+import org.jabref.model.database.BibDatabaseContext;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.BibtexString;
+import org.jabref.model.entry.field.StandardField;
+import org.jabref.model.entry.types.StandardEntryType;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class LibraryBaselineTest {
+
+ private static final GlobalCitationKeyPatterns PATTERNS = GlobalCitationKeyPatterns.fromPattern("[auth][year]");
+
+ private BibEntry local;
+ private BibEntry remote;
+ private LibraryBaseline baseline;
+
+ @BeforeEach
+ void setUp() {
+ local = new BibEntry(StandardEntryType.Article).withCitationKey("Key").withField(StandardField.TITLE, "Title").withField(StandardField.YEAR, "2020");
+ baseline = LibraryBaseline.of(new BibDatabaseContext(new BibDatabase(List.of(local))), PATTERNS);
+ // the file as it would be parsed: same content, different entry object
+ remote = new BibEntry(local);
+ }
+
+ @Test
+ void entryChangedOnDiskOnly() {
+ remote.setField(StandardField.TITLE, "Disk title");
+
+ assertEquals(Side.DISK, baseline.sideOfEntry(local, remote));
+ }
+
+ @Test
+ void entryChangedInMemoryOnly() {
+ local.setField(StandardField.TITLE, "Memory title");
+
+ assertEquals(Side.MEMORY, baseline.sideOfEntry(local, remote));
+ }
+
+ @Test
+ void entryChangedOnBothSides() {
+ local.setField(StandardField.TITLE, "Memory title");
+ remote.setField(StandardField.TITLE, "Disk title");
+
+ assertEquals(Side.BOTH, baseline.sideOfEntry(local, remote));
+ }
+
+ @Test
+ void differentFieldsChangedOnBothSidesAreMerged() {
+ local.setField(StandardField.TITLE, "Memory title");
+ remote.setField(StandardField.YEAR, "2021");
+
+ Optional merged = baseline.mergeEntry(local, remote);
+
+ assertEquals(Optional.of(new BibEntry(StandardEntryType.Article).withCitationKey("Key").withField(StandardField.TITLE, "Memory title").withField(StandardField.YEAR, "2021")), merged);
+ }
+
+ @Test
+ void sameFieldChangedDifferentlyIsNotMerged() {
+ local.setField(StandardField.TITLE, "Memory title");
+ remote.setField(StandardField.TITLE, "Disk title");
+
+ assertEquals(Optional.empty(), baseline.mergeEntry(local, remote));
+ }
+
+ @Test
+ void entryDeletedOnDiskWhenUntouchedInMemory() {
+ assertEquals(Side.DISK, baseline.sideOfDeletedEntry(local));
+ }
+
+ @Test
+ void entryUnknownToBaselineWasAddedInMemory() {
+ BibEntry added = new BibEntry(StandardEntryType.Misc).withCitationKey("New");
+
+ assertEquals(Side.MEMORY, baseline.sideOfDeletedEntry(added));
+ }
+
+ @Test
+ void addedEntryUnknownToBaselineIsNewOnDisk() {
+ BibEntry added = new BibEntry(StandardEntryType.Misc).withCitationKey("New");
+
+ assertEquals(Side.DISK, baseline.lookup().sideOfAddedEntry(added, _ -> true));
+ }
+
+ @Test
+ void addedEntryEqualToBaselineWasDeletedInMemory() {
+ assertEquals(Side.MEMORY, baseline.lookup().sideOfAddedEntry(remote, _ -> false));
+ }
+
+ @Test
+ void renamedEntryIsFoundByContent() {
+ remote.setCitationKey("Renamed");
+
+ assertEquals(Optional.of(local.getId()), baseline.lookup().baseIdOf(remote));
+ }
+
+ @Test
+ void duplicateKeysPreferIdenticalContent() {
+ BibEntry duplicate = new BibEntry(StandardEntryType.Article).withCitationKey("Key").withField(StandardField.TITLE, "Duplicate");
+ baseline = LibraryBaseline.of(new BibDatabaseContext(new BibDatabase(List.of(duplicate, local))), PATTERNS);
+
+ assertEquals(Optional.of(local.getId()), baseline.lookup().baseIdOf(remote));
+ }
+
+ @Test
+ void stringAddedOnDiskOnly() {
+ assertEquals(Side.DISK, baseline.sideOfString("jan", null, "January"));
+ }
+
+ @Test
+ void keptEntryStaysTheAncestorOfTheNextComparison() {
+ local.setField(StandardField.TITLE, "Memory title");
+ LibraryBaseline updated = LibraryBaseline.of(new BibDatabaseContext(new BibDatabase(List.of(local))), PATTERNS);
+ updated.keepEntry(baseline, local.getId());
+
+ // Against the kept ancestor, the memory edit is still visible as such
+ assertEquals(Side.MEMORY, updated.sideOfEntry(local, remote));
+ }
+
+ @Test
+ void commentChangedOnDiskOnly() {
+ remote.setCommentsBeforeEntry("% new comment");
+
+ assertEquals(Side.DISK, baseline.sideOfEntry(local, remote));
+ }
+
+ @Test
+ void commentChangedOnDiskIsMergedWithFieldChangedInMemory() {
+ local.setField(StandardField.TITLE, "Memory title");
+ remote.setCommentsBeforeEntry("% new comment");
+
+ Optional merged = baseline.mergeEntry(local, remote);
+
+ assertEquals(Optional.of("% new comment"), merged.map(BibEntry::getUserComments));
+ assertEquals(Optional.of("Memory title"), merged.flatMap(entry -> entry.getField(StandardField.TITLE)));
+ }
+
+ @Test
+ void ambiguousDuplicatesAreNotAssociated() {
+ BibEntry duplicate = new BibEntry(local);
+ baseline = LibraryBaseline.of(new BibDatabaseContext(new BibDatabase(List.of(duplicate, local))), PATTERNS);
+ remote.setField(StandardField.TITLE, "Changed on disk");
+
+ assertEquals(Optional.empty(), baseline.lookup().baseIdOf(remote));
+ }
+
+ @Test
+ void stringDeletedInMemoryAndRenamedOnDiskIsAConflict() {
+ BibDatabase database = new BibDatabase(List.of(local));
+ database.addString(new BibtexString("jan", "January"));
+ baseline = LibraryBaseline.of(new BibDatabaseContext(database), PATTERNS);
+
+ // memory has no string anymore, disk has the same content under a new name
+ assertEquals(Side.BOTH, baseline.sideOfAddedString("january", "January", _ -> false));
+ assertEquals(Side.DISK, baseline.sideOfAddedString("feb", "February", _ -> false));
+ }
+
+ @Test
+ void commentsChangedDifferentlyOnBothSidesAreAConflict() {
+ local.setCommentsBeforeEntry("% memory");
+ remote.setCommentsBeforeEntry("% disk");
+
+ assertEquals(Optional.empty(), baseline.mergeEntry(local, remote));
+ }
+
+ @Test
+ void stringRenamedOnDiskToANameTakenInMemoryIsAConflict() {
+ BibDatabase database = new BibDatabase(List.of(local));
+ database.addString(new BibtexString("jan", "January"));
+ baseline = LibraryBaseline.of(new BibDatabaseContext(database), PATTERNS);
+
+ assertEquals(Side.DISK, baseline.sideOfStringRename("jan", "January", "january", _ -> false));
+ assertEquals(Side.BOTH, baseline.sideOfStringRename("jan", "January", "january", "january"::equals));
+ }
+
+ @Test
+ void ambiguousRenameSourceIsNotAConflict() {
+ BibDatabase database = new BibDatabase(List.of(local));
+ database.addString(new BibtexString("jan", "January"));
+ database.addString(new BibtexString("januar", "January"));
+ baseline = LibraryBaseline.of(new BibDatabaseContext(database), PATTERNS);
+
+ // both equal-valued strings are gone from memory: nothing says which one the disk string was renamed from
+ assertEquals(Side.DISK, baseline.sideOfAddedString("january", "January", _ -> false));
+ }
+
+ @Test
+ void closestEntryIsFoundWhenKeyAndOneFieldChanged() {
+ BibEntry other = new BibEntry(StandardEntryType.Book).withCitationKey("Other").withField(StandardField.TITLE, "Other");
+ baseline = LibraryBaseline.of(new BibDatabaseContext(new BibDatabase(List.of(local, other))), PATTERNS);
+ remote.setCitationKey("Renamed");
+ remote.setField(StandardField.YEAR, "2021");
+
+ assertEquals(Optional.of(local.getId()), baseline.closestOf(List.of(local.getId(), other.getId()), remote));
+ }
+
+ @Test
+ void closestEntryIsNotGuessedWhenTwoFieldsChanged() {
+ remote.setCitationKey("Renamed");
+ remote.setField(StandardField.YEAR, "2021");
+ remote.setField(StandardField.TITLE, "Another title");
+
+ assertEquals(Optional.empty(), baseline.closestOf(List.of(local.getId()), remote));
+ }
+
+ @Test
+ void duplicateAddedOnDiskWhileTheOriginalStaysInMemoryIsAnAddition() {
+ assertEquals(Side.DISK, baseline.lookup().sideOfAddedEntry(remote, _ -> true));
+ }
+
+ @Test
+ void entryWithoutAncestorKeepsTheLocalType() {
+ BibEntry newLocal = new BibEntry(StandardEntryType.Article).withCitationKey("New").withField(StandardField.TITLE, "New");
+ BibEntry newRemote = new BibEntry(StandardEntryType.Misc).withCitationKey("New").withField(StandardField.TITLE, "New").withField(StandardField.YEAR, "2022");
+
+ Optional merged = baseline.mergeEntry(newLocal, newRemote);
+
+ assertEquals(Optional.of(StandardEntryType.Article), merged.map(BibEntry::getType));
+ assertEquals(Optional.of("2022"), merged.flatMap(entry -> entry.getField(StandardField.YEAR)));
+ }
+
+ @Test
+ void typeChangedOnDiskOnlyIsTakenOver() {
+ local.setField(StandardField.TITLE, "Memory title");
+ remote.setType(StandardEntryType.Book);
+
+ assertEquals(Optional.of(StandardEntryType.Book), baseline.mergeEntry(local, remote).map(BibEntry::getType));
+ }
+
+ @Test
+ void typeChangedDifferentlyOnBothSidesIsAConflict() {
+ local.setType(StandardEntryType.Misc);
+ remote.setType(StandardEntryType.Book);
+
+ assertEquals(Optional.empty(), baseline.mergeEntry(local, remote));
+ }
+
+ @Test
+ void entryDeletedInMemoryAndRenamedWithAChangeOnDiskIsAConflict() {
+ remote.setCitationKey("Renamed");
+ remote.setField(StandardField.YEAR, "2021");
+
+ assertEquals(Side.BOTH, baseline.lookup().sideOfAddedEntry(remote, _ -> false));
+ assertEquals(Side.DISK, baseline.lookup().sideOfAddedEntry(remote, _ -> true));
+ }
+
+ @Test
+ void entryRenamedToAnotherEntryKeyIsFoundByContent() {
+ BibEntry other = new BibEntry(StandardEntryType.Book).withCitationKey("Other").withField(StandardField.TITLE, "Other");
+ baseline = LibraryBaseline.of(new BibDatabaseContext(new BibDatabase(List.of(local, other))), PATTERNS);
+ remote.setCitationKey("Other");
+
+ assertEquals(Optional.of(local.getId()), baseline.lookup().baseIdOf(remote));
+ }
+}
diff --git a/jablib/src/test/java/org/jabref/model/metadata/MetaDataOverwriteWithTest.java b/jablib/src/test/java/org/jabref/model/metadata/MetaDataOverwriteWithTest.java
index 42124b7b9d4e..ef3c15312bba 100644
--- a/jablib/src/test/java/org/jabref/model/metadata/MetaDataOverwriteWithTest.java
+++ b/jablib/src/test/java/org/jabref/model/metadata/MetaDataOverwriteWithTest.java
@@ -85,6 +85,7 @@ private static MetaData populated() {
metaData.setSaveActions(new FieldFormatterCleanupActions(true, List.of()));
metaData.setMode(BibDatabaseMode.BIBLATEX);
metaData.setLibraryAbbreviationType(AbbreviationType.DOTLESS);
+ metaData.setSynchronizeWithFile(true);
metaData.setKeywordSeparator(';');
metaData.addContentSelector(new ContentSelector(StandardField.AUTHOR, "Einstein"));
metaData.setLibrarySpecificFileDirectory("/tmp/library");
diff --git a/jablib/src/test/java/org/jabref/model/undo/BibChangeTest.java b/jablib/src/test/java/org/jabref/model/undo/BibChangeTest.java
index 1159e4b400e7..ba36a5ab2bac 100644
--- a/jablib/src/test/java/org/jabref/model/undo/BibChangeTest.java
+++ b/jablib/src/test/java/org/jabref/model/undo/BibChangeTest.java
@@ -46,6 +46,7 @@ static Stream changes() {
new UndoableFieldChange(entry, StandardField.YEAR, null, "1905"),
new UndoableFieldChange(entry, StandardField.YEAR, "1905", null),
new UndoableChangeType(entry, StandardEntryType.Article, StandardEntryType.Book),
+ UndoableChangedFlag.marking(entry),
new UndoableInsertEntries(database, entry),
new UndoableRemoveEntries(database, entry),
new UndoablePreambleChange(database, null, "preamble"),
@@ -441,4 +442,31 @@ void changesAgainstDistinctEntriesWithEqualContentAreNotEqual() {
assertNotEquals(onFirst, onSecond);
}
+
+ @Test
+ void theUnchangedMarkIsNotRestoredOverALaterEdit() {
+ BibEntry entry = entry();
+ entry.setChanged(false);
+ UndoableChangedFlag marking = UndoableChangedFlag.marking(entry);
+ assertTrue(marking.apply().complete());
+ entry.setField(StandardField.TITLE, "Edited later");
+
+ ApplyResult result = marking.inverted().apply();
+
+ assertFalse(result.complete());
+ assertTrue(entry.hasChanged());
+ }
+
+ @Test
+ void theUnchangedMarkIsRestoredWithTheContent() {
+ BibEntry entry = entry();
+ entry.setChanged(false);
+ UndoableChangedFlag marking = UndoableChangedFlag.marking(entry);
+ marking.apply();
+ entry.setField(StandardField.TITLE, "Edited later");
+ entry.setField(StandardField.TITLE, "Relativity");
+
+ assertTrue(marking.inverted().apply().complete());
+ assertFalse(entry.hasChanged());
+ }
}