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));