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 @@