package org.jabref.collab;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import javax.swing.tree.DefaultMutableTreeNode;
import org.jabref.Globals;
import org.jabref.JabRefExecutorService;
import org.jabref.gui.BasePanel;
import org.jabref.gui.JabRefFrame;
import org.jabref.logic.bibtex.DuplicateCheck;
import org.jabref.logic.bibtex.comparator.EntryComparator;
import org.jabref.logic.exporter.BibDatabaseWriter;
import org.jabref.logic.exporter.BibtexDatabaseWriter;
import org.jabref.logic.exporter.FileSaveSession;
import org.jabref.logic.exporter.SaveException;
import org.jabref.logic.exporter.SavePreferences;
import org.jabref.logic.exporter.SaveSession;
import org.jabref.logic.importer.ImportFormatPreferences;
import org.jabref.logic.importer.OpenDatabase;
import org.jabref.logic.importer.ParserResult;
import org.jabref.logic.l10n.Localization;
import org.jabref.model.Defaults;
import org.jabref.model.database.BibDatabase;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.database.EntrySorter;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.BibtexString;
import org.jabref.model.entry.FieldName;
import org.jabref.model.groups.GroupTreeNode;
import org.jabref.model.metadata.MetaData;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
public class ChangeScanner implements Runnable {
private static final Log LOGGER = LogFactory.getLog(ChangeScanner.class);
private static final String[] SORT_BY = new String[] {FieldName.YEAR, FieldName.AUTHOR, FieldName.TITLE};
private static final double MATCH_THRESHOLD = 0.4;
private final File file;
private final BibDatabase databaseInMemory;
private final MetaData metadataInMemory;
private final BasePanel panel;
private final JabRefFrame frame;
private BibDatabase databaseInTemp;
private MetaData metadataInTemp;
/**
* We create an ArrayList to hold the changes we find. These will be added in the form
* of UndoEdit objects. We instantiate these so that the changes found in the file on disk
* can be reproduced in memory by calling redo() on them. REDO, not UNDO!
*/
private final DefaultMutableTreeNode changes = new DefaultMutableTreeNode(Localization.lang("External changes"));
// NamedCompound edit = new NamedCompound("Merged external changes")
public ChangeScanner(JabRefFrame frame, BasePanel bp, File file) {
this.panel = bp;
this.frame = frame;
this.databaseInMemory = bp.getDatabase();
this.metadataInMemory = bp.getBibDatabaseContext().getMetaData();
this.file = file;
}
@Override
public void run() {
try {
// Parse the temporary file.
Path tempFile = Globals.getFileUpdateMonitor().getTempFile(panel.fileMonitorHandle());
ImportFormatPreferences importFormatPreferences = Globals.prefs.getImportFormatPreferences();
ParserResult result = OpenDatabase.loadDatabase(tempFile.toFile(), importFormatPreferences);
databaseInTemp = result.getDatabase();
metadataInTemp = result.getMetaData();
// Parse the modified file.
result = OpenDatabase.loadDatabase(file, importFormatPreferences);
BibDatabase databaseOnDisk = result.getDatabase();
MetaData metadataOnDisk = result.getMetaData();
// Sort both databases according to a common sort key.
EntryComparator comparator = new EntryComparator(false, true, SORT_BY[2]);
comparator = new EntryComparator(false, true, SORT_BY[1], comparator);
comparator = new EntryComparator(false, true, SORT_BY[0], comparator);
EntrySorter sorterInTemp = databaseInTemp.getSorter(comparator);
comparator = new EntryComparator(false, true, SORT_BY[2]);
comparator = new EntryComparator(false, true, SORT_BY[1], comparator);
comparator = new EntryComparator(false, true, SORT_BY[0], comparator);
EntrySorter sorterOnDisk = databaseOnDisk.getSorter(comparator);
comparator = new EntryComparator(false, true, SORT_BY[2]);
comparator = new EntryComparator(false, true, SORT_BY[1], comparator);
comparator = new EntryComparator(false, true, SORT_BY[0], comparator);
EntrySorter sorterInMem = databaseInMemory.getSorter(comparator);
// Start looking at changes.
scanMetaData(metadataInMemory, metadataInTemp, metadataOnDisk);
scanPreamble(databaseInMemory, databaseInTemp, databaseOnDisk);
scanStrings(databaseInMemory, databaseInTemp, databaseOnDisk);
scanEntries(sorterInMem, sorterInTemp, sorterOnDisk);
scanGroups(metadataInTemp, metadataOnDisk);
} catch (IOException ex) {
LOGGER.warn("Problem running", ex);
}
}
public boolean changesFound() {
return changes.getChildCount() > 0;
}
public void displayResult(final DisplayResultCallback fup) {
if (changes.getChildCount() > 0) {
SwingUtilities.invokeLater(() -> {
ChangeDisplayDialog changeDialog = new ChangeDisplayDialog(frame, panel, databaseInTemp, changes);
changeDialog.setLocationRelativeTo(frame);
changeDialog.setVisible(true);
fup.scanResultsResolved(changeDialog.isOkPressed());
if (changeDialog.isOkPressed()) {
// Overwrite the temp database:
storeTempDatabase();
}
});
} else {
JOptionPane.showMessageDialog(frame, Localization.lang("No actual changes found."),
Localization.lang("External changes"), JOptionPane.INFORMATION_MESSAGE);
fup.scanResultsResolved(true);
}
}
private void storeTempDatabase() {
JabRefExecutorService.INSTANCE.execute(() -> {
try {
SavePreferences prefs = SavePreferences.loadForSaveFromPreferences(Globals.prefs).withMakeBackup(false)
.withEncoding(panel.getBibDatabaseContext().getMetaData().getEncoding()
.orElse(Globals.prefs.getDefaultEncoding()));
Defaults defaults = new Defaults(Globals.prefs.getDefaultBibDatabaseMode());
BibDatabaseWriter<SaveSession> databaseWriter = new BibtexDatabaseWriter<>(FileSaveSession::new);
SaveSession ss = databaseWriter.saveDatabase(new BibDatabaseContext(databaseInTemp, metadataInTemp, defaults), prefs);
ss.commit(Globals.getFileUpdateMonitor().getTempFile(panel.fileMonitorHandle()));
} catch (SaveException ex) {
LOGGER.warn("Problem updating tmp file after accepting external changes", ex);
}
});
}
private void scanMetaData(MetaData inMemory, MetaData onTmp, MetaData onDisk) {
if (!onTmp.isEmpty()) {
if (!inMemory.equals(onDisk)) {
changes.add(new MetaDataChange(inMemory, onDisk));
}
} else {
if (!onDisk.isEmpty() || !onTmp.equals(onDisk)) {
changes.add(new MetaDataChange(inMemory, onDisk));
}
}
}
private void scanEntries(EntrySorter memorySorter, EntrySorter tmpSorter, EntrySorter diskSorter) {
// Create pointers that are incremented as the entries of each base are used in
// successive order from the beginning. Entries "further down" in the "disk" base
// can also be matched.
int piv1;
int piv2 = 0;
// Create a HashSet where we can put references to entry numbers in the "disk"
// database that we have matched. This is to avoid matching them twice.
Set<String> used = new HashSet<>(diskSorter.getEntryCount());
Set<Integer> notMatched = new HashSet<>(tmpSorter.getEntryCount());
// Loop through the entries of the "tmp" database, looking for exact matches in the "disk" one.
// We must finish scanning for exact matches before looking for near matches, to avoid an exact
// match being "stolen" from another entry.
mainLoop:
for (piv1 = 0; piv1 < tmpSorter.getEntryCount(); piv1++) {
// First check if the similarly placed entry in the other base matches exactly.
double comp = -1;
// (if there are not any entries left in the "disk" database, comp will stay at -1,
// and this entry will be marked as nonmatched).
if (!used.contains(String.valueOf(piv2)) && (piv2 < diskSorter.getEntryCount())) {
comp = DuplicateCheck.compareEntriesStrictly(tmpSorter.getEntryAt(piv1), diskSorter.getEntryAt(piv2));
}
if (comp > 1) {
used.add(String.valueOf(piv2));
piv2++;
continue;
}
// No? Then check if another entry matches exactly.
if (piv2 < (diskSorter.getEntryCount() - 1)) {
for (int i = piv2 + 1; i < diskSorter.getEntryCount(); i++) {
if (used.contains(String.valueOf(i))) {
comp = -1;
} else {
comp = DuplicateCheck.compareEntriesStrictly(tmpSorter.getEntryAt(piv1), diskSorter.getEntryAt(i));
}
if (comp > 1) {
used.add(String.valueOf(i));
continue mainLoop;
}
}
}
// No? Add this entry to the list of nonmatched entries.
notMatched.add(piv1);
}
// Now we've found all exact matches, look through the remaining entries, looking
// for close matches.
if (!notMatched.isEmpty()) {
for (Iterator<Integer> it = notMatched.iterator(); it.hasNext(); ) {
piv1 = it.next();
// These two variables will keep track of which entry most closely matches the
// one we're looking at, in case none matches completely.
int bestMatchI = -1;
double bestMatch = 0;
double comp;
if (piv2 < (diskSorter.getEntryCount() - 1)) {
for (int i = piv2; i < diskSorter.getEntryCount(); i++) {
if (used.contains(String.valueOf(i))) {
comp = -1;
} else {
comp = DuplicateCheck.compareEntriesStrictly(tmpSorter.getEntryAt(piv1), diskSorter.getEntryAt(i));
}
if (comp > bestMatch) {
bestMatch = comp;
bestMatchI = i;
}
}
}
if (bestMatch > MATCH_THRESHOLD) {
used.add(String.valueOf(bestMatchI));
it.remove();
changes.add(new EntryChange(bestFit(tmpSorter, memorySorter, piv1), tmpSorter.getEntryAt(piv1),
diskSorter.getEntryAt(bestMatchI)));
} else {
changes.add(
new EntryDeleteChange(bestFit(tmpSorter, memorySorter, piv1), tmpSorter.getEntryAt(piv1)));
}
}
}
// Finally, look if there are still untouched entries in the disk database. These
// may have been added.
if (used.size() < diskSorter.getEntryCount()) {
for (int i = 0; i < diskSorter.getEntryCount(); i++) {
if (!used.contains(String.valueOf(i))) {
// See if there is an identical dupe in the mem database:
boolean hasAlready = false;
for (int j = 0; j < memorySorter.getEntryCount(); j++) {
if (DuplicateCheck.compareEntriesStrictly(memorySorter.getEntryAt(j), diskSorter.getEntryAt(i)) >= 1) {
hasAlready = true;
break;
}
}
if (!hasAlready) {
changes.add(new EntryAddChange(diskSorter.getEntryAt(i)));
}
}
}
}
}
/**
* Finds the entry in neu best fitting the specified entry in old. If no entries get a score
* above zero, an entry is still returned.
*
* @param oldSorter EntrySorter
* @param newSorter EntrySorter
* @param index int
* @return BibEntry
*/
private static BibEntry bestFit(EntrySorter oldSorter, EntrySorter newSorter, int index) {
double comp = -1;
int found = 0;
for (int i = 0; i < newSorter.getEntryCount(); i++) {
double res = DuplicateCheck.compareEntriesStrictly(oldSorter.getEntryAt(index), newSorter.getEntryAt(i));
if (res > comp) {
comp = res;
found = i;
}
if (comp > 1) {
break;
}
}
return newSorter.getEntryAt(found);
}
private void scanPreamble(BibDatabase inMemory, BibDatabase onTmp, BibDatabase onDisk) {
String mem = inMemory.getPreamble().orElse(null);
Optional<String> tmp = onTmp.getPreamble();
Optional<String> disk = onDisk.getPreamble();
if (!tmp.isPresent()) {
disk.ifPresent(diskContent -> changes.add(new PreambleChange(mem, diskContent)));
} else {
if (!disk.isPresent() || !tmp.equals(disk)) {
changes.add(new PreambleChange(mem, disk.orElse(null)));
}
}
}
private void scanStrings(BibDatabase inMem1, BibDatabase inTmp, BibDatabase onDisk) {
if (inTmp.hasNoStrings() && onDisk.hasNoStrings()) {
return;
}
Set<Object> used = new HashSet<>();
Set<Object> usedInMem = new HashSet<>();
Set<String> notMatched = new HashSet<>(inTmp.getStringCount());
// First try to match by string names.
mainLoop:
for (String key : inTmp.getStringKeySet()) {
BibtexString tmp = inTmp.getString(key);
for (String diskId : onDisk.getStringKeySet()) {
if (!used.contains(diskId)) {
BibtexString disk = onDisk.getString(diskId);
if (disk.getName().equals(tmp.getName())) {
// We have found a string with a matching name.
if (!Objects.equals(tmp.getContent(), disk.getContent())) {
// But they have nonmatching contents, so we've found a change.
Optional<BibtexString> mem = findString(inMem1, tmp.getName(), usedInMem);
if (mem.isPresent()) {
changes.add(new StringChange(mem.get(), tmp, tmp.getName(), mem.get().getContent(),
disk.getContent()));
} else {
changes.add(new StringChange(null, tmp, tmp.getName(), null, disk.getContent()));
}
}
used.add(diskId);
continue mainLoop;
}
}
}
// If we get here, there was no match for this string.
notMatched.add(tmp.getId());
}
// See if we can detect a name change for those entries that we couldn't match.
if (!notMatched.isEmpty()) {
for (Iterator<String> i = notMatched.iterator(); i.hasNext(); ) {
BibtexString tmp = inTmp.getString(i.next());
// If we get to this point, we found no string with matching name. See if we
// can find one with matching content.
for (String diskId : onDisk.getStringKeySet()) {
if (!used.contains(diskId)) {
BibtexString disk = onDisk.getString(diskId);
if (disk.getContent().equals(tmp.getContent())) {
// We have found a string with the same content. It cannot have the same
// name, or we would have found it above.
// Try to find the matching one in memory:
BibtexString bsMem = null;
for (String memId : inMem1.getStringKeySet()) {
BibtexString bsMemCandidate = inMem1.getString(memId);
if (bsMemCandidate.getContent().equals(disk.getContent())
&& !usedInMem.contains(memId)) {
usedInMem.add(memId);
bsMem = bsMemCandidate;
break;
}
}
if (bsMem != null) {
changes.add(new StringNameChange(bsMem, tmp, bsMem.getName(), tmp.getName(),
disk.getName(), tmp.getContent()));
i.remove();
used.add(diskId);
}
}
}
}
}
}
if (!notMatched.isEmpty()) {
// Still one or more non-matched strings. So they must have been removed.
for (String notMatchedId : notMatched) {
BibtexString tmp = inTmp.getString(notMatchedId);
// The removed string is not removed from the mem version.
findString(inMem1, tmp.getName(), usedInMem).ifPresent(
x -> changes.add(new StringRemoveChange(tmp, tmp, x)));
}
}
// Finally, see if there are remaining strings in the disk database. They
// must have been added.
for (String diskId : onDisk.getStringKeySet()) {
if (!used.contains(diskId)) {
BibtexString disk = onDisk.getString(diskId);
used.add(diskId);
changes.add(new StringAddChange(disk));
}
}
}
private static Optional<BibtexString> findString(BibDatabase base, String name, Set<Object> used) {
if (!base.hasStringLabel(name)) {
return Optional.empty();
}
for (String key : base.getStringKeySet()) {
BibtexString bs = base.getString(key);
if (bs.getName().equals(name) && !used.contains(key)) {
used.add(key);
return Optional.of(bs);
}
}
return Optional.empty();
}
/**
* This method only detects whether a change took place or not. It does not determine the type of change. This would
* be possible, but difficult to do properly, so I rather only report the change.
*/
private void scanGroups(MetaData inTemp, MetaData onDisk) {
final Optional<GroupTreeNode> groupsTmp = inTemp.getGroups();
final Optional<GroupTreeNode> groupsDisk = onDisk.getGroups();
if (!groupsTmp.isPresent() && !groupsDisk.isPresent()) {
return;
}
if ((groupsTmp.isPresent() && !groupsDisk.isPresent()) || !groupsTmp.isPresent()) {
changes.add(new GroupChange(groupsDisk.orElse(null), groupsTmp.orElse(null)));
return;
}
// Both present here
if (!groupsTmp.equals(groupsDisk)) {
changes.add(new GroupChange(groupsDisk.get(), groupsTmp.get()));
}
}
@FunctionalInterface
public interface DisplayResultCallback {
void scanResultsResolved(boolean resolved);
}
}