diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48482f765e9..b017558a542 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
### Added
+- Enhanced backup and restore functionality. [#2961](https://github.com/JabRef/jabref/issues/2961)
- We added a Markdown export layout. [#12220](https://github.com/JabRef/jabref/pull/12220)
- We added a "view as BibTeX" option before importing an entry from the citation relation tab. [#11826](https://github.com/JabRef/jabref/issues/11826)
- We added support finding LaTeX-encoded special characters based on plain Unicode and vice versa. [#11542](https://github.com/JabRef/jabref/pull/11542)
diff --git a/buildres/abbrv.jabref.org b/buildres/abbrv.jabref.org
index 0fdf99147a8..d87037495de 160000
--- a/buildres/abbrv.jabref.org
+++ b/buildres/abbrv.jabref.org
@@ -1 +1 @@
-Subproject commit 0fdf99147a8a5fc8ae7ccd79ad4e0029e736e4a3
+Subproject commit d87037495de7213b896dbb6a20170387de170709
diff --git a/jabref b/jabref
new file mode 160000
index 00000000000..4705977685c
--- /dev/null
+++ b/jabref
@@ -0,0 +1 @@
+Subproject commit 4705977685c6b0551a8d40458abced473501d245
diff --git a/src/main/java/org/jabref/gui/LibraryTab.java b/src/main/java/org/jabref/gui/LibraryTab.java
index f93ef8c6101..6ab7334c880 100644
--- a/src/main/java/org/jabref/gui/LibraryTab.java
+++ b/src/main/java/org/jabref/gui/LibraryTab.java
@@ -42,7 +42,7 @@
import org.jabref.gui.autocompleter.SuggestionProvider;
import org.jabref.gui.autocompleter.SuggestionProviders;
import org.jabref.gui.autosaveandbackup.AutosaveManager;
-import org.jabref.gui.autosaveandbackup.BackupManager;
+import org.jabref.gui.autosaveandbackup.BackupManagerGit;
import org.jabref.gui.collab.DatabaseChangeMonitor;
import org.jabref.gui.dialogs.AutosaveUiManager;
import org.jabref.gui.entryeditor.EntryEditor;
@@ -105,6 +105,7 @@
import com.tobiasdiez.easybind.Subscription;
import org.controlsfx.control.NotificationPane;
import org.controlsfx.control.action.Action;
+import org.eclipse.jgit.api.errors.GitAPIException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -308,7 +309,7 @@ private void onDatabaseLoadingStarted() {
getMainTable().placeholderProperty().setValue(loadingLayout);
}
- private void onDatabaseLoadingSucceed(ParserResult result) {
+ private void onDatabaseLoadingSucceed(ParserResult result) throws GitAPIException, IOException {
OpenDatabaseAction.performPostOpenActions(result, dialogService, preferences);
if (result.getChangedOnMigration()) {
this.markBaseChanged();
@@ -343,7 +344,7 @@ private void onDatabaseLoadingFailed(Exception ex) {
dialogService.showErrorDialogAndWait(title, content, ex);
}
- private void setDatabaseContext(BibDatabaseContext bibDatabaseContext) {
+ private void setDatabaseContext(BibDatabaseContext bibDatabaseContext) throws GitAPIException, IOException {
TabPane tabPane = this.getTabPane();
if (tabPane == null) {
LOGGER.debug("User interrupted loading. Not showing any library.");
@@ -367,13 +368,13 @@ private void setDatabaseContext(BibDatabaseContext bibDatabaseContext) {
installAutosaveManagerAndBackupManager();
}
- public void installAutosaveManagerAndBackupManager() {
+ public void installAutosaveManagerAndBackupManager() throws GitAPIException, IOException {
if (isDatabaseReadyForAutoSave(bibDatabaseContext)) {
AutosaveManager autosaveManager = AutosaveManager.start(bibDatabaseContext);
autosaveManager.registerListener(new AutosaveUiManager(this, dialogService, preferences, entryTypesManager));
}
if (isDatabaseReadyForBackup(bibDatabaseContext) && preferences.getFilePreferences().shouldCreateBackup()) {
- BackupManager.start(this, bibDatabaseContext, Injector.instantiateModelOrService(BibEntryTypesManager.class), preferences);
+ BackupManagerGit.start(this, bibDatabaseContext, entryTypesManager, preferences);
}
}
@@ -750,7 +751,7 @@ private boolean confirmClose() {
}
if (buttonType.equals(discardChanges)) {
- BackupManager.discardBackup(bibDatabaseContext, preferences.getFilePreferences().getBackupDirectory());
+ LOGGER.debug("Discarding changes");
return true;
}
@@ -798,7 +799,7 @@ private void onClosed(Event event) {
LOGGER.error("Problem when shutting down autosave manager", e);
}
try {
- BackupManager.shutdown(bibDatabaseContext,
+ BackupManagerGit.shutdown(bibDatabaseContext,
preferences.getFilePreferences().getBackupDirectory(),
preferences.getFilePreferences().shouldCreateBackup());
} catch (RuntimeException e) {
@@ -1078,7 +1079,13 @@ public static LibraryTab createLibraryTab(BackgroundTask dataLoadi
newTab.setDataLoadingTask(dataLoadingTask);
dataLoadingTask.onRunning(newTab::onDatabaseLoadingStarted)
- .onSuccess(newTab::onDatabaseLoadingSucceed)
+ .onSuccess(result -> {
+ try {
+ newTab.onDatabaseLoadingSucceed(result);
+ } catch (Exception e) {
+ LOGGER.error("An error occurred while loading the database", e);
+ }
+ })
.onFailure(newTab::onDatabaseLoadingFailed)
.executeWith(taskExecutor);
diff --git a/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java b/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java
deleted file mode 100644
index acae02c01c8..00000000000
--- a/src/main/java/org/jabref/gui/autosaveandbackup/BackupManager.java
+++ /dev/null
@@ -1,384 +0,0 @@
-package org.jabref.gui.autosaveandbackup;
-
-import java.io.IOException;
-import java.io.Writer;
-import java.nio.charset.Charset;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.nio.file.attribute.FileTime;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Queue;
-import java.util.Set;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ScheduledThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-
-import javafx.scene.control.TableColumn;
-
-import org.jabref.gui.LibraryTab;
-import org.jabref.gui.maintable.BibEntryTableViewModel;
-import org.jabref.gui.maintable.columns.MainTableColumn;
-import org.jabref.logic.bibtex.InvalidFieldValueException;
-import org.jabref.logic.exporter.AtomicFileWriter;
-import org.jabref.logic.exporter.BibWriter;
-import org.jabref.logic.exporter.BibtexDatabaseWriter;
-import org.jabref.logic.exporter.SelfContainedSaveConfiguration;
-import org.jabref.logic.preferences.CliPreferences;
-import org.jabref.logic.util.BackupFileType;
-import org.jabref.logic.util.CoarseChangeFilter;
-import org.jabref.logic.util.io.BackupFileUtil;
-import org.jabref.model.database.BibDatabase;
-import org.jabref.model.database.BibDatabaseContext;
-import org.jabref.model.database.event.BibDatabaseContextChangedEvent;
-import org.jabref.model.entry.BibEntry;
-import org.jabref.model.entry.BibEntryTypesManager;
-import org.jabref.model.metadata.SaveOrder;
-import org.jabref.model.metadata.SelfContainedSaveOrder;
-
-import com.google.common.eventbus.Subscribe;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Backups the given bib database file from {@link BibDatabaseContext} on every {@link BibDatabaseContextChangedEvent}.
- * An intelligent {@link ExecutorService} with a {@link BlockingQueue} prevents a high load while making backups and
- * rejects all redundant backup tasks. This class does not manage the .bak file which is created when opening a
- * database.
- */
-public class BackupManager {
-
- private static final Logger LOGGER = LoggerFactory.getLogger(BackupManager.class);
-
- private static final int MAXIMUM_BACKUP_FILE_COUNT = 10;
-
- private static final int DELAY_BETWEEN_BACKUP_ATTEMPTS_IN_SECONDS = 19;
-
- private static Set runningInstances = new HashSet<>();
-
- private final BibDatabaseContext bibDatabaseContext;
- private final CliPreferences preferences;
- private final ScheduledThreadPoolExecutor executor;
- private final CoarseChangeFilter changeFilter;
- private final BibEntryTypesManager entryTypesManager;
- private final LibraryTab libraryTab;
-
- // Contains a list of all backup paths
- // During writing, the less recent backup file is deleted
- private final Queue backupFilesQueue = new LinkedBlockingQueue<>();
- private boolean needsBackup = false;
-
- BackupManager(LibraryTab libraryTab, BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager, CliPreferences preferences) {
- this.bibDatabaseContext = bibDatabaseContext;
- this.entryTypesManager = entryTypesManager;
- this.preferences = preferences;
- this.executor = new ScheduledThreadPoolExecutor(2);
- this.libraryTab = libraryTab;
-
- changeFilter = new CoarseChangeFilter(bibDatabaseContext);
- changeFilter.registerListener(this);
- }
-
- /**
- * Determines the most recent backup file name
- */
- static Path getBackupPathForNewBackup(Path originalPath, Path backupDir) {
- return BackupFileUtil.getPathForNewBackupFileAndCreateDirectory(originalPath, BackupFileType.BACKUP, backupDir);
- }
-
- /**
- * Determines the most recent existing backup file name
- */
- static Optional getLatestBackupPath(Path originalPath, Path backupDir) {
- return BackupFileUtil.getPathOfLatestExistingBackupFile(originalPath, BackupFileType.BACKUP, backupDir);
- }
-
- /**
- * Starts the BackupManager which is associated with the given {@link BibDatabaseContext}. As long as no database
- * file is present in {@link BibDatabaseContext}, the {@link BackupManager} will do nothing.
- *
- * This method is not thread-safe. The caller has to ensure that this method is not called in parallel.
- *
- * @param bibDatabaseContext Associated {@link BibDatabaseContext}
- */
- public static BackupManager start(LibraryTab libraryTab, BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager, CliPreferences preferences) {
- BackupManager backupManager = new BackupManager(libraryTab, bibDatabaseContext, entryTypesManager, preferences);
- backupManager.startBackupTask(preferences.getFilePreferences().getBackupDirectory());
- runningInstances.add(backupManager);
- return backupManager;
- }
-
- /**
- * Marks the backup as discarded at the library which is associated with the given {@link BibDatabaseContext}.
- *
- * @param bibDatabaseContext Associated {@link BibDatabaseContext}
- */
- public static void discardBackup(BibDatabaseContext bibDatabaseContext, Path backupDir) {
- runningInstances.stream().filter(instance -> instance.bibDatabaseContext == bibDatabaseContext).forEach(backupManager -> backupManager.discardBackup(backupDir));
- }
-
- /**
- * Shuts down the BackupManager which is associated with the given {@link BibDatabaseContext}.
- *
- * @param bibDatabaseContext Associated {@link BibDatabaseContext}
- * @param createBackup True, if a backup should be created
- * @param backupDir The path to the backup directory
- */
- public static void shutdown(BibDatabaseContext bibDatabaseContext, Path backupDir, boolean createBackup) {
- runningInstances.stream().filter(instance -> instance.bibDatabaseContext == bibDatabaseContext).forEach(backupManager -> backupManager.shutdown(backupDir, createBackup));
- runningInstances.removeIf(instance -> instance.bibDatabaseContext == bibDatabaseContext);
- }
-
- /**
- * Checks whether a backup file exists for the given database file. If it exists, it is checked whether it is
- * newer and different from the original.
- *
- * In case a discarded file is present, the method also returns false
, See also {@link #discardBackup(Path)}.
- *
- * @param originalPath Path to the file a backup should be checked for. Example: jabref.bib.
- *
- * @return true
if backup file exists AND differs from originalPath. false
is the
- * "default" return value in the good case. In case a discarded file exists, false
is returned, too.
- * In the case of an exception true
is returned to ensure that the user checks the output.
- */
- public static boolean backupFileDiffers(Path originalPath, Path backupDir) {
- Path discardedFile = determineDiscardedFile(originalPath, backupDir);
- if (Files.exists(discardedFile)) {
- try {
- Files.delete(discardedFile);
- } catch (IOException e) {
- LOGGER.error("Could not remove discarded file {}", discardedFile, e);
- return true;
- }
- return false;
- }
- return getLatestBackupPath(originalPath, backupDir).map(latestBackupPath -> {
- FileTime latestBackupFileLastModifiedTime;
- try {
- latestBackupFileLastModifiedTime = Files.getLastModifiedTime(latestBackupPath);
- } catch (IOException e) {
- LOGGER.debug("Could not get timestamp of backup file {}", latestBackupPath, e);
- // If we cannot get the timestamp, we do show any warning
- return false;
- }
- FileTime currentFileLastModifiedTime;
- try {
- currentFileLastModifiedTime = Files.getLastModifiedTime(originalPath);
- } catch (IOException e) {
- LOGGER.debug("Could not get timestamp of current file file {}", originalPath, e);
- // If we cannot get the timestamp, we do show any warning
- return false;
- }
- if (latestBackupFileLastModifiedTime.compareTo(currentFileLastModifiedTime) <= 0) {
- // Backup is older than current file
- // We treat the backup as non-different (even if it could differ)
- return false;
- }
- try {
- boolean result = Files.mismatch(originalPath, latestBackupPath) != -1L;
- if (result) {
- LOGGER.info("Backup file {} differs from current file {}", latestBackupPath, originalPath);
- }
- return result;
- } catch (IOException e) {
- LOGGER.debug("Could not compare original file and backup file.", e);
- // User has to investigate in this case
- return true;
- }
- }).orElse(false);
- }
-
- /**
- * Restores the backup file by copying and overwriting the original one.
- *
- * @param originalPath Path to the file which should be equalized to the backup file.
- */
- public static void restoreBackup(Path originalPath, Path backupDir) {
- Optional backupPath = getLatestBackupPath(originalPath, backupDir);
- if (backupPath.isEmpty()) {
- LOGGER.error("There is no backup file");
- return;
- }
- try {
- Files.copy(backupPath.get(), originalPath, StandardCopyOption.REPLACE_EXISTING);
- } catch (IOException e) {
- LOGGER.error("Error while restoring the backup file.", e);
- }
- }
-
- Optional determineBackupPathForNewBackup(Path backupDir) {
- return bibDatabaseContext.getDatabasePath().map(path -> BackupManager.getBackupPathForNewBackup(path, backupDir));
- }
-
- /**
- * This method is called as soon as the scheduler says: "Do the backup"
- *
- * SIDE EFFECT: Deletes oldest backup file
- *
- * @param backupPath the full path to the file where the library should be backed up to
- */
- void performBackup(Path backupPath) {
- if (!needsBackup) {
- return;
- }
-
- // We opted for "while" to delete backups in case there are more than 10
- while (backupFilesQueue.size() >= MAXIMUM_BACKUP_FILE_COUNT) {
- Path oldestBackupFile = backupFilesQueue.poll();
- try {
- Files.delete(oldestBackupFile);
- } catch (IOException e) {
- LOGGER.error("Could not delete backup file {}", oldestBackupFile, e);
- }
- }
-
- // code similar to org.jabref.gui.exporter.SaveDatabaseAction.saveDatabase
- SelfContainedSaveOrder saveOrder = bibDatabaseContext
- .getMetaData().getSaveOrder()
- .map(so -> {
- if (so.getOrderType() == SaveOrder.OrderType.TABLE) {
- // We need to "flatten out" SaveOrder.OrderType.TABLE as BibWriter does not have access to preferences
- List> sortOrder = libraryTab.getMainTable().getSortOrder();
- return new SelfContainedSaveOrder(
- SaveOrder.OrderType.SPECIFIED,
- sortOrder.stream()
- .filter(col -> col instanceof MainTableColumn>)
- .map(column -> ((MainTableColumn>) column).getModel())
- .flatMap(model -> model.getSortCriteria().stream())
- .toList());
- } else {
- return SelfContainedSaveOrder.of(so);
- }
- })
- .orElse(SaveOrder.getDefaultSaveOrder());
- SelfContainedSaveConfiguration saveConfiguration = (SelfContainedSaveConfiguration) new SelfContainedSaveConfiguration()
- .withMakeBackup(false)
- .withSaveOrder(saveOrder)
- .withReformatOnSave(preferences.getLibraryPreferences().shouldAlwaysReformatOnSave());
-
- // "Clone" the database context
- // We "know" that "only" the BibEntries might be changed during writing (see [org.jabref.logic.exporter.BibDatabaseWriter.savePartOfDatabase])
- List list = bibDatabaseContext.getDatabase().getEntries().stream()
- .map(BibEntry::clone)
- .map(BibEntry.class::cast)
- .toList();
- BibDatabase bibDatabaseClone = new BibDatabase(list);
- BibDatabaseContext bibDatabaseContextClone = new BibDatabaseContext(bibDatabaseClone, bibDatabaseContext.getMetaData());
-
- Charset encoding = bibDatabaseContext.getMetaData().getEncoding().orElse(StandardCharsets.UTF_8);
- // We want to have successful backups only
- // Thus, we do not use a plain "FileWriter", but the "AtomicFileWriter"
- // Example: What happens if one hard powers off the machine (or kills the jabref process) during writing of the backup?
- // This MUST NOT create a broken backup file that then jabref wants to "restore" from?
- try (Writer writer = new AtomicFileWriter(backupPath, encoding, false)) {
- BibWriter bibWriter = new BibWriter(writer, bibDatabaseContext.getDatabase().getNewLineSeparator());
- new BibtexDatabaseWriter(
- bibWriter,
- saveConfiguration,
- preferences.getFieldPreferences(),
- preferences.getCitationKeyPatternPreferences(),
- entryTypesManager)
- // we save the clone to prevent the original database (and thus the UI) from being changed
- .saveDatabase(bibDatabaseContextClone);
- backupFilesQueue.add(backupPath);
-
- // We wrote the file successfully
- // Thus, we currently do not need any new backup
- this.needsBackup = false;
- } catch (IOException e) {
- logIfCritical(backupPath, e);
- }
- }
-
- private static Path determineDiscardedFile(Path file, Path backupDir) {
- return backupDir.resolve(BackupFileUtil.getUniqueFilePrefix(file) + "--" + file.getFileName() + "--discarded");
- }
-
- /**
- * Marks the backups as discarded.
- *
- * We do not delete any files, because the user might want to recover old backup files.
- * Therefore, we mark discarded backups by a --discarded file.
- */
- public void discardBackup(Path backupDir) {
- Path path = determineDiscardedFile(bibDatabaseContext.getDatabasePath().get(), backupDir);
- try {
- Files.createFile(path);
- } catch (IOException e) {
- LOGGER.info("Could not create backup file {}", path, e);
- }
- }
-
- private void logIfCritical(Path backupPath, IOException e) {
- Throwable innermostCause = e;
- while (innermostCause.getCause() != null) {
- innermostCause = innermostCause.getCause();
- }
- boolean isErrorInField = innermostCause instanceof InvalidFieldValueException;
-
- // do not print errors in field values into the log during autosave
- if (!isErrorInField) {
- LOGGER.error("Error while saving to file {}", backupPath, e);
- }
- }
-
- @Subscribe
- public synchronized void listen(@SuppressWarnings("unused") BibDatabaseContextChangedEvent event) {
- if (!event.isFilteredOut()) {
- this.needsBackup = true;
- }
- }
-
- private void startBackupTask(Path backupDir) {
- fillQueue(backupDir);
-
- executor.scheduleAtFixedRate(
- // We need to determine the backup path on each action, because we use the timestamp in the filename
- () -> determineBackupPathForNewBackup(backupDir).ifPresent(path -> this.performBackup(path)),
- DELAY_BETWEEN_BACKUP_ATTEMPTS_IN_SECONDS,
- DELAY_BETWEEN_BACKUP_ATTEMPTS_IN_SECONDS,
- TimeUnit.SECONDS);
- }
-
- private void fillQueue(Path backupDir) {
- if (!Files.exists(backupDir)) {
- return;
- }
- bibDatabaseContext.getDatabasePath().ifPresent(databasePath -> {
- // code similar to {@link org.jabref.logic.util.io.BackupFileUtil.getPathOfLatestExisingBackupFile}
- final String prefix = BackupFileUtil.getUniqueFilePrefix(databasePath) + "--" + databasePath.getFileName();
- try {
- List allSavFiles = Files.list(backupDir)
- // just list the .sav belonging to the given targetFile
- .filter(p -> p.getFileName().toString().startsWith(prefix))
- .sorted().toList();
- backupFilesQueue.addAll(allSavFiles);
- } catch (IOException e) {
- LOGGER.error("Could not determine most recent file", e);
- }
- });
- }
-
- /**
- * Unregisters the BackupManager from the eventBus of {@link BibDatabaseContext}.
- * This method should only be used when closing a database/JabRef in a normal way.
- *
- * @param backupDir The backup directory
- * @param createBackup If the backup manager should still perform a backup
- */
- private void shutdown(Path backupDir, boolean createBackup) {
- changeFilter.unregisterListener(this);
- changeFilter.shutdown();
- executor.shutdown();
-
- if (createBackup) {
- // Ensure that backup is a recent one
- determineBackupPathForNewBackup(backupDir).ifPresent(this::performBackup);
- }
- }
-}
diff --git a/src/main/java/org/jabref/gui/autosaveandbackup/BackupManagerGit.java b/src/main/java/org/jabref/gui/autosaveandbackup/BackupManagerGit.java
new file mode 100644
index 00000000000..d86a8425559
--- /dev/null
+++ b/src/main/java/org/jabref/gui/autosaveandbackup/BackupManagerGit.java
@@ -0,0 +1,705 @@
+package org.jabref.gui.autosaveandbackup;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.text.MessageFormat;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.ResourceBundle;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.jabref.gui.LibraryTab;
+import org.jabref.gui.backup.BackupEntry;
+import org.jabref.logic.preferences.CliPreferences;
+import org.jabref.logic.util.CoarseChangeFilter;
+import org.jabref.model.database.BibDatabaseContext;
+import org.jabref.model.database.event.BibDatabaseContextChangedEvent;
+import org.jabref.model.entry.BibEntryTypesManager;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class BackupManagerGit {
+
+ static Set runningInstances = new HashSet<>();
+
+ private static final String LINE_BREAK = System.lineSeparator();
+ private static final Logger LOGGER = LoggerFactory.getLogger(BackupManagerGit.class);
+
+ private static final int DELAY_BETWEEN_BACKUP_ATTEMPTS_IN_SECONDS = 19;
+
+ private static Git git;
+
+ private final BibDatabaseContext bibDatabaseContext;
+ private final Path backupDirectory;
+ private final ScheduledThreadPoolExecutor executor;
+ private final CoarseChangeFilter changeFilter;
+ private final BibEntryTypesManager entryTypesManager;
+ private final LibraryTab libraryTab;
+
+ private boolean needsBackup = false;
+
+ BackupManagerGit(LibraryTab libraryTab, BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager, Path backupDir) throws IOException, GitAPIException {
+ Path dbFile = bibDatabaseContext.getDatabasePath().orElseThrow(() -> new IllegalArgumentException("Database path is not provided."));
+ if (!Files.exists(dbFile)) {
+ LOGGER.error("Database file does not exist: {}", dbFile);
+ throw new IOException("Database file not found: " + dbFile);
+ }
+
+ this.bibDatabaseContext = bibDatabaseContext;
+ LOGGER.info("Backup manager initialized for file: {}", bibDatabaseContext.getDatabasePath().orElseThrow());
+ this.entryTypesManager = entryTypesManager;
+ this.backupDirectory = backupDir;
+ this.executor = new ScheduledThreadPoolExecutor(2);
+ this.libraryTab = libraryTab;
+
+ changeFilter = new CoarseChangeFilter(bibDatabaseContext);
+ changeFilter.registerListener(this);
+
+ LOGGER.info("Backup directory path: {}", backupDirectory);
+
+ ensureGitInitialized(backupDirectory);
+
+ File backupDirFile = backupDirectory.toFile();
+ if (!backupDirFile.exists() && !backupDirFile.mkdirs()) {
+ LOGGER.error("Failed to create backup directory: {}", backupDirectory);
+ throw new IOException("Unable to create backup directory: " + backupDirectory);
+ }
+
+ copyDatabaseFileToBackupDir(dbFile, backupDirectory);
+ }
+
+ /**
+ * Appends a UUID to a file name, keeping the original extension.
+ *
+ * @param originalFileName The original file name (e.g., library.bib).
+ * @param uuid The UUID to append.
+ * @return The modified file name with the UUID (e.g., library_123e4567-e89b-12d3-a456-426614174000.bib).
+ */
+ private static String appendUuidToFileName(String originalFileName, String uuid) {
+ int dotIndex = originalFileName.lastIndexOf('.');
+ if (dotIndex == -1) {
+ // If there's no extension, just append the UUID
+ return originalFileName + "_" + uuid;
+ }
+
+ // Insert the UUID before the extension
+ String baseName = originalFileName.substring(0, dotIndex);
+ String extension = originalFileName.substring(dotIndex);
+ return baseName + "_" + uuid + extension;
+ }
+
+ /**
+ * Retrieves or generates a persistent unique identifier (UUID) for the given file.
+ * The UUID is stored in an extended attribute or a metadata file alongside the original file.
+ *
+ * @param filePath The path to the file.
+ * @return The UUID associated with the file.
+ * @throws IOException If an error occurs while accessing or creating the UUID.
+ */
+ protected static String getOrGenerateFileUuid(Path filePath) throws IOException {
+ // Define a hidden metadata file to store the UUID
+ Path metadataFile = filePath.resolveSibling("." + filePath.getFileName().toString() + ".uuid");
+
+ // If the UUID metadata file exists, read it
+ if (Files.exists(metadataFile)) {
+ return Files.readString(metadataFile).trim();
+ }
+
+ // Otherwise, generate a new UUID and save it
+ String uuid = UUID.randomUUID().toString();
+ Files.writeString(metadataFile, uuid);
+ LOGGER.info("Generated new UUID for file {}: {}", filePath, uuid);
+ return uuid;
+ }
+
+ /**
+ * Rewrites the content of the file at the specified path with the given string.
+ *
+ * @param dbFile The path to the file to be rewritten.
+ * @param content The string content to write into the file.
+ * @throws IOException If an I/O error occurs during the write operation.
+ */
+ public static void rewriteFile(Path dbFile, String content) throws IOException {
+ // Ensure the file exists before rewriting
+ if (!Files.exists(dbFile)) {
+ Locale currentLocale = Locale.getDefault();
+ ResourceBundle messages = ResourceBundle.getBundle("messages", currentLocale);
+ String errorMessage = MessageFormat.format(messages.getString("file.not.found"), dbFile.toString());
+ throw new FileNotFoundException(errorMessage);
+ }
+
+ // Write the new content to the file (overwrite mode)
+ Files.writeString(dbFile, content, StandardCharsets.UTF_8);
+
+ LOGGER.info("Successfully rewrote the file at path: {}", dbFile);
+ }
+
+ // Helper method to normalize BibTeX content
+ private static String normalizeBibTeX(String input) {
+ if (input == null || input.isBlank()) {
+ return "";
+ }
+
+ // Split lines and process each line
+ Stream lines = input.lines();
+
+ // Normalize lines
+ String normalized = lines
+ .map(String::trim) // Remove leading and trailing spaces
+ .filter(line -> !line.isBlank()) // Remove blank lines
+ .collect(Collectors.joining(LINE_BREAK)); // Reassemble with line breaks
+
+ return normalized;
+ }
+
+ // Helper method to ensure the Git repository is initialized
+ static void ensureGitInitialized(Path backupDir) throws IOException, GitAPIException {
+
+ // This method was created because the initialization of the Git object, when written in the constructor, was causing a NullPointerException
+ // because the first method called when loading the database is BackupGitdiffers
+
+ // Convert Path to File
+ File gitDir = new File(backupDir.toFile(), ".git");
+
+ // Check if the `.git` directory exists
+ if (!gitDir.exists() || !gitDir.isDirectory()) {
+ LOGGER.info(".git directory not found in {}, initializing new Git repository.", backupDir);
+
+ // Initialize a new Git repository
+ Git.init().setDirectory(backupDir.toFile()).call();
+ LOGGER.info("Git repository successfully initialized in {}", backupDir);
+ } else {
+ LOGGER.info("Existing Git repository found in {}", backupDir);
+ }
+
+ // Build the Git object
+ FileRepositoryBuilder builder = new FileRepositoryBuilder();
+ Repository repository = builder.setGitDir(gitDir)
+ .readEnvironment()
+ .findGitDir()
+ .build();
+ git = new Git(repository);
+ }
+
+ // Helper method to copy the database file to the backup directory
+ protected static void copyDatabaseFileToBackupDir(Path dbFile, Path backupDirPath) throws IOException {
+ String fileUuid = getOrGenerateFileUuid(dbFile);
+ String uniqueFileName = appendUuidToFileName(dbFile.getFileName().toString(), fileUuid);
+ Path backupFilePath = backupDirPath.resolve(uniqueFileName);
+ Files.copy(dbFile, backupFilePath, StandardCopyOption.REPLACE_EXISTING);
+ LOGGER.info("Database file uniquely copied to backup directory: {}", backupFilePath);
+ }
+
+ // A method
+
+ /**
+ * Starts a new BackupManagerGit instance and begins the backup task.
+ *
+ * @param libraryTab the library tab
+ * @param bibDatabaseContext the BibDatabaseContext to be backed up
+ * @param entryTypesManager the BibEntryTypesManager
+ * @param preferences the CLI preferences
+ * @return the started BackupManagerGit instance
+ * @throws IOException if an I/O error occurs
+ * @throws GitAPIException if a Git API error occurs
+ */
+
+ public static BackupManagerGit start(LibraryTab libraryTab, BibDatabaseContext bibDatabaseContext, BibEntryTypesManager entryTypesManager, CliPreferences preferences) throws IOException, GitAPIException {
+ LOGGER.info("In methode Start");
+ Path backupDir = preferences.getFilePreferences().getBackupDirectory();
+ BackupManagerGit backupManagerGit = new BackupManagerGit(libraryTab, bibDatabaseContext, entryTypesManager, backupDir);
+ backupManagerGit.startBackupTask(preferences.getFilePreferences().getBackupDirectory(), bibDatabaseContext);
+ runningInstances.add(backupManagerGit);
+ return backupManagerGit;
+ }
+
+ /**
+ * Shuts down the BackupManagerGit instances associated with the given BibDatabaseContext.
+ *
+ * @param bibDatabaseContext the BibDatabaseContext
+ * @param createBackup whether to create a backup before shutting down
+ */
+ public static void shutdown(BibDatabaseContext bibDatabaseContext, Path backupDir, boolean createBackup) {
+ runningInstances.stream()
+ .filter(instance -> instance.bibDatabaseContext == bibDatabaseContext)
+ .forEach(backupManager -> backupManager.shutdownGit(bibDatabaseContext,
+ backupDir,
+ createBackup));
+
+ // Remove the instances associated with the BibDatabaseContext after shutdown
+ runningInstances.removeIf(instance -> instance.bibDatabaseContext == bibDatabaseContext);
+ LOGGER.info("Shut down backup manager for file: {}");
+ }
+
+ /**
+ * Starts the backup task that periodically checks for changes and commits them to the Git repository.
+ *
+ * @param backupDir the backup directory
+ */
+
+ void startBackupTask(Path backupDir, BibDatabaseContext bibDatabaseContext) {
+ LOGGER.info("Initializing backup task for directory: {} and file: {}", backupDir, bibDatabaseContext.getDatabasePath().orElseThrow());
+ executor.scheduleAtFixedRate(
+ () -> {
+ try {
+ Path dbFile = bibDatabaseContext.getDatabasePath().orElseThrow(() -> new IllegalArgumentException("Database path is not provided."));
+ // copyDatabaseFileToBackupDir(dbFile, backupDir);
+ performBackup(dbFile, backupDir);
+ } catch (IOException | GitAPIException e) {
+ LOGGER.error("Error during backup", e);
+ }
+ },
+ DELAY_BETWEEN_BACKUP_ATTEMPTS_IN_SECONDS,
+ DELAY_BETWEEN_BACKUP_ATTEMPTS_IN_SECONDS,
+ TimeUnit.SECONDS);
+ LOGGER.info("Backup task scheduled with a delay of {} seconds", DELAY_BETWEEN_BACKUP_ATTEMPTS_IN_SECONDS);
+ }
+
+ /**
+ * Performs the backup by checking for changes and committing them to the Git repository.
+ *
+ * @param backupDir the backup directory
+ * @param dbfile the database file
+ * @throws IOException if an I/O error occurs
+ * @throws GitAPIException if a Git API error occurs
+ */
+
+ protected void performBackup(Path dbfile, Path backupDir) throws IOException, GitAPIException {
+
+ boolean needsCommit = backupGitDiffers(dbfile, backupDir);
+
+ if (!needsBackup && !needsCommit) {
+ LOGGER.info("No changes detected, beacuse needsBackup is :{} and needsCommit is :{}", needsBackup, needsCommit);
+ return;
+ }
+
+ if (needsBackup) {
+ LOGGER.info("Backup needed, because needsBackup is :{}", needsBackup);
+ } else {
+ LOGGER.info("Backup needed, because needsCommit is :{}", needsCommit);
+ }
+
+ // Stage the file for commit
+ git.add().addFilepattern(".").call();
+ LOGGER.info("Staged changes for backup in directory: {}", backupDir);
+
+ // Commit the staged changes
+ RevCommit commit = git.commit()
+ .setMessage("Backup at " + Instant.now().toString())
+ .call();
+ LOGGER.info("Backup committed in :{} with commit ID: {} for the file : {}", backupDir, commit.getName(), bibDatabaseContext.getDatabasePath().orElseThrow());
+ }
+
+ public synchronized void listen(BibDatabaseContextChangedEvent event) {
+ if (!event.isFilteredOut()) {
+ LOGGER.info("Change detected/LISTENED in file: {}", bibDatabaseContext.getDatabasePath().orElseThrow());
+ this.needsBackup = true;
+ }
+ }
+
+ /**
+ * Restores the backup from the specified commit.
+ *
+ * @param backupDir the backup directory
+ * @param objectId the commit ID to restore from
+ */
+
+ public static void restoreBackup(Path dbFile, Path backupDir, ObjectId objectId) {
+ try (Repository repository = openGitRepository(backupDir)) {
+ // Resolve the filename of dbFile in the repository
+ String baseName = dbFile.getFileName().toString();
+ String uuid = getOrGenerateFileUuid(dbFile); // Generate or retrieve the UUID for this file
+ String relativeFilePath = baseName.replace(".bib", "") + "_" + uuid + ".bib";
+ LOGGER.info("Relative file path TO RESTORE: {}", relativeFilePath);
+ String gitPath = backupDir.relativize(backupDir.resolve(relativeFilePath)).toString().replace("\\", "/");
+
+ LOGGER.info("Restoring file: {}", gitPath);
+
+ // Load the content of the file from the specified commit
+ ObjectId fileObjectId = repository.resolve(objectId.getName() + ":" + gitPath);
+ if (fileObjectId == null) { // File not found in the commit
+ performBackupNoCommits(dbFile, backupDir);
+ }
+
+ // Read the content of the file from the Git object
+ ObjectLoader loader = repository.open(fileObjectId);
+ String fileContent = new String(loader.getBytes(), StandardCharsets.UTF_8);
+
+ // Rewrite the original file at dbFile path
+ rewriteFile(dbFile, fileContent);
+ LOGGER.info("Restored content to: {}", dbFile);
+ } catch (IOException | IllegalArgumentException | GitAPIException e) {
+ LOGGER.error("Error while restoring the backup: {}", e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Checks if there are differences between the files in the directory and the last commit.
+ *
+ * @param backupDir the backup directory
+ * @return true if there are differences, false otherwise
+ * @throws IOException if an I/O error occurs
+ * @throws GitAPIException if a Git API error occurs
+ */
+
+ public static boolean backupGitDiffers(Path dbFile, Path backupDir) throws IOException, GitAPIException {
+
+ // Ensure the specific database file is copied to the backup directory
+ copyDatabaseFileToBackupDir(dbFile, backupDir);
+
+ // Ensure the Git repository exists
+ LOGGER.info("Checking if backup differs for file: {}", dbFile);
+
+ // Open the Git repository located in the backup directory
+ Repository repository = openGitRepository(backupDir);
+
+ // Get the HEAD commit to compare with
+ ObjectId headCommitId = repository.resolve("HEAD");
+ if (headCommitId == null) {
+ LOGGER.info("No commits found in the repository. Assuming the file differs.");
+ // perform a commit
+ performBackupNoCommits(dbFile, backupDir);
+ return false;
+ }
+ LOGGER.info("HEAD commit ID: {}", headCommitId.getName());
+
+ // Compute the repository file name using the naming convention (filename + UUID)
+ String baseName = dbFile.getFileName().toString();
+ String uuid = getOrGenerateFileUuid(dbFile); // Generate or retrieve the UUID for this file
+ String repoFileName = baseName.replace(".bib", "") + "_" + uuid + ".bib";
+ Path relativePath = Path.of(repoFileName);
+ LOGGER.info("Checking repository file: {}", relativePath);
+
+ try {
+ // Check if the file exists in the latest commit
+ ObjectId objectId = repository.resolve("HEAD:" + relativePath.toString().replace("\\", "/"));
+ if (objectId == null) {
+ LOGGER.info("File not found in the latest commit: {}. Assuming it differs.", relativePath);
+ return true;
+ }
+
+ // Compare the content of the file in the Git repository with the current file
+ ObjectLoader loader = repository.open(objectId);
+ String committedContent = normalizeBibTeX(new String(loader.getBytes(), StandardCharsets.UTF_8));
+ String currentContent = normalizeBibTeX(Files.readString(dbFile, StandardCharsets.UTF_8));
+ LOGGER.info("Committed content: {}", committedContent);
+ LOGGER.info("Current content: {}", currentContent);
+
+ // If the contents differ, return true
+ if (!currentContent.equals(committedContent)) {
+ LOGGER.info("Content differs for file: {}", relativePath);
+ return true;
+ }
+ } catch (MissingObjectException e) {
+ // If the file is missing from the commit, assume it differs
+ LOGGER.info("File not found in the latest commit: {}. Assuming it differs.", relativePath);
+ return true;
+ }
+
+ LOGGER.info("No differences found for the file: {}", dbFile);
+ return false; // No differences found
+ }
+
+ public static Path getBackupFilePath(Path dbFile, Path backupDir) {
+ try {
+ String baseName = dbFile.getFileName().toString();
+ String uuid = getOrGenerateFileUuid(dbFile);
+ String relativeFileName = baseName.replace(".bib", "") + "_" + uuid + ".bib";
+ return backupDir.resolve(relativeFileName);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static void writeBackupFileToCommit(Path dbFile, Path backupDir, ObjectId objectId) {
+ try (Repository repository = openGitRepository(backupDir)) {
+ // Resolve the filename of dbFile in the repository
+ String baseName = dbFile.getFileName().toString();
+ String uuid = getOrGenerateFileUuid(dbFile); // Generate or retrieve the UUID for this file
+ String relativeFilePath = baseName.replace(".bib", "") + "_" + uuid + ".bib";
+ LOGGER.info("Relative file path TO RESTORE: {}", relativeFilePath);
+ String gitPath = backupDir.relativize(backupDir.resolve(relativeFilePath)).toString().replace("\\", "/");
+
+ LOGGER.info("Restoring file: {}", gitPath);
+
+ // Load the content of the file from the specified commit
+ ObjectId fileObjectId = repository.resolve(objectId.getName() + ":" + gitPath);
+ if (fileObjectId == null) { // File not found in the commit
+ performBackupNoCommits(dbFile, backupDir);
+ }
+
+ // Read the content of the file from the Git object
+ ObjectLoader loader = repository.open(fileObjectId);
+ String fileContent = new String(loader.getBytes(), StandardCharsets.UTF_8);
+
+ Path backupFilePath = getBackupFilePath(dbFile, backupDir);
+ // Rewrite the original file at backupFilePath path
+ rewriteFile(backupFilePath, fileContent);
+ LOGGER.info("Restored content to: {}", dbFile);
+ } catch (IOException | IllegalArgumentException | GitAPIException e) {
+ LOGGER.error("Error while restoring the backup: {}", e.getMessage(), e);
+ }
+ }
+
+ private static Repository openGitRepository(Path backupDir) throws IOException {
+ FileRepositoryBuilder builder = new FileRepositoryBuilder();
+ // Initialize Git repository from the backup directory
+ return builder.setGitDir(new File(backupDir.toFile(), ".git"))
+ .readEnvironment()
+ .findGitDir()
+ .build();
+ }
+
+ /**
+ * Shows the differences between the specified commit and the latest commit.
+ *
+ * @param dbFile the path of the file
+ * @param backupDir the backup directory
+ * @param commitId the commit ID to compare with the latest commit
+ * @return a list of DiffEntry objects representing the differences
+ * @throws IOException if an I/O error occurs
+ * @throws GitAPIException if a Git API error occurs
+ */
+
+ public List showDiffers(Path dbFile, Path backupDir, String commitId) throws IOException, GitAPIException {
+
+ File repoDir = backupDir.toFile();
+ Repository repository = new FileRepositoryBuilder()
+ .setGitDir(new File(repoDir, ".git"))
+ .build();
+ /*
+ need a class to show the last ten backups indicating: date/ size/ number of entries
+ */
+
+ ObjectId oldCommit = repository.resolve(commitId);
+ ObjectId newCommit = repository.resolve("HEAD");
+
+ FileOutputStream fos = new FileOutputStream(FileDescriptor.out);
+ DiffFormatter diffFr = new DiffFormatter(fos);
+ diffFr.setRepository(repository);
+ return diffFr.scan(oldCommit, newCommit);
+ }
+
+ /**
+ * Retrieves the last n commits from the Git repository.
+ *
+ * @param dbFile the database file
+ * @param backupDir the backup directory
+ * @param n the number of commits to retrieve
+ * @return a list of RevCommit objects representing the commits
+ * @throws IOException if an I/O error occurs
+ * @throws GitAPIException if a Git API error occurs
+ */
+
+ public static List retrieveCommits(Path dbFile, Path backupDir, int n) throws IOException, GitAPIException {
+ List retrievedCommits = new ArrayList<>();
+
+ // Compute the repository file name using the naming convention (filename + UUID)
+ String baseName = dbFile.getFileName().toString();
+ String uuid = getOrGenerateFileUuid(dbFile); // Generate or retrieve the UUID for this file
+ String repoFileName = baseName.replace(".bib", "") + "_" + uuid + ".bib";
+ String dbFileRelativePath = backupDir.relativize(backupDir.resolve(repoFileName)).toString().replace("\\", "/");
+
+ // Open Git repository
+ try (Repository repository = Git.open(backupDir.toFile()).getRepository()) {
+ // Use RevWalk to traverse commits
+ try (RevWalk revWalk = new RevWalk(repository)) {
+ RevCommit startCommit = revWalk.parseCommit(repository.resolve("HEAD"));
+ revWalk.markStart(startCommit);
+
+ int count = 0;
+ for (RevCommit commit : revWalk) {
+ // Check if this commit involves the dbFile
+ try (TreeWalk treeWalk = new TreeWalk(repository)) {
+ treeWalk.addTree(commit.getTree());
+ treeWalk.setRecursive(true);
+
+ boolean fileFound = false;
+ while (treeWalk.next()) {
+ if (treeWalk.getPathString().equals(dbFileRelativePath)) {
+ fileFound = true;
+ break;
+ }
+ }
+
+ if (fileFound) {
+ retrievedCommits.add(commit);
+ count++;
+ if (count == n) {
+ break; // Stop after collecting the required number of commits
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return retrievedCommits;
+ }
+
+ /**
+ * Retrieves detailed information about the specified commits, focusing on the target file.
+ *
+ * @param commits the list of commits to retrieve details for
+ * @param dbFile the target file to retrieve details about
+ * @param backupDir the backup directory
+ * @return a list of BackupEntry objects containing details about each commit
+ * @throws IOException if an I/O error occurs
+ * @throws GitAPIException if a Git API error occurs
+ */
+ public static List retrieveCommitDetails(List commits, Path dbFile, Path backupDir) throws IOException, GitAPIException {
+ List commitDetails = new ArrayList<>();
+
+ // Compute the repository file name using the naming convention (filename + UUID)
+ String baseName = dbFile.getFileName().toString();
+ String uuid = getOrGenerateFileUuid(dbFile); // Generate or retrieve the UUID for this file
+ String repoFileName = baseName.replace(".bib", "") + "_" + uuid + ".bib";
+ String dbFileRelativePath = backupDir.relativize(backupDir.resolve(repoFileName)).toString().replace("\\", "/");
+
+ try (Repository repository = Git.open(backupDir.toFile()).getRepository()) {
+ // Browse the list of commits given as a parameter
+ for (RevCommit commit : commits) {
+ // Variables to store commit-specific details
+ String sizeFormatted = "0 KB";
+ long fileSize = 0;
+ boolean fileFound = false;
+
+ // Use TreeWalk to find the target file in the commit
+ try (TreeWalk treeWalk = new TreeWalk(repository)) {
+ treeWalk.addTree(commit.getTree());
+ treeWalk.setRecursive(true);
+
+ while (treeWalk.next()) {
+ if (treeWalk.getPathString().equals(dbFileRelativePath)) {
+ // Calculate size of the target file
+ ObjectLoader loader = repository.open(treeWalk.getObjectId(0));
+ fileSize = loader.getSize();
+ fileFound = true;
+ break;
+ }
+ }
+
+ // Convert size to KB or MB
+ sizeFormatted = fileSize > 1024 * 1024
+ ? "%.2f MB".formatted(fileSize / (1024.0 * 1024.0))
+ : "%.2f KB".formatted(fileSize / 1024.0);
+ }
+
+ // Skip this commit if the file was not found
+ if (!fileFound) {
+ continue;
+ }
+
+ // Add commit details
+ Date date = commit.getAuthorIdent().getWhen();
+ BackupEntry backupEntry = new BackupEntry(
+ ObjectId.fromString(commit.getName()), // Commit ID
+ commit.getName(), // Commit ID as string
+ date.toString(), // Commit date
+ sizeFormatted, // Formatted file size
+ 1 // Number of relevant .bib files (always 1 for dbFile)
+ );
+ commitDetails.add(backupEntry);
+ }
+ }
+
+ return commitDetails;
+ }
+
+ public static void performBackupNoCommits(Path dbFile, Path backupDir) throws IOException, GitAPIException {
+
+ LOGGER.info("No commits found in the repository. We need a first commit.");
+ // Ensure the specific database file is copied to the backup directory
+ // no need of copying again !!
+ // copyDatabaseFileToBackupDir(dbFile, backupDir);
+
+ // Ensure the Git repository exists
+ LOGGER.info("Ensuring the .git is initialized");
+ ensureGitInitialized(backupDir);
+
+ // Get the file name of the database file
+ String baseName = dbFile.getFileName().toString();
+ String uuid = getOrGenerateFileUuid(dbFile); // Generate or retrieve the UUID for this file
+ String repoFileName = baseName.replace(".bib", "") + "_" + uuid + ".bib";
+
+ // Stage the file for commit
+ LOGGER.info("Staging the file for commit");
+ git.add().addFilepattern(repoFileName).call();
+
+ // Commit the staged changes
+ LOGGER.info("Committing the file");
+ RevCommit commit = git.commit()
+ .setMessage("Backup at " + Instant.now().toString())
+ .call();
+ }
+
+ /**
+ * Shuts down the JGit components and optionally creates a backup.
+ *
+ * @param createBackup whether to create a backup before shutting down
+ * @param backupDir the backup directory
+ * @param bibDatabaseContext the BibDatabaseContext
+ */
+ private void shutdownGit(BibDatabaseContext bibDatabaseContext, Path backupDir, boolean createBackup) {
+ // Unregister the listener and shut down the change filter
+ if (changeFilter != null) {
+ changeFilter.unregisterListener(this);
+ changeFilter.shutdown();
+ LOGGER.info("Shut down change filter");
+ }
+
+ // Shut down the executor if it's not already shut down
+ if (executor != null && !executor.isShutdown()) {
+ executor.shutdown();
+ LOGGER.info("Shut down backup task for file: {}");
+ }
+
+ // If backup is requested, ensure that we perform the Git-based backup
+ if (createBackup) {
+ try {
+ // Get the file path of the database
+ Path dbFile = bibDatabaseContext.getDatabasePath().orElseThrow(() -> new IllegalArgumentException("Database path is not provided."));
+ // Ensure the backup is a recent one by performing the Git commit
+ performBackup(dbFile, backupDir);
+ LOGGER.info("Backup created on shutdown for file: {}");
+ } catch (IOException | GitAPIException e) {
+ LOGGER.error("Error during Git backup on shutdown");
+ }
+ }
+ }
+}
+
+
+
+
+
+
diff --git a/src/main/java/org/jabref/gui/backup/BackupChoiceDialog.java b/src/main/java/org/jabref/gui/backup/BackupChoiceDialog.java
new file mode 100644
index 00000000000..d75f07a4535
--- /dev/null
+++ b/src/main/java/org/jabref/gui/backup/BackupChoiceDialog.java
@@ -0,0 +1,73 @@
+package org.jabref.gui.backup;
+
+import java.nio.file.Path;
+import java.util.List;
+
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.fxml.FXML;
+import javafx.scene.control.ButtonBar;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Label;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.layout.VBox;
+
+import org.jabref.gui.util.BaseDialog;
+import org.jabref.logic.l10n.Localization;
+
+public class BackupChoiceDialog extends BaseDialog {
+ public static final ButtonType RESTORE_BACKUP = new ButtonType(Localization.lang("Restore from backup"), ButtonBar.ButtonData.OK_DONE);
+ public static final ButtonType IGNORE_BACKUP = new ButtonType(Localization.lang("Ignore backup"), ButtonBar.ButtonData.CANCEL_CLOSE);
+ public static final ButtonType REVIEW_BACKUP = new ButtonType(Localization.lang("Review backup"), ButtonBar.ButtonData.LEFT);
+
+ private final ObservableList tableData = FXCollections.observableArrayList();
+
+ private final Path backupDir;
+ @FXML
+ private final TableView backupTableView;
+
+ public BackupChoiceDialog(Path backupDir, List backups) {
+ this.backupDir = backupDir;
+
+ setTitle(Localization.lang("Choose backup file"));
+ setHeaderText(null);
+ getDialogPane().setMinHeight(150);
+ getDialogPane().setMinWidth(450);
+ getDialogPane().getButtonTypes().setAll(RESTORE_BACKUP, IGNORE_BACKUP, REVIEW_BACKUP);
+
+ String content = Localization.lang("It looks like JabRef did not shut down cleanly last time the file was used.") + "\n\n" +
+ Localization.lang("Do you want to recover the library from a backup file?");
+
+ backupTableView = new TableView<>();
+ setupBackupTableView();
+ tableData.addAll(backups);
+
+ backupTableView.setItems(tableData);
+
+ VBox contentBox = new VBox();
+ contentBox.getChildren().addAll(new Label(content), backupTableView);
+ contentBox.setPrefWidth(380);
+
+ getDialogPane().setContent(contentBox);
+ setResultConverter(dialogButton -> {
+ if (dialogButton == RESTORE_BACKUP || dialogButton == REVIEW_BACKUP) {
+ return new BackupChoiceDialogRecord(backupTableView.getSelectionModel().getSelectedItem(), dialogButton);
+ }
+ return new BackupChoiceDialogRecord(null, dialogButton);
+ });
+ }
+
+ private void setupBackupTableView() {
+ TableColumn dateColumn = new TableColumn<>(Localization.lang("Date of Backup"));
+ dateColumn.setCellValueFactory(cellData -> cellData.getValue().dateProperty());
+
+ TableColumn sizeColumn = new TableColumn<>(Localization.lang("Size of Backup"));
+ sizeColumn.setCellValueFactory(cellData -> cellData.getValue().sizeProperty());
+
+ TableColumn entriesColumn = new TableColumn<>(Localization.lang("Number of Entries"));
+ entriesColumn.setCellValueFactory(cellData -> cellData.getValue().entriesProperty().asObject());
+
+ backupTableView.getColumns().addAll(dateColumn, sizeColumn, entriesColumn);
+ }
+}
diff --git a/src/main/java/org/jabref/gui/backup/BackupChoiceDialogRecord.java b/src/main/java/org/jabref/gui/backup/BackupChoiceDialogRecord.java
new file mode 100644
index 00000000000..0e3cf772825
--- /dev/null
+++ b/src/main/java/org/jabref/gui/backup/BackupChoiceDialogRecord.java
@@ -0,0 +1,8 @@
+package org.jabref.gui.backup;
+
+import javafx.scene.control.ButtonType;
+
+public record BackupChoiceDialogRecord(
+ BackupEntry entry,
+ ButtonType action) {
+}
diff --git a/src/main/java/org/jabref/gui/backup/BackupEntry.java b/src/main/java/org/jabref/gui/backup/BackupEntry.java
new file mode 100644
index 00000000000..2ff14fbe728
--- /dev/null
+++ b/src/main/java/org/jabref/gui/backup/BackupEntry.java
@@ -0,0 +1,60 @@
+package org.jabref.gui.backup;
+
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.SimpleIntegerProperty;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+
+import org.eclipse.jgit.lib.ObjectId;
+
+public class BackupEntry {
+ private final ObjectId id;
+ private final StringProperty name;
+ private final StringProperty date;
+ private final StringProperty size;
+ private final IntegerProperty entries;
+
+ public BackupEntry(ObjectId id, String name, String date, String size, int entries) {
+ this.id = id;
+ this.name = new SimpleStringProperty(name);
+ this.date = new SimpleStringProperty(date);
+ this.size = new SimpleStringProperty(size);
+ this.entries = new SimpleIntegerProperty(entries);
+ }
+
+ public String getDate() {
+ return date.get();
+ }
+
+ public StringProperty dateProperty() {
+ return date;
+ }
+
+ public String getSize() {
+ return size.get();
+ }
+
+ public StringProperty sizeProperty() {
+ return size;
+ }
+
+ public int getEntries() {
+ return entries.get();
+ }
+
+ public IntegerProperty entriesProperty() {
+ return entries;
+ }
+
+ public ObjectId getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name.get();
+ }
+
+ public StringProperty nameProperty() {
+ return name;
+ }
+}
diff --git a/src/main/java/org/jabref/gui/backup/BackupResolverDialog.java b/src/main/java/org/jabref/gui/backup/BackupResolverDialog.java
index cf35336765d..750e94a9e4f 100644
--- a/src/main/java/org/jabref/gui/backup/BackupResolverDialog.java
+++ b/src/main/java/org/jabref/gui/backup/BackupResolverDialog.java
@@ -1,61 +1,28 @@
package org.jabref.gui.backup;
-import java.io.IOException;
import java.nio.file.Path;
-import java.util.Optional;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
-import javafx.scene.control.Hyperlink;
import org.jabref.gui.FXDialog;
-import org.jabref.gui.desktop.os.NativeDesktop;
-import org.jabref.gui.frame.ExternalApplicationsPreferences;
import org.jabref.logic.l10n.Localization;
-import org.jabref.logic.util.BackupFileType;
-import org.jabref.logic.util.io.BackupFileUtil;
-
-import org.controlsfx.control.HyperlinkLabel;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
public class BackupResolverDialog extends FXDialog {
- public static final ButtonType RESTORE_FROM_BACKUP = new ButtonType(Localization.lang("Restore from backup"), ButtonBar.ButtonData.OK_DONE);
- public static final ButtonType REVIEW_BACKUP = new ButtonType(Localization.lang("Review backup"), ButtonBar.ButtonData.LEFT);
+ public static final ButtonType RESTORE_FROM_BACKUP = new ButtonType(Localization.lang("Restore from latest backup"), ButtonBar.ButtonData.OK_DONE);
+ public static final ButtonType REVIEW_BACKUP = new ButtonType(Localization.lang("Review latest backup"), ButtonBar.ButtonData.LEFT);
public static final ButtonType IGNORE_BACKUP = new ButtonType(Localization.lang("Ignore backup"), ButtonBar.ButtonData.CANCEL_CLOSE);
+ public static final ButtonType COMPARE_OLDER_BACKUP = new ButtonType("Compare older backup", ButtonBar.ButtonData.LEFT);
- private static final Logger LOGGER = LoggerFactory.getLogger(BackupResolverDialog.class);
-
- public BackupResolverDialog(Path originalPath, Path backupDir, ExternalApplicationsPreferences externalApplicationsPreferences) {
+ public BackupResolverDialog(Path originalPath) {
super(AlertType.CONFIRMATION, Localization.lang("Backup found"), true);
setHeaderText(null);
getDialogPane().setMinHeight(180);
- getDialogPane().getButtonTypes().setAll(RESTORE_FROM_BACKUP, REVIEW_BACKUP, IGNORE_BACKUP);
+ getDialogPane().getButtonTypes().setAll(RESTORE_FROM_BACKUP, REVIEW_BACKUP, IGNORE_BACKUP, COMPARE_OLDER_BACKUP);
- Optional backupPathOpt = BackupFileUtil.getPathOfLatestExistingBackupFile(originalPath, BackupFileType.BACKUP, backupDir);
- String backupFilename = backupPathOpt.map(Path::getFileName).map(Path::toString).orElse(Localization.lang("File not found"));
- String content = Localization.lang("A backup file for '%0' was found at [%1]", originalPath.getFileName().toString(), backupFilename) + "\n" +
+ String content = Localization.lang("A backup for '%0' was found.", originalPath.getFileName().toString()) + "\n" +
Localization.lang("This could indicate that JabRef did not shut down cleanly last time the file was used.") + "\n\n" +
Localization.lang("Do you want to recover the library from the backup file?");
setContentText(content);
-
- HyperlinkLabel contentLabel = new HyperlinkLabel(content);
- contentLabel.setPrefWidth(360);
- contentLabel.setOnAction(e -> {
- if (backupPathOpt.isPresent()) {
- if (!(e.getSource() instanceof Hyperlink)) {
- return;
- }
- String clickedLinkText = ((Hyperlink) (e.getSource())).getText();
- if (backupFilename.equals(clickedLinkText)) {
- try {
- NativeDesktop.openFolderAndSelectFile(backupPathOpt.get(), externalApplicationsPreferences, null);
- } catch (IOException ex) {
- LOGGER.error("Could not open backup folder", ex);
- }
- }
- }
- });
- getDialogPane().setContent(contentLabel);
}
}
diff --git a/src/main/java/org/jabref/gui/dialogs/BackupUIManager.java b/src/main/java/org/jabref/gui/dialogs/BackupUIManager.java
index 08dd120ab78..21760488bb8 100644
--- a/src/main/java/org/jabref/gui/dialogs/BackupUIManager.java
+++ b/src/main/java/org/jabref/gui/dialogs/BackupUIManager.java
@@ -12,13 +12,15 @@
import org.jabref.gui.DialogService;
import org.jabref.gui.LibraryTab;
import org.jabref.gui.StateManager;
-import org.jabref.gui.autosaveandbackup.BackupManager;
+import org.jabref.gui.autosaveandbackup.BackupManagerGit;
+import org.jabref.gui.backup.BackupChoiceDialog;
+import org.jabref.gui.backup.BackupChoiceDialogRecord;
+import org.jabref.gui.backup.BackupEntry;
import org.jabref.gui.backup.BackupResolverDialog;
import org.jabref.gui.collab.DatabaseChange;
import org.jabref.gui.collab.DatabaseChangeList;
import org.jabref.gui.collab.DatabaseChangeResolverFactory;
import org.jabref.gui.collab.DatabaseChangesResolverDialog;
-import org.jabref.gui.frame.ExternalApplicationsPreferences;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.gui.undo.NamedCompound;
import org.jabref.gui.util.UiTaskExecutor;
@@ -26,17 +28,18 @@
import org.jabref.logic.importer.OpenDatabase;
import org.jabref.logic.importer.ParserResult;
import org.jabref.logic.l10n.Localization;
-import org.jabref.logic.util.BackupFileType;
-import org.jabref.logic.util.io.BackupFileUtil;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.util.DummyFileUpdateMonitor;
import org.jabref.model.util.FileUpdateMonitor;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Stores all user dialogs related to {@link BackupManager}.
+ * Stores all user dialogs related to {@link BackupManagerGit}.
*/
public class BackupUIManager {
private static final Logger LOGGER = LoggerFactory.getLogger(BackupUIManager.class);
@@ -50,28 +53,66 @@ public static Optional showRestoreBackupDialog(DialogService dialo
FileUpdateMonitor fileUpdateMonitor,
UndoManager undoManager,
StateManager stateManager) {
+ LOGGER.info("Show restore backup dialog");
var actionOpt = showBackupResolverDialog(
dialogService,
- preferences.getExternalApplicationsPreferences(),
- originalPath,
- preferences.getFilePreferences().getBackupDirectory());
+ originalPath);
+
return actionOpt.flatMap(action -> {
- if (action == BackupResolverDialog.RESTORE_FROM_BACKUP) {
- BackupManager.restoreBackup(originalPath, preferences.getFilePreferences().getBackupDirectory());
- return Optional.empty();
- } else if (action == BackupResolverDialog.REVIEW_BACKUP) {
- return showReviewBackupDialog(dialogService, originalPath, preferences, fileUpdateMonitor, undoManager, stateManager);
+ try {
+
+ List commits = BackupManagerGit.retrieveCommits(originalPath, preferences.getFilePreferences().getBackupDirectory(), -1);
+ List backups = BackupManagerGit.retrieveCommitDetails(commits, originalPath, preferences.getFilePreferences().getBackupDirectory()).reversed();
+
+ if (action == BackupResolverDialog.RESTORE_FROM_BACKUP) {
+ ObjectId commitId = backups.getFirst().getId();
+
+ BackupManagerGit.restoreBackup(originalPath, preferences.getFilePreferences().getBackupDirectory(), commitId);
+
+ return Optional.empty();
+ } else if (action == BackupResolverDialog.REVIEW_BACKUP) {
+ ObjectId commitId = backups.getFirst().getId();
+
+ return showReviewBackupDialog(dialogService, originalPath, preferences, fileUpdateMonitor, undoManager, stateManager, commitId, commitId);
+ } else if (action == BackupResolverDialog.COMPARE_OLDER_BACKUP) {
+ var recordBackupChoice = showBackupChoiceDialog(dialogService, preferences, backups);
+
+ if (recordBackupChoice.isEmpty()) {
+ return Optional.empty();
+ }
+
+ if (recordBackupChoice.get().action() == BackupChoiceDialog.RESTORE_BACKUP) {
+ LOGGER.warn(recordBackupChoice.get().entry().getSize());
+ ObjectId commitId = recordBackupChoice.get().entry().getId();
+ BackupManagerGit.restoreBackup(originalPath, preferences.getFilePreferences().getBackupDirectory(), commitId);
+ return Optional.empty();
+ }
+ if (recordBackupChoice.get().action() == BackupChoiceDialog.REVIEW_BACKUP) {
+ LOGGER.warn(recordBackupChoice.get().entry().getSize());
+ ObjectId latestCommitId = backups.getFirst().getId();
+ ObjectId commitId = recordBackupChoice.get().entry().getId();
+ return showReviewBackupDialog(dialogService, originalPath, preferences, fileUpdateMonitor, undoManager, stateManager, commitId, latestCommitId);
+ }
+ }
+ } catch (GitAPIException | IOException e) {
+ throw new RuntimeException(e);
}
return Optional.empty();
});
}
private static Optional showBackupResolverDialog(DialogService dialogService,
- ExternalApplicationsPreferences externalApplicationsPreferences,
- Path originalPath,
- Path backupDir) {
+ Path originalPath) {
return UiTaskExecutor.runInJavaFXThread(
- () -> dialogService.showCustomDialogAndWait(new BackupResolverDialog(originalPath, backupDir, externalApplicationsPreferences)));
+ () -> dialogService.showCustomDialogAndWait(new BackupResolverDialog(originalPath)));
+ }
+
+ private static Optional showBackupChoiceDialog(DialogService dialogService,
+ GuiPreferences preferences,
+ List backups) {
+ Path backupDirectory = preferences.getFilePreferences().getBackupDirectory();
+ return UiTaskExecutor.runInJavaFXThread(
+ () -> dialogService.showCustomDialogAndWait(new BackupChoiceDialog(backupDirectory, backups)));
}
private static Optional showReviewBackupDialog(
@@ -80,7 +121,9 @@ private static Optional showReviewBackupDialog(
GuiPreferences preferences,
FileUpdateMonitor fileUpdateMonitor,
UndoManager undoManager,
- StateManager stateManager) {
+ StateManager stateManager,
+ ObjectId commitIdToReview,
+ ObjectId latestCommitId) {
try {
ImportFormatPreferences importFormatPreferences = preferences.getImportFormatPreferences();
@@ -89,8 +132,13 @@ private static Optional showReviewBackupDialog(
// This will be modified by using the `DatabaseChangesResolverDialog`.
BibDatabaseContext originalDatabase = originalParserResult.getDatabaseContext();
- Path backupPath = BackupFileUtil.getPathOfLatestExistingBackupFile(originalPath, BackupFileType.BACKUP, preferences.getFilePreferences().getBackupDirectory()).orElseThrow();
- BibDatabaseContext backupDatabase = OpenDatabase.loadDatabase(backupPath, importFormatPreferences, new DummyFileUpdateMonitor()).getDatabaseContext();
+ Path backupPath = preferences.getFilePreferences().getBackupDirectory();
+
+ BackupManagerGit.writeBackupFileToCommit(originalPath, backupPath, commitIdToReview);
+
+ Path backupFilePath = BackupManagerGit.getBackupFilePath(originalPath, backupPath);
+
+ BibDatabaseContext backupDatabase = OpenDatabase.loadDatabase(backupFilePath, importFormatPreferences, new DummyFileUpdateMonitor()).getDatabaseContext();
DatabaseChangeResolverFactory changeResolverFactory = new DatabaseChangeResolverFactory(dialogService, originalDatabase, preferences);
@@ -119,6 +167,7 @@ private static Optional showReviewBackupDialog(
}
// In case not all changes are resolved, start from scratch
+ BackupManagerGit.writeBackupFileToCommit(originalPath, backupPath, latestCommitId);
return showRestoreBackupDialog(dialogService, originalPath, preferences, fileUpdateMonitor, undoManager, stateManager);
});
} catch (IOException e) {
diff --git a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java
index 10487ead6ad..f55ec43bd0d 100644
--- a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java
+++ b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java
@@ -21,7 +21,7 @@
import org.jabref.gui.DialogService;
import org.jabref.gui.LibraryTab;
import org.jabref.gui.autosaveandbackup.AutosaveManager;
-import org.jabref.gui.autosaveandbackup.BackupManager;
+import org.jabref.gui.autosaveandbackup.BackupManagerGit;
import org.jabref.gui.maintable.BibEntryTableViewModel;
import org.jabref.gui.maintable.columns.MainTableColumn;
import org.jabref.gui.preferences.GuiPreferences;
@@ -44,6 +44,7 @@
import org.jabref.model.metadata.SaveOrder;
import org.jabref.model.metadata.SelfContainedSaveOrder;
+import org.eclipse.jgit.api.errors.GitAPIException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -87,11 +88,18 @@ public boolean save(SaveDatabaseMode mode) {
/**
* Asks the user for the path and saves afterward
*/
+
public void saveAs() {
- askForSavePath().ifPresent(this::saveAs);
+ askForSavePath().ifPresent(path -> {
+ try {
+ saveAs(path);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to save the database", e);
+ }
+ });
}
- public boolean saveAs(Path file) {
+ public boolean saveAs(Path file) throws GitAPIException, IOException {
return this.saveAs(file, SaveDatabaseMode.NORMAL);
}
@@ -134,14 +142,14 @@ public void saveSelectedAsPlain() {
* successful save.
* @return true on successful save
*/
- boolean saveAs(Path file, SaveDatabaseMode mode) {
+ boolean saveAs(Path file, SaveDatabaseMode mode) throws GitAPIException, IOException {
BibDatabaseContext context = libraryTab.getBibDatabaseContext();
Optional databasePath = context.getDatabasePath();
if (databasePath.isPresent()) {
- // Close AutosaveManager, BackupManager, and IndexManager for original library
+ // Close AutosaveManager, BackupManagerGit, and IndexManager for original library
AutosaveManager.shutdown(context);
- BackupManager.shutdown(context, this.preferences.getFilePreferences().getBackupDirectory(), preferences.getFilePreferences().shouldCreateBackup());
+ BackupManagerGit.shutdown(context, preferences.getFilePreferences().getBackupDirectory(), preferences.getFilePreferences().shouldCreateBackup());
libraryTab.closeIndexManger();
}
@@ -160,7 +168,7 @@ boolean saveAs(Path file, SaveDatabaseMode mode) {
context.setDatabasePath(file);
libraryTab.updateTabTitle(false);
- // Reset (here: uninstall and install again) AutosaveManager, BackupManager and IndexManager for the new file name
+ // Reset (here: uninstall and install again) AutosaveManager, BackupManagerGit and IndexManager for the new file name
libraryTab.resetChangeMonitor();
libraryTab.installAutosaveManagerAndBackupManager();
libraryTab.createIndexManager();
@@ -204,7 +212,14 @@ private boolean save(BibDatabaseContext bibDatabaseContext, SaveDatabaseMode mod
Optional databasePath = bibDatabaseContext.getDatabasePath();
if (databasePath.isEmpty()) {
Optional savePath = askForSavePath();
- return savePath.filter(path -> saveAs(path, mode)).isPresent();
+ return savePath.filter(path -> {
+ try {
+ return saveAs(path, mode);
+ } catch (GitAPIException | IOException e) {
+ LOGGER.error("A problem occurred when trying to save the file %s".formatted(path), e);
+ throw new RuntimeException(e);
+ }
+ }).isPresent();
}
return save(databasePath.get(), mode);
diff --git a/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java b/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java
index 748c78db227..e1acb386292 100644
--- a/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java
+++ b/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java
@@ -18,7 +18,7 @@
import org.jabref.gui.LibraryTabContainer;
import org.jabref.gui.StateManager;
import org.jabref.gui.actions.SimpleCommand;
-import org.jabref.gui.autosaveandbackup.BackupManager;
+import org.jabref.gui.autosaveandbackup.BackupManagerGit;
import org.jabref.gui.dialogs.BackupUIManager;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.gui.shared.SharedDatabaseUIManager;
@@ -73,15 +73,15 @@ public class OpenDatabaseAction extends SimpleCommand {
private final TaskExecutor taskExecutor;
public OpenDatabaseAction(LibraryTabContainer tabContainer,
- GuiPreferences preferences,
- AiService aiService,
- DialogService dialogService,
- StateManager stateManager,
- FileUpdateMonitor fileUpdateMonitor,
- BibEntryTypesManager entryTypesManager,
- CountingUndoManager undoManager,
- ClipBoardManager clipBoardManager,
- TaskExecutor taskExecutor) {
+ GuiPreferences preferences,
+ AiService aiService,
+ DialogService dialogService,
+ StateManager stateManager,
+ FileUpdateMonitor fileUpdateMonitor,
+ BibEntryTypesManager entryTypesManager,
+ CountingUndoManager undoManager,
+ ClipBoardManager clipBoardManager,
+ TaskExecutor taskExecutor) {
this.tabContainer = tabContainer;
this.preferences = preferences;
this.aiService = aiService;
@@ -96,7 +96,9 @@ public OpenDatabaseAction(LibraryTabContainer tabContainer,
public static void performPostOpenActions(ParserResult result, DialogService dialogService, CliPreferences preferences) {
for (GUIPostOpenAction action : OpenDatabaseAction.POST_OPEN_ACTIONS) {
+ LOGGER.info("Performing post open action: {}", action.getClass().getSimpleName());
if (action.isActionNecessary(result, dialogService, preferences)) {
+ LOGGER.info("Action is necessary");
action.performAction(result, dialogService, preferences);
}
}
@@ -104,6 +106,7 @@ public static void performPostOpenActions(ParserResult result, DialogService dia
@Override
public void execute() {
+ LOGGER.info("OpenDatabaseAction");
List filesToOpen = getFilesToOpen();
openFiles(new ArrayList<>(filesToOpen));
}
@@ -118,6 +121,7 @@ List getFilesToOpen() {
} catch (IllegalArgumentException e) {
// See https://github.com/JabRef/jabref/issues/10548 for details
// Rebuild a new config with the home directory
+ LOGGER.error("Error while opening file dialog", e);
FileDialogConfiguration homeDirectoryConfig = getFileDialogConfiguration(Directories.getUserDirectory());
filesToOpen = dialogService.showFileOpenDialogAndGetMultipleFiles(homeDirectoryConfig);
}
@@ -242,16 +246,29 @@ private void openTheFile(Path file) {
}
private ParserResult loadDatabase(Path file) throws Exception {
+ LOGGER.info("Opening {}", file);
Path fileToLoad = file.toAbsolutePath();
dialogService.notify(Localization.lang("Opening") + ": '" + file + "'");
+ LOGGER.info("Opening {}", fileToLoad);
preferences.getFilePreferences().setWorkingDirectory(fileToLoad.getParent());
Path backupDir = preferences.getFilePreferences().getBackupDirectory();
+ // To debug
+ if (!Files.exists(backupDir)) {
+ LOGGER.error("Backup directory does not exist: {}", backupDir);
+ throw new IOException("Backup directory not found: " + backupDir);
+ }
+ if (!Files.isReadable(backupDir)) {
+ LOGGER.error("Backup directory is not readable: {}", backupDir);
+ throw new IOException("Cannot read from backup directory: " + backupDir);
+ }
+
ParserResult parserResult = null;
- if (BackupManager.backupFileDiffers(fileToLoad, backupDir)) {
+ if (BackupManagerGit.backupGitDiffers(fileToLoad, backupDir)) {
// In case the backup differs, ask the user what to do.
+ LOGGER.info("Backup differs from saved file, ask the user what to do");
// In case the user opted for restoring a backup, the content of the backup is contained in parserResult.
parserResult = BackupUIManager.showRestoreBackupDialog(dialogService, fileToLoad, preferences, fileUpdateMonitor, undoManager, stateManager)
.orElse(null);
@@ -260,6 +277,7 @@ private ParserResult loadDatabase(Path file) throws Exception {
try {
if (parserResult == null) {
// No backup was restored, do the "normal" loading
+ LOGGER.info("No backup was restored, do the \"normal\" loading");
parserResult = OpenDatabase.loadDatabase(fileToLoad,
preferences.getImportFormatPreferences(),
fileUpdateMonitor);
@@ -277,18 +295,18 @@ private ParserResult loadDatabase(Path file) throws Exception {
}
if (parserResult.getDatabase().isShared()) {
- openSharedDatabase(
- parserResult,
- tabContainer,
- dialogService,
- preferences,
- aiService,
- stateManager,
- entryTypesManager,
- fileUpdateMonitor,
- undoManager,
- clipboardManager,
- taskExecutor);
+ openSharedDatabase(
+ parserResult,
+ tabContainer,
+ dialogService,
+ preferences,
+ aiService,
+ stateManager,
+ entryTypesManager,
+ fileUpdateMonitor,
+ undoManager,
+ clipboardManager,
+ taskExecutor);
}
return parserResult;
}
diff --git a/src/main/java/org/jabref/logic/exporter/SaveConfiguration.java b/src/main/java/org/jabref/logic/exporter/SaveConfiguration.java
index 358659113e5..f92fdf8e8ef 100644
--- a/src/main/java/org/jabref/logic/exporter/SaveConfiguration.java
+++ b/src/main/java/org/jabref/logic/exporter/SaveConfiguration.java
@@ -1,6 +1,6 @@
package org.jabref.logic.exporter;
-import org.jabref.gui.autosaveandbackup.BackupManager;
+import org.jabref.gui.autosaveandbackup.BackupManagerGit;
import org.jabref.model.metadata.SaveOrder;
public class SaveConfiguration {
@@ -44,7 +44,7 @@ public boolean shouldMakeBackup() {
}
/**
- * Required by {@link BackupManager}. Should not be used in other settings
+ * Required by {@link BackupManagerGit}. Should not be used in other settings
*
* @param newMakeBackup whether a backup (.bak file) should be made
*/
diff --git a/src/main/java/org/jabref/logic/util/BackupFileType.java b/src/main/java/org/jabref/logic/util/BackupFileType.java
index b24cc3faeb8..3fbf331daf4 100644
--- a/src/main/java/org/jabref/logic/util/BackupFileType.java
+++ b/src/main/java/org/jabref/logic/util/BackupFileType.java
@@ -5,7 +5,7 @@
public enum BackupFileType implements FileType {
- // Used at BackupManager
+ // Used at BackupManagerGit
BACKUP("Backup", "bak"),
// Used when writing the .bib file. See {@link org.jabref.logic.exporter.AtomicFileWriter}
diff --git a/src/main/java/org/jabref/logic/util/io/BackupFileUtil.java b/src/main/java/org/jabref/logic/util/io/BackupFileUtil.java
index 8df2eb600d7..9b2e1948089 100644
--- a/src/main/java/org/jabref/logic/util/io/BackupFileUtil.java
+++ b/src/main/java/org/jabref/logic/util/io/BackupFileUtil.java
@@ -9,7 +9,7 @@
import java.util.HexFormat;
import java.util.Optional;
-import org.jabref.gui.autosaveandbackup.BackupManager;
+import org.jabref.gui.autosaveandbackup.BackupManagerGit;
import org.jabref.logic.util.BackupFileType;
import org.slf4j.Logger;
@@ -34,7 +34,7 @@ private BackupFileUtil() {
* In case that fails, the return path of the .bak file is set to be next to the .bib file
*
*
- * Note that this backup is different from the .sav
file generated by {@link BackupManager}
+ * Note that this backup is different from the .sav
file generated by {@link BackupManagerGit}
* (and configured in the preferences as "make backups")
*
*/
diff --git a/src/main/resources/csl-locales b/src/main/resources/csl-locales
index 96d704de2fc..8bc2af16f51 160000
--- a/src/main/resources/csl-locales
+++ b/src/main/resources/csl-locales
@@ -1 +1 @@
-Subproject commit 96d704de2fc7b930ae4a0ec4686a7143bb4a0d33
+Subproject commit 8bc2af16f5180a8e4fb591c2be916650f75bb8f6
diff --git a/src/main/resources/csl-styles b/src/main/resources/csl-styles
index 6b7b611908b..b413a778b81 160000
--- a/src/main/resources/csl-styles
+++ b/src/main/resources/csl-styles
@@ -1 +1 @@
-Subproject commit 6b7b611908b20c91f34110d1c9489fb3278e0ef5
+Subproject commit b413a778b8170cf5aebbb9aeffec62cfd068e19e
diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties
index 32bc777fcc6..f552dad398b 100644
--- a/src/main/resources/l10n/JabRef_en.properties
+++ b/src/main/resources/l10n/JabRef_en.properties
@@ -2689,9 +2689,17 @@ Keep\ existing\ entry=Keep existing entry
No\ entries\ corresponding\ to\ given\ query=No entries corresponding to given query
Review\ backup=Review\ backup
-A\ backup\ file\ for\ '%0'\ was\ found\ at\ [%1]=A backup file for '%0' was found at [%1]
+A\ backup\ for\ '%0'\ was\ found.=A backup for '%0' was found.
Do\ you\ want\ to\ recover\ the\ library\ from\ the\ backup\ file?=Do you want to recover the library from the backup file?
This\ could\ indicate\ that\ JabRef\ did\ not\ shut\ down\ cleanly\ last\ time\ the\ file\ was\ used.=This could indicate that JabRef did not shut down cleanly last time the file was used.
+Choose\ backup\ file=Choose backup file
+Date\ of\ Backup=Date of Backup
+Do\ you\ want\ to\ recover\ the\ library\ from\ a\ backup\ file?=Do you want to recover the library from a backup file?
+It\ looks\ like\ JabRef\ did\ not\ shut\ down\ cleanly\ last\ time\ the\ file\ was\ used.=It looks like JabRef did not shut down cleanly last time the file was used.
+Number\ of\ Entries=Number of Entries
+Restore\ from\ latest\ backup=Restore from latest backup
+Review\ latest\ backup=Review latest backup
+Size\ of\ Backup=Size of Backup
Use\ the\ field\ FJournal\ to\ store\ the\ full\ journal\ name\ for\ (un)abbreviations\ in\ the\ entry=Use the field FJournal to store the full journal name for (un)abbreviations in the entry
diff --git a/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerDiscardedTest.java b/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerDiscardedTest.java
deleted file mode 100644
index 7619e0c9ae2..00000000000
--- a/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerDiscardedTest.java
+++ /dev/null
@@ -1,109 +0,0 @@
-package org.jabref.gui.autosaveandbackup;
-
-import java.io.IOException;
-import java.io.Writer;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-import org.jabref.gui.LibraryTab;
-import org.jabref.logic.exporter.AtomicFileWriter;
-import org.jabref.logic.exporter.BibDatabaseWriter;
-import org.jabref.logic.exporter.BibWriter;
-import org.jabref.logic.exporter.BibtexDatabaseWriter;
-import org.jabref.logic.exporter.SelfContainedSaveConfiguration;
-import org.jabref.logic.preferences.CliPreferences;
-import org.jabref.model.database.BibDatabase;
-import org.jabref.model.database.BibDatabaseContext;
-import org.jabref.model.entry.BibEntry;
-import org.jabref.model.entry.BibEntryTypesManager;
-import org.jabref.model.entry.field.StandardField;
-import org.jabref.model.metadata.SaveOrder;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-import org.mockito.Answers;
-
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.mock;
-
-/**
- * Test for "discarded" flag
- */
-class BackupManagerDiscardedTest {
-
- private BibDatabaseContext bibDatabaseContext;
- private BackupManager backupManager;
- private Path testBib;
- private SelfContainedSaveConfiguration saveConfiguration;
- private CliPreferences preferences;
- private BibEntryTypesManager bibEntryTypesManager;
- private Path backupDir;
-
- @BeforeEach
- void setup(@TempDir Path tempDir) throws Exception {
- this.backupDir = tempDir.resolve("backups");
- Files.createDirectories(backupDir);
-
- testBib = tempDir.resolve("test.bib");
-
- bibDatabaseContext = new BibDatabaseContext(new BibDatabase());
- bibDatabaseContext.setDatabasePath(testBib);
-
- bibEntryTypesManager = new BibEntryTypesManager();
- saveConfiguration = new SelfContainedSaveConfiguration(SaveOrder.getDefaultSaveOrder(), false, BibDatabaseWriter.SaveType.WITH_JABREF_META_DATA, false);
- preferences = mock(CliPreferences.class, Answers.RETURNS_DEEP_STUBS);
-
- saveDatabase();
-
- backupManager = new BackupManager(mock(LibraryTab.class), bibDatabaseContext, bibEntryTypesManager, preferences);
-
- makeBackup();
- }
-
- private void saveDatabase() throws IOException {
- try (Writer writer = new AtomicFileWriter(testBib, StandardCharsets.UTF_8, false)) {
- BibWriter bibWriter = new BibWriter(writer, bibDatabaseContext.getDatabase().getNewLineSeparator());
- new BibtexDatabaseWriter(
- bibWriter,
- saveConfiguration,
- preferences.getFieldPreferences(),
- preferences.getCitationKeyPatternPreferences(),
- bibEntryTypesManager)
- .saveDatabase(bibDatabaseContext);
- }
- }
-
- private void databaseModification() {
- bibDatabaseContext.getDatabase().insertEntry(new BibEntry().withField(StandardField.NOTE, "test"));
- }
-
- private void makeBackup() {
- backupManager.determineBackupPathForNewBackup(backupDir).ifPresent(path -> backupManager.performBackup(path));
- }
-
- @Test
- void noDiscardingAChangeLeadsToNewerBackupBeReported() throws Exception {
- databaseModification();
- makeBackup();
- assertTrue(BackupManager.backupFileDiffers(testBib, backupDir));
- }
-
- @Test
- void noDiscardingASavedChange() throws Exception {
- databaseModification();
- makeBackup();
- saveDatabase();
- assertFalse(BackupManager.backupFileDiffers(testBib, backupDir));
- }
-
- @Test
- void discardingAChangeLeadsToNewerBackupToBeIgnored() throws Exception {
- databaseModification();
- makeBackup();
- backupManager.discardBackup(backupDir);
- assertFalse(BackupManager.backupFileDiffers(testBib, backupDir));
- }
-}
diff --git a/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerGitTest.java b/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerGitTest.java
new file mode 100644
index 00000000000..6e698fb5018
--- /dev/null
+++ b/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerGitTest.java
@@ -0,0 +1,202 @@
+
+package org.jabref.gui.autosaveandbackup;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jabref.gui.LibraryTab;
+import org.jabref.logic.FilePreferences;
+import org.jabref.logic.preferences.CliPreferences;
+import org.jabref.model.database.BibDatabase;
+import org.jabref.model.database.BibDatabaseContext;
+import org.jabref.model.entry.BibEntry;
+import org.jabref.model.entry.BibEntryTypesManager;
+import org.jabref.model.entry.field.StandardField;
+import org.jabref.model.entry.types.StandardEntryType;
+import org.jabref.model.metadata.MetaData;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class BackupManagerGitTest {
+
+ private Path tempDir1;
+ private Path tempDir2;
+ private Path tempDir;
+ private LibraryTab mockLibraryTab;
+ private BibDatabaseContext mockDatabaseContext1;
+ private BibDatabaseContext mockDatabaseContext2;
+ private BibEntryTypesManager mockEntryTypesManager;
+ private CliPreferences mockPreferences;
+ private Path dataBasePath1;
+ private Path dataBasePath2;
+
+ @BeforeEach
+ public void setUp(@TempDir Path tempDir) throws IOException, GitAPIException {
+
+ // creating Entries
+ BibEntry entry1 = new BibEntry(StandardEntryType.Article)
+ .withField(StandardField.AUTHOR, "Garcia, Maria and Lee, David")
+ .withField(StandardField.JOURNAL, "International Review of Physics")
+ .withField(StandardField.NUMBER, "6")
+ .withField(StandardField.PAGES, "789--810")
+ .withField(StandardField.TITLE, "Quantum Entanglement in Superconductors")
+ .withField(StandardField.VOLUME, "28")
+ .withField(StandardField.ISSUE, "3")
+ .withField(StandardField.YEAR, "2021")
+ .withCitationKey("Garcia_2021");
+ BibEntry entry2 = new BibEntry(StandardEntryType.Book)
+ .withField(StandardField.AUTHOR, "Smith, John")
+ .withField(StandardField.TITLE, "Advanced Quantum Mechanics")
+ .withField(StandardField.PUBLISHER, "Physics Press")
+ .withField(StandardField.YEAR, "2019")
+ .withField(StandardField.ISBN, "978-3-16-148410-0")
+ .withCitationKey("Smith_2019");
+
+ BibEntry entry3 = new BibEntry(StandardEntryType.InProceedings)
+ .withField(StandardField.AUTHOR, "Doe, Jane and Brown, Alice")
+ .withField(StandardField.TITLE, "Machine Learning in Quantum Computing")
+ .withField(StandardField.BOOKTITLE, "Proceedings of the International Conference on Quantum Computing")
+ .withField(StandardField.YEAR, "2020")
+ .withField(StandardField.PAGES, "123-130")
+ .withCitationKey("Doe_2020");
+
+ BibEntry entry4 = new BibEntry(StandardEntryType.Thesis)
+ .withField(StandardField.AUTHOR, "Johnson, Emily")
+ .withField(StandardField.TITLE, "Quantum Algorithms for Data Analysis")
+ .withField(StandardField.SCHOOL, "University of Quantum Studies")
+ .withField(StandardField.YEAR, "2022")
+ .withField(StandardField.TYPE, "PhD Thesis")
+ .withCitationKey("Johnson_2022");
+
+ List entries1 = new ArrayList<>();
+ entries1.add(entry1);
+ entries1.add(entry2);
+ List entries2 = new ArrayList<>();
+ entries2.add(entry3);
+ entries2.add(entry4);
+
+ // Initializing BibDatabases
+ BibDatabase bibDatabase1 = new BibDatabase(entries1);
+ BibDatabase bibDatabase2 = new BibDatabase(entries2);
+
+ // Create temporary backup directories and .bib files
+ this.tempDir = tempDir.resolve("");
+ this.tempDir1 = tempDir.resolve("backup1");
+ this.tempDir2 = tempDir.resolve("backup2");
+ dataBasePath1 = tempDir1.resolve("test1.bib");
+ dataBasePath2 = tempDir2.resolve("test2.bib");
+
+ // Ensure the directories exists
+ Files.createDirectories(this.tempDir);
+ Files.createDirectories(this.tempDir1);
+ Files.createDirectories(this.tempDir2);
+
+ // creating the bibDatabaseContexts
+ mockDatabaseContext1 = new BibDatabaseContext(bibDatabase1, new MetaData(), dataBasePath1);
+ mockDatabaseContext2 = new BibDatabaseContext(bibDatabase2, new MetaData(), dataBasePath2);
+ mockEntryTypesManager = mock(BibEntryTypesManager.class);
+
+ // creating the mockLibraryTab
+ mockLibraryTab = mock(LibraryTab.class);
+
+ // creating the mockPreferences
+ mockPreferences = mock(CliPreferences.class);
+ FilePreferences filePreferences = mock(FilePreferences.class);
+ when(mockPreferences.getFilePreferences()).thenReturn(filePreferences);
+ when(filePreferences.getBackupDirectory()).thenReturn(tempDir);
+
+ // creating the content of the .bib files
+ Files.writeString(dataBasePath1, "Mock content for testing 1"); // Create the file
+ Files.writeString(dataBasePath2, "Mock content for testing 2"); // Create the file
+ }
+
+ @Test
+ void initializationCreatesBackupDirectory() throws IOException, GitAPIException {
+ // Create BackupManagerGit
+ BackupManagerGit manager1 = new BackupManagerGit(mockLibraryTab, mockDatabaseContext1, mockEntryTypesManager, tempDir);
+ BackupManagerGit manager2 = new BackupManagerGit(mockLibraryTab, mockDatabaseContext2, mockEntryTypesManager, tempDir);
+ // Check if the backup directory exists
+ assertTrue(Files.exists(tempDir), " directory should be created which contains .git and single copies og .bib");
+ assertTrue(Files.exists(tempDir1), "Backup directory should be created during initialization.");
+ assertTrue(Files.exists(tempDir2), "Backup directory should be created during initialization.");
+ }
+
+ @Test
+ void gitInitialization() throws IOException, GitAPIException {
+ // Initialize Git
+ BackupManagerGit.ensureGitInitialized(tempDir);
+ // Verify that the .git directory is created
+ Path gitDir = tempDir.resolve(".git");
+ assertTrue(Files.exists(gitDir), ".git directory should be created during Git initialization.");
+ }
+
+ @Test
+ void backupFileCopiedToDirectory() throws IOException, GitAPIException {
+ BackupManagerGit manager1 = new BackupManagerGit(mockLibraryTab, mockDatabaseContext1, mockEntryTypesManager, tempDir);
+ BackupManagerGit manager2 = new BackupManagerGit(mockLibraryTab, mockDatabaseContext2, mockEntryTypesManager, tempDir);
+
+ // Generate the expected backup file names
+ String uuid1 = BackupManagerGit.getOrGenerateFileUuid(dataBasePath1);
+ String uuid2 = BackupManagerGit.getOrGenerateFileUuid(dataBasePath2);
+
+ String backupFileName1 = dataBasePath1.getFileName().toString().replace(".bib", "") + "_" + uuid1 + ".bib";
+ String backupFileName2 = dataBasePath2.getFileName().toString().replace(".bib", "") + "_" + uuid2 + ".bib";
+
+ // Verify the file is copied to the backup directory
+ Path backupFile1 = tempDir.resolve(backupFileName1);
+ Path backupFile2 = tempDir.resolve(backupFileName2);
+ assertTrue(Files.exists(backupFile1), "Database file should be copied to the backup directory.");
+ assertTrue(Files.exists(backupFile2), "Database file should be copied to the backup directory.");
+ }
+
+ @Test
+ public void start() throws IOException, GitAPIException {
+ BackupManagerGit startedManager = BackupManagerGit.start(mockLibraryTab, mockDatabaseContext1, mockEntryTypesManager, mockPreferences);
+ assertNotNull(startedManager);
+ }
+
+ @Test
+ void performBackupCommitsChanges() throws IOException, GitAPIException {
+ // Initialize Git
+ BackupManagerGit.ensureGitInitialized(tempDir);
+
+ // Create a test file
+ Path dbFile1 = tempDir.resolve("test1.bib");
+
+ // Create BackupManagerGit and perform backup
+ BackupManagerGit manager = new BackupManagerGit(mockLibraryTab, mockDatabaseContext1, mockEntryTypesManager, tempDir);
+ Files.writeString(dbFile1, "Initial content of test 1");
+
+ BackupManagerGit.copyDatabaseFileToBackupDir(dbFile1, tempDir);
+
+ // Generate the expected backup file name
+ String uuid1 = BackupManagerGit.getOrGenerateFileUuid(dbFile1);
+ String backupFileName1 = dbFile1.getFileName().toString().replace(".bib", "") + "_" + uuid1 + ".bib";
+ Path backupFile1 = tempDir.resolve(backupFileName1);
+
+ // Verify the file is copied to the backup directory
+ assertTrue(Files.exists(backupFile1), "Database file should be copied to the backup directory.");
+
+ manager.performBackup(dbFile1, tempDir);
+
+ // Verify that changes are committed
+ try (Git git = Git.open(tempDir.toFile())) {
+ boolean hasUncommittedChanges = git.status().call().getUncommittedChanges().stream()
+ .anyMatch(file -> file.endsWith(".bib"));
+ assertFalse(hasUncommittedChanges, "Git repository should have no uncommitted .bib file changes after backup.");
+ }
+ }
+}
diff --git a/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerTest.java b/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerTest.java
deleted file mode 100644
index a45d7cbb9c1..00000000000
--- a/src/test/java/org/jabref/gui/autosaveandbackup/BackupManagerTest.java
+++ /dev/null
@@ -1,191 +0,0 @@
-package org.jabref.gui.autosaveandbackup;
-
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.nio.file.attribute.FileTime;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-
-import org.jabref.gui.LibraryTab;
-import org.jabref.logic.FilePreferences;
-import org.jabref.logic.preferences.CliPreferences;
-import org.jabref.logic.util.BackupFileType;
-import org.jabref.logic.util.Directories;
-import org.jabref.logic.util.io.BackupFileUtil;
-import org.jabref.model.database.BibDatabase;
-import org.jabref.model.database.BibDatabaseContext;
-import org.jabref.model.entry.BibEntryTypesManager;
-import org.jabref.model.groups.event.GroupUpdatedEvent;
-import org.jabref.model.metadata.MetaData;
-import org.jabref.model.metadata.event.MetaDataChangedEvent;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-import org.mockito.Answers;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-class BackupManagerTest {
-
- Path backupDir;
-
- @BeforeEach
- void setup(@TempDir Path tempDir) {
- backupDir = tempDir.resolve("backup");
- }
-
- @Test
- void backupFileNameIsCorrectlyGeneratedInAppDataDirectory() {
- Path bibPath = Path.of("tmp", "test.bib");
- backupDir = Directories.getBackupDirectory();
- Path bakPath = BackupManager.getBackupPathForNewBackup(bibPath, backupDir);
-
- // Pattern is "27182d3c--test.bib--", but the hashing is implemented differently on Linux than on Windows
- assertNotEquals("", bakPath);
- }
-
- @Test
- void backupFileIsEqualForNonExistingBackup() throws Exception {
- Path originalFile = Path.of(BackupManagerTest.class.getResource("no-autosave.bib").toURI());
- assertFalse(BackupManager.backupFileDiffers(originalFile, backupDir));
- }
-
- @Test
- void backupFileIsEqual() throws Exception {
- // Prepare test: Create backup file on "right" path
- Path source = Path.of(BackupManagerTest.class.getResource("no-changes.bib.bak").toURI());
- Path target = BackupFileUtil.getPathForNewBackupFileAndCreateDirectory(Path.of(BackupManagerTest.class.getResource("no-changes.bib").toURI()), BackupFileType.BACKUP, backupDir);
- Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
-
- Path originalFile = Path.of(BackupManagerTest.class.getResource("no-changes.bib").toURI());
- assertFalse(BackupManager.backupFileDiffers(originalFile, backupDir));
- }
-
- @Test
- void backupFileDiffers() throws Exception {
- // Prepare test: Create backup file on "right" path
- Path source = Path.of(BackupManagerTest.class.getResource("changes.bib.bak").toURI());
- Path target = BackupFileUtil.getPathForNewBackupFileAndCreateDirectory(Path.of(BackupManagerTest.class.getResource("changes.bib").toURI()), BackupFileType.BACKUP, backupDir);
- Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
-
- Path originalFile = Path.of(BackupManagerTest.class.getResource("changes.bib").toURI());
- assertTrue(BackupManager.backupFileDiffers(originalFile, backupDir));
- }
-
- @Test
- void correctBackupFileDeterminedForMultipleBakFiles() throws Exception {
- Path noChangesBib = Path.of(BackupManagerTest.class.getResource("no-changes.bib").toURI());
- Path noChangesBibBak = Path.of(BackupManagerTest.class.getResource("no-changes.bib.bak").toURI());
-
- // Prepare test: Create backup files on "right" path
- // most recent file does not have any changes
- Path target = BackupFileUtil.getPathForNewBackupFileAndCreateDirectory(noChangesBib, BackupFileType.BACKUP, backupDir);
- Files.copy(noChangesBibBak, target, StandardCopyOption.REPLACE_EXISTING);
-
- // create "older" .bak files containing changes
- for (int i = 0; i < 10; i++) {
- Path changesBibBak = Path.of(BackupManagerTest.class.getResource("changes.bib").toURI());
- Path directory = backupDir;
- String timeSuffix = "2020-02-03--00.00.0" + Integer.toString(i);
- String fileName = BackupFileUtil.getUniqueFilePrefix(noChangesBib) + "--no-changes.bib--" + timeSuffix + ".bak";
- target = directory.resolve(fileName);
- Files.copy(changesBibBak, target, StandardCopyOption.REPLACE_EXISTING);
- }
-
- Path originalFile = noChangesBib;
- assertFalse(BackupManager.backupFileDiffers(originalFile, backupDir));
- }
-
- @Test
- void bakFileWithNewerTimeStampLeadsToDiff() throws Exception {
- Path changesBib = Path.of(BackupManagerTest.class.getResource("changes.bib").toURI());
- Path changesBibBak = Path.of(BackupManagerTest.class.getResource("changes.bib.bak").toURI());
-
- Path target = BackupFileUtil.getPathForNewBackupFileAndCreateDirectory(changesBib, BackupFileType.BACKUP, backupDir);
- Files.copy(changesBibBak, target, StandardCopyOption.REPLACE_EXISTING);
-
- assertTrue(BackupManager.backupFileDiffers(changesBib, backupDir));
- }
-
- @Test
- void bakFileWithOlderTimeStampDoesNotLeadToDiff() throws Exception {
- Path changesBib = Path.of(BackupManagerTest.class.getResource("changes.bib").toURI());
- Path changesBibBak = Path.of(BackupManagerTest.class.getResource("changes.bib.bak").toURI());
-
- Path target = BackupFileUtil.getPathForNewBackupFileAndCreateDirectory(changesBib, BackupFileType.BACKUP, backupDir);
- Files.copy(changesBibBak, target, StandardCopyOption.REPLACE_EXISTING);
-
- // Make .bak file very old
- Files.setLastModifiedTime(target, FileTime.fromMillis(0));
-
- assertFalse(BackupManager.backupFileDiffers(changesBib, backupDir));
- }
-
- @Test
- void shouldNotCreateABackup(@TempDir Path customDir) throws Exception {
- Path backupDir = customDir.resolve("subBackupDir");
- Files.createDirectories(backupDir);
-
- var database = new BibDatabaseContext(new BibDatabase());
- database.setDatabasePath(customDir.resolve("Bibfile.bib"));
-
- var preferences = mock(CliPreferences.class, Answers.RETURNS_DEEP_STUBS);
- var filePreferences = mock(FilePreferences.class);
- when(preferences.getFilePreferences()).thenReturn(filePreferences);
- when(filePreferences.getBackupDirectory()).thenReturn(backupDir);
- when(filePreferences.shouldCreateBackup()).thenReturn(false);
-
- BackupManager manager = BackupManager.start(
- mock(LibraryTab.class),
- database,
- mock(BibEntryTypesManager.class, Answers.RETURNS_DEEP_STUBS),
- preferences);
- manager.listen(new MetaDataChangedEvent(new MetaData()));
-
- BackupManager.shutdown(database, filePreferences.getBackupDirectory(), filePreferences.shouldCreateBackup());
-
- List files = Files.list(backupDir).toList();
- assertEquals(Collections.emptyList(), files);
- }
-
- @Test
- void shouldCreateABackup(@TempDir Path customDir) throws Exception {
- Path backupDir = customDir.resolve("subBackupDir");
- Files.createDirectories(backupDir);
-
- var database = new BibDatabaseContext(new BibDatabase());
- database.setDatabasePath(customDir.resolve("Bibfile.bib"));
-
- var preferences = mock(CliPreferences.class, Answers.RETURNS_DEEP_STUBS);
- var filePreferences = mock(FilePreferences.class);
- when(preferences.getFilePreferences()).thenReturn(filePreferences);
- when(filePreferences.getBackupDirectory()).thenReturn(backupDir);
- when(filePreferences.shouldCreateBackup()).thenReturn(true);
-
- BackupManager manager = BackupManager.start(
- mock(LibraryTab.class),
- database,
- mock(BibEntryTypesManager.class, Answers.RETURNS_DEEP_STUBS),
- preferences);
- manager.listen(new MetaDataChangedEvent(new MetaData()));
-
- Optional fullBackupPath = manager.determineBackupPathForNewBackup(backupDir);
- fullBackupPath.ifPresent(manager::performBackup);
- manager.listen(new GroupUpdatedEvent(new MetaData()));
-
- BackupManager.shutdown(database, backupDir, true);
-
- List files = Files.list(backupDir).sorted().toList();
- // we only know the first backup path because the second one is created on shutdown
- // due to timing issues we cannot test that reliable
- assertEquals(fullBackupPath.get(), files.getFirst());
- }
-}
diff --git a/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java b/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java
index bbc91aa2501..8f5f80c3f22 100644
--- a/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java
+++ b/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java
@@ -33,6 +33,7 @@
import org.jabref.model.metadata.MetaData;
import org.jabref.model.metadata.SaveOrder;
+import org.eclipse.jgit.api.errors.GitAPIException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -67,7 +68,7 @@ void setUp() {
}
@Test
- void saveAsShouldSetWorkingDirectory() {
+ void saveAsShouldSetWorkingDirectory() throws GitAPIException, IOException {
when(dialogService.showFileSaveDialog(any(FileDialogConfiguration.class))).thenReturn(Optional.of(file));
doReturn(true).when(saveDatabaseAction).saveAs(any());
@@ -77,7 +78,7 @@ void saveAsShouldSetWorkingDirectory() {
}
@Test
- void saveAsShouldNotSetWorkingDirectoryIfNotSelected() {
+ void saveAsShouldNotSetWorkingDirectoryIfNotSelected() throws GitAPIException, IOException {
when(dialogService.showFileSaveDialog(any(FileDialogConfiguration.class))).thenReturn(Optional.empty());
doReturn(false).when(saveDatabaseAction).saveAs(any());
@@ -87,7 +88,7 @@ void saveAsShouldNotSetWorkingDirectoryIfNotSelected() {
}
@Test
- void saveShouldShowSaveAsIfDatabaseNotSelected() {
+ void saveShouldShowSaveAsIfDatabaseNotSelected() throws GitAPIException, IOException {
when(dbContext.getDatabasePath()).thenReturn(Optional.empty());
when(dbContext.getLocation()).thenReturn(DatabaseLocation.LOCAL);
when(dialogService.showFileSaveDialog(any())).thenReturn(Optional.of(file));