package org.jabref.gui.exporter; import java.io.File; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.UnsupportedCharsetException; import java.nio.file.Path; import java.util.Optional; import javax.swing.JOptionPane; import javax.swing.JTextArea; import javax.swing.SwingUtilities; import org.jabref.Globals; import org.jabref.JabRefExecutorService; import org.jabref.collab.ChangeScanner; import org.jabref.collab.FileUpdatePanel; import org.jabref.gui.BasePanel; import org.jabref.gui.DialogService; import org.jabref.gui.FXDialogService; import org.jabref.gui.JabRefFrame; import org.jabref.gui.autosaveandbackup.AutosaveUIManager; import org.jabref.gui.util.DefaultTaskExecutor; import org.jabref.gui.util.FileDialogConfiguration; import org.jabref.gui.worker.AbstractWorker; import org.jabref.logic.autosaveandbackup.AutosaveManager; import org.jabref.logic.autosaveandbackup.BackupManager; import org.jabref.logic.exporter.BibtexDatabaseWriter; import org.jabref.logic.exporter.FileSaveSession; import org.jabref.logic.exporter.SaveException; import org.jabref.logic.exporter.SavePreferences; import org.jabref.logic.exporter.SaveSession; import org.jabref.logic.l10n.Encodings; import org.jabref.logic.l10n.Localization; import org.jabref.logic.util.FileExtensions; import org.jabref.logic.util.io.FileBasedLock; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.database.DatabaseLocation; import org.jabref.model.database.event.ChangePropagation; import org.jabref.model.entry.BibEntry; import org.jabref.preferences.JabRefPreferences; import org.jabref.shared.DBMSConnectionProperties; import org.jabref.shared.prefs.SharedDatabasePreferences; import com.jgoodies.forms.builder.FormBuilder; import com.jgoodies.forms.layout.FormLayout; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; /** * Action for the "Save" and "Save as" operations called from BasePanel. This class is also used for * save operations when closing a database or quitting the applications. * * The operations run synchronously, but offload the save operation from the event thread using Spin. * Callers can query whether the operation was canceled, or whether it was successful. */ public class SaveDatabaseAction extends AbstractWorker { private static final Log LOGGER = LogFactory.getLog(SaveDatabaseAction.class); private final BasePanel panel; private final JabRefFrame frame; private boolean success; private boolean canceled; private boolean fileLockedError; private Optional<Path> filePath; public SaveDatabaseAction(BasePanel panel) { this.panel = panel; this.frame = panel.frame(); this.filePath = Optional.empty(); } /** * @param panel BasePanel which contains the database to be saved * @param filePath Path to the file the database should be saved to */ public SaveDatabaseAction(BasePanel panel, Path filePath) { this(panel); this.filePath = Optional.ofNullable(filePath); } @Override public void init() throws Exception { success = false; canceled = false; fileLockedError = false; if (panel.getBibDatabaseContext().getDatabaseFile().isPresent()) { // Check for external modifications: if true, save not performed so do not tell the user a save is underway but return instead. if (checkExternalModification()) { return; } panel.frame().output(Localization.lang("Saving library") + "..."); panel.setSaving(true); } else if (filePath.isPresent()) { // save as directly if the target file location is known saveAs(filePath.get().toFile()); } else { saveAs(); } } @Override public void update() { if (success) { // Reset title of tab frame.setTabTitle(panel, panel.getTabTitle(), panel.getBibDatabaseContext().getDatabaseFile().get().getAbsolutePath()); frame.output(Localization.lang("Saved library") + " '" + panel.getBibDatabaseContext().getDatabaseFile().get().getPath() + "'."); frame.setWindowTitle(); frame.updateAllTabTitles(); } else if (!canceled) { if (fileLockedError) { // TODO: user should have the option to override the lock file. frame.output(Localization.lang("Could not save, file locked by another JabRef instance.")); } else { frame.output(Localization.lang("Save failed")); } } } @Override public void run() { if (canceled || !panel.getBibDatabaseContext().getDatabaseFile().isPresent()) { return; } try { // Make sure the current edit is stored panel.storeCurrentEdit(); // If set in preferences, generate missing BibTeX keys panel.autoGenerateKeysBeforeSaving(); if (FileBasedLock.waitForFileLock(panel.getBibDatabaseContext().getDatabaseFile().get().toPath())) { // Check for external modifications to alleviate multiuser concurrency issue when near // simultaneous saves occur to a shared database file: if true, do not perform the save // rather return instead. if (checkExternalModification()) { return; } // Save the database success = saveDatabase(panel.getBibDatabaseContext().getDatabaseFile().get(), false, panel.getBibDatabaseContext().getMetaData().getEncoding() .orElse(Globals.prefs.getDefaultEncoding())); Globals.getFileUpdateMonitor().updateTimeStamp(panel.getFileMonitorHandle()); } else { success = false; fileLockedError = true; } // release panel from save status panel.setSaving(false); if (success) { panel.getUndoManager().markUnchanged(); // (Only) after a successful save the following // statement marks that the base is unchanged // since last save: panel.setNonUndoableChange(false); panel.setBaseChanged(false); panel.setUpdatedExternally(false); } } catch (SaveException ex) { if (ex == SaveException.FILE_LOCKED) { success = false; fileLockedError = true; return; } LOGGER.error("Problem saving file", ex); } } private boolean saveDatabase(File file, boolean selectedOnly, Charset encoding) throws SaveException { SaveSession session; // block user input frame.block(); try { SavePreferences prefs = SavePreferences.loadForSaveFromPreferences(Globals.prefs).withEncoding(encoding); BibtexDatabaseWriter<SaveSession> databaseWriter = new BibtexDatabaseWriter<>(FileSaveSession::new); if (selectedOnly) { session = databaseWriter.savePartOfDatabase(panel.getBibDatabaseContext(), panel.getSelectedEntries(), prefs); } else { session = databaseWriter.saveDatabase(panel.getBibDatabaseContext(), prefs); } panel.registerUndoableChanges(session); } catch (UnsupportedCharsetException ex) { JOptionPane.showMessageDialog(frame, Localization.lang("Could not save file.") + Localization.lang("Character encoding '%0' is not supported.", encoding.displayName()), Localization.lang("Save library"), JOptionPane.ERROR_MESSAGE); // FIXME: rethrow anti-pattern throw new SaveException("rt"); } catch (SaveException ex) { if (ex == SaveException.FILE_LOCKED) { throw ex; } if (ex.specificEntry()) { BibEntry entry = ex.getEntry(); // Error occured during processing of an entry. Highlight it! panel.highlightEntry(entry); } else { LOGGER.error("A problem occured when trying to save the file", ex); } JOptionPane.showMessageDialog(frame, Localization.lang("Could not save file.") + ".\n" + ex.getMessage(), Localization.lang("Save library"), JOptionPane.ERROR_MESSAGE); // FIXME: rethrow anti-pattern throw new SaveException("rt"); } finally { // re-enable user input frame.unblock(); } // handle encoding problems boolean success = true; if (!session.getWriter().couldEncodeAll()) { FormBuilder builder = FormBuilder.create() .layout(new FormLayout("left:pref, 4dlu, fill:pref", "pref, 4dlu, pref")); JTextArea ta = new JTextArea(session.getWriter().getProblemCharacters()); ta.setEditable(false); builder.add(Localization.lang("The chosen encoding '%0' could not encode the following characters:", session.getEncoding().displayName())).xy(1, 1); builder.add(ta).xy(3, 1); builder.add(Localization.lang("What do you want to do?")).xy(1, 3); String tryDiff = Localization.lang("Try different encoding"); int answer = JOptionPane.showOptionDialog(frame, builder.getPanel(), Localization.lang("Save library"), JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE, null, new String[] {Localization.lang("Save"), tryDiff, Localization.lang("Cancel")}, tryDiff); if (answer == JOptionPane.NO_OPTION) { // The user wants to use another encoding. Object choice = JOptionPane.showInputDialog(frame, Localization.lang("Select encoding"), Localization.lang("Save library"), JOptionPane.QUESTION_MESSAGE, null, Encodings.ENCODINGS_DISPLAYNAMES, encoding); if (choice == null) { success = false; } else { Charset newEncoding = Charset.forName((String) choice); return saveDatabase(file, selectedOnly, newEncoding); } } else if (answer == JOptionPane.CANCEL_OPTION) { success = false; } } // backup file? try { if (success) { session.commit(file.toPath()); // Make sure to remember which encoding we used. panel.getBibDatabaseContext().getMetaData().setEncoding(encoding, ChangePropagation.DO_NOT_POST_EVENT); } else { session.cancel(); } } catch (SaveException e) { int ans = JOptionPane.showConfirmDialog(null, Localization.lang("Save failed during backup creation") + ". " + Localization.lang("Save without backup?"), Localization.lang("Unable to create backup"), JOptionPane.YES_NO_OPTION); if (ans == JOptionPane.YES_OPTION) { session.setUseBackup(false); session.commit(file.toPath()); panel.getBibDatabaseContext().getMetaData().setEncoding(encoding, ChangePropagation.DO_NOT_POST_EVENT); } else { success = false; } } return success; } /** * Run the "Save" operation. This method offloads the actual save operation to a background thread, but * still runs synchronously using Spin (the method returns only after completing the operation). */ public void runCommand() throws Exception { BasePanel.runWorker(this); } public void save() throws Exception { runCommand(); frame.updateEnabledState(); } public void saveAs() throws Exception { // configure file dialog FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() .addExtensionFilter(FileExtensions.BIBTEX_DB) .withDefaultExtension(FileExtensions.BIBTEX_DB) .withInitialDirectory(Globals.prefs.get(JabRefPreferences.WORKING_DIRECTORY)).build(); DialogService ds = new FXDialogService(); Optional<Path> path = DefaultTaskExecutor .runInJavaFXThread(() -> ds.showFileSaveDialog(fileDialogConfiguration)); if (path.isPresent()) { saveAs(path.get().toFile()); } else { canceled = true; return; } } /** * Run the "Save as" operation. This method offloads the actual save operation to a background thread, but * still runs synchronously using Spin (the method returns only after completing the operation). */ public void saveAs(File file) throws Exception { BibDatabaseContext context = panel.getBibDatabaseContext(); if (context.getLocation() == DatabaseLocation.SHARED) { // Save all properties dependent on the ID. This makes it possible to restore them. DBMSConnectionProperties properties = context.getDBMSSynchronizer().getDBProcessor() .getDBMSConnectionProperties(); new SharedDatabasePreferences(context.getDatabase().generateSharedDatabaseID()) .putAllDBMSConnectionProperties(properties); } context.setDatabaseFile(file); if (file.getParent() != null) { Globals.prefs.put(JabRefPreferences.WORKING_DIRECTORY, file.getParent()); } runCommand(); // If the operation failed, revert the file field and return: if (!success) { return; } // Register so we get notifications about outside changes to the file. try { panel.setFileMonitorHandle(Globals.getFileUpdateMonitor().addUpdateListener(panel, context.getDatabaseFile().orElse(null))); } catch (IOException ex) { LOGGER.error("Problem registering file change notifications", ex); } if (readyForAutosave(context)) { AutosaveManager autosaver = AutosaveManager.start(context); autosaver.registerListener(new AutosaveUIManager(panel)); } if (readyForBackup(context)) { BackupManager.start(context); } context.getDatabaseFile().ifPresent(presentFile -> frame.getFileHistory().newFile(presentFile.getPath())); frame.updateEnabledState(); } private boolean readyForAutosave(BibDatabaseContext context) { return ((context.getLocation() == DatabaseLocation.SHARED) || ((context.getLocation() == DatabaseLocation.LOCAL) && Globals.prefs.getBoolean(JabRefPreferences.LOCAL_AUTO_SAVE))) && context.getDatabaseFile().isPresent(); } private boolean readyForBackup(BibDatabaseContext context) { return (context.getLocation() == DatabaseLocation.LOCAL) && context.getDatabaseFile().isPresent(); } /** * Query whether the last operation was successful. * * @return true if the last Save/SaveAs operation completed successfully, false otherwise. */ public boolean isSuccess() { return success; } /** * Query whether the last operation was canceled. * * @return true if the last Save/SaveAs operation was canceled from the file dialog or from another * query dialog, false otherwise. */ public boolean isCanceled() { return canceled; } /** * Check whether or not the external database has been modified. If so need to alert the user to accept external updates prior to * saving the database. This is necessary to avoid overwriting other users work when using a multiuser database file. * * @return true if the external database file has been modified and the user must choose to accept the changes and false if no modifications * were found or there is no requested protection for the database file. */ private boolean checkExternalModification() { // Check for external modifications: if (panel.isUpdatedExternally() || Globals.getFileUpdateMonitor().hasBeenModified(panel.getFileMonitorHandle())) { String[] opts = new String[] {Localization.lang("Review changes"), Localization.lang("Save"), Localization.lang("Cancel")}; int answer = JOptionPane.showOptionDialog(panel.frame(), Localization.lang("File has been updated externally. " + "What do you want to do?"), Localization.lang("File updated externally"), JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, null, opts, opts[0]); if (answer == JOptionPane.CANCEL_OPTION) { canceled = true; return true; } else if (answer == JOptionPane.YES_OPTION) { canceled = true; JabRefExecutorService.INSTANCE.execute(() -> { if (!FileBasedLock .waitForFileLock(panel.getBibDatabaseContext().getDatabaseFile().get().toPath())) { // TODO: GUI handling of the situation when the externally modified file keeps being locked. LOGGER.error("File locked, this will be trouble."); } ChangeScanner scanner = new ChangeScanner(panel.frame(), panel, panel.getBibDatabaseContext().getDatabaseFile().get()); JabRefExecutorService.INSTANCE.executeInterruptableTaskAndWait(scanner); if (scanner.changesFound()) { scanner.displayResult(resolved -> { if (resolved) { panel.setUpdatedExternally(false); SwingUtilities .invokeLater(() -> panel.getSidePaneManager().hide(FileUpdatePanel.class)); } else { canceled = true; } }); } }); return true; } else { // User indicated to store anyway. if (panel.getBibDatabaseContext().getMetaData().isProtected()) { JOptionPane.showMessageDialog(frame, Localization .lang("Library is protected. Cannot save until external changes have been reviewed."), Localization.lang("Protected library"), JOptionPane.ERROR_MESSAGE); canceled = true; } else { panel.setUpdatedExternally(false); panel.getSidePaneManager().hide(FileUpdatePanel.class); } } } // Return false as either no external database file modifications have been found or overwrite is requested any way return false; } }