diff --git a/chrome/content/zotero/preferences/preferences_advanced.js b/chrome/content/zotero/preferences/preferences_advanced.js
index 97c9ee693..d670d8f76 100644
--- a/chrome/content/zotero/preferences/preferences_advanced.js
+++ b/chrome/content/zotero/preferences/preferences_advanced.js
@@ -36,18 +36,41 @@ Zotero_Preferences.Advanced = {
this.onDataDirLoad();
},
- revealDataDirectory: function () {
- var dataDir = Zotero.getZoteroDirectory();
- dataDir.QueryInterface(Components.interfaces.nsILocalFile);
- try {
- dataDir.reveal();
+
+ migrateDataDirectory: Zotero.Promise.coroutine(function* () {
+ var currentDir = Zotero.getZoteroDirectory().path;
+ var defaultDir = Zotero.getDefaultDataDir();
+ if (currentDir == defaultDir) {
+ Zotero.debug("Already using default directory");
+ return;
}
- catch (e) {
- // On platforms that don't support nsILocalFile.reveal() (e.g. Linux),
- // launch the directory
- window.opener.ZoteroPane_Local.launchFile(dataDir);
+
+ Components.utils.import("resource://zotero/config.js")
+ var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
+ .getService(Components.interfaces.nsIPromptService);
+ var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
+ + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL);
+ var index = ps.confirmEx(window,
+ Zotero.getString('zotero.preferences.advanced.migrateDataDir.title'),
+ Zotero.getString(
+ 'zotero.preferences.advanced.migrateDataDir.directoryWillBeMoved',
+ [ZOTERO_CONFIG.CLIENT_NAME, defaultDir]
+ ) + '\n\n'
+ + Zotero.getString(
+ 'zotero.preferences.advanced.migrateDataDir.appMustBeRestarted', Zotero.appName
+ ),
+ buttonFlags,
+ Zotero.getString('general.restartApp', Zotero.appName),
+ null, null, null, {}
+ );
+
+ if (index == 0) {
+ yield Zotero.File.putContentsAsync(
+ OS.Path.join(currentDir, Zotero.DATA_DIR_MIGRATION_MARKER), currentDir
+ );
+ Zotero.Utilities.Internal.quitZotero(true);
}
- },
+ }),
runIntegrityCheck: Zotero.Promise.coroutine(function* () {
@@ -195,10 +218,11 @@ Zotero_Preferences.Advanced = {
var useDataDir = Zotero.Prefs.get('useDataDir');
var dataDir = Zotero.Prefs.get('lastDataDir') || Zotero.Prefs.get('dataDir');
var defaultDataDir = Zotero.getDefaultDataDir();
+ var currentDir = Zotero.getZoteroDirectory().path;
// Change "Use profile directory" label to home directory location unless using profile dir
- if (useDataDir || Zotero.getZoteroDirectory().path == defaultDataDir) {
- document.getElementById('defaultDataDir').setAttribute(
+ if (useDataDir || currentDir == defaultDataDir) {
+ document.getElementById('default-data-dir').setAttribute(
'label', Zotero.getString('dataDir.default', Zotero.getDefaultDataDir())
);
}
@@ -208,14 +232,17 @@ Zotero_Preferences.Advanced = {
useDataDir = false;
}
- document.getElementById('dataDirPath').setAttribute('disabled', !useDataDir);
+ document.getElementById('data-dir-path').setAttribute('disabled', !useDataDir);
+ document.getElementById('migrate-data-dir').setAttribute(
+ 'hidden', !Zotero.canMigrateDataDirectory()
+ );
return useDataDir;
},
onDataDirUpdate: function (event) {
- var radiogroup = document.getElementById('dataDir');
+ var radiogroup = document.getElementById('data-dir');
var useDataDir = Zotero.Prefs.get('useDataDir');
var newUseDataDir = radiogroup.selectedIndex == 1;
@@ -226,14 +253,14 @@ Zotero_Preferences.Advanced = {
// This call shows a filepicker if needed, forces a restart if required, and does nothing if
// cancel was pressed or value hasn't changed
Zotero.chooseZoteroDirectory(true, !newUseDataDir, function () {
- Zotero_Preferences.openURL('http://zotero.org/support/zotero_data');
+ Zotero_Preferences.openURL('https://zotero.org/support/zotero_data');
});
radiogroup.selectedIndex = this._usingDefaultDataDir() ? 0 : 1;
},
chooseDataDir: function(event) {
- document.getElementById('dataDir').selectedIndex = 1;
+ document.getElementById('data-dir').selectedIndex = 1;
//this.onDataDirUpdate(event);
},
diff --git a/chrome/content/zotero/preferences/preferences_advanced.xul b/chrome/content/zotero/preferences/preferences_advanced.xul
index fdfbd07ae..5a984a0b3 100644
--- a/chrome/content/zotero/preferences/preferences_advanced.xul
+++ b/chrome/content/zotero/preferences/preferences_advanced.xul
@@ -182,14 +182,14 @@
-
-
+
-
diff --git a/chrome/content/zotero/xpcom/file.js b/chrome/content/zotero/xpcom/file.js
index a06493b1e..876c8bfc0 100644
--- a/chrome/content/zotero/xpcom/file.js
+++ b/chrome/content/zotero/xpcom/file.js
@@ -437,11 +437,33 @@ Zotero.File = new function(){
}
+ /**
+ * @return {Promise}
+ */
+ this.directoryIsEmpty = Zotero.Promise.coroutine(function* (path) {
+ var it = new OS.File.DirectoryIterator(path);
+ try {
+ let entry = yield it.next();
+ Zotero.debug(entry);
+ return false;
+ }
+ catch (e) {
+ if (e != StopIteration) {
+ throw e;
+ }
+ }
+ finally {
+ it.close();
+ }
+ return true;
+ });
+
+
/**
* Run a generator with an OS.File.DirectoryIterator, closing the
* iterator when done
*
- * The DirectoryInterator is passed as the first parameter to the generator.
+ * The DirectoryIterator is passed as the first parameter to the generator.
*
* Zotero.File.iterateDirectory(path, function* (iterator) {
* while (true) {
@@ -465,6 +487,133 @@ Zotero.File = new function(){
});
}
+ /**
+ * Move directory (using mv on macOS/Linux, recursively on Windows)
+ *
+ * @param {Boolean} [options.allowExistingTarget=false] - If true, merge files into an existing
+ * target directory if one exists rather than throwing an error
+ * @param {Function} options.noOverwrite - Function that returns true if the file at the given
+ * path should throw an error rather than overwrite an existing file in the target
+ */
+ this.moveDirectory = Zotero.Promise.coroutine(function* (oldDir, newDir, options = {}) {
+ var maxDepth = options.maxDepth || 10;
+ var cmd = "/bin/mv";
+ var useCmd = !Zotero.isWin && (yield OS.File.exists(cmd));
+
+ if (!options.allowExistingTarget && (yield OS.File.exists(newDir))) {
+ throw new Error(newDir + " exists");
+ }
+
+ var errors = [];
+
+ // Throw certain known errors (no more disk space) to interrupt the operation
+ function checkError(e) {
+ if (!(e instanceof OS.File.Error)) {
+ return;
+ }
+
+ if (!Zotero.isWin) {
+ switch (e.unixErrno) {
+ case OS.Constants.libc.ENOSPC:
+ throw e;
+ }
+ }
+ }
+
+ function addError(e) {
+ errors.push(e);
+ Zotero.logError(e);
+ }
+
+ var rootDir = oldDir;
+ var moveSubdirs = Zotero.Promise.coroutine(function* (oldDir, depth) {
+ if (!depth) return;
+
+ // Create target directory
+ try {
+ yield Zotero.File.createDirectoryIfMissingAsync(newDir + oldDir.substr(rootDir.length));
+ }
+ catch (e) {
+ addError(e);
+ return;
+ }
+
+ Zotero.debug("Moving files in " + oldDir);
+
+ yield Zotero.File.iterateDirectory(oldDir, function* (iterator) {
+ while (true) {
+ let entry = yield iterator.next();
+ let dest = newDir + entry.path.substr(rootDir.length);
+
+ // Move files in directory
+ if (!entry.isDir) {
+ try {
+ yield OS.File.move(
+ entry.path,
+ dest,
+ {
+ noOverwrite: options
+ && options.noOverwrite
+ && options.noOverwrite(entry.path)
+ }
+ );
+ }
+ catch (e) {
+ checkError(e);
+ Zotero.debug("Error moving " + entry.path);
+ addError(e);
+ }
+ }
+ else {
+ // Move directory with external command if possible and the directory doesn't
+ // already exist in target
+ let moved = false;
+
+ if (useCmd && !(yield OS.File.exists(dest))) {
+ let args = [entry.path, dest];
+ try {
+ yield Zotero.Utilities.Internal.exec(cmd, args);
+ moved = true;
+ }
+ catch (e) {
+ checkError(e);
+ addError(e);
+ }
+ }
+
+ // Otherwise, recurse into subdirectories to copy files individually
+ if (!moved) {
+ try {
+ yield moveSubdirs(entry.path, depth - 1);
+ }
+ catch (e) {
+ checkError(e);
+ addError(e);
+ }
+ }
+ }
+ }
+ });
+
+ // Remove directory after moving everything within
+ //
+ // Don't try to remove root directory if there've been errors, since it won't work.
+ // (Deeper directories might fail too, but we don't worry about those.)
+ if (!errors.length || oldDir != rootDir) {
+ Zotero.debug("Removing " + oldDir);
+ try {
+ yield OS.File.removeEmptyDir(oldDir);
+ }
+ catch (e) {
+ addError(e);
+ }
+ }
+ });
+
+ yield moveSubdirs(oldDir, maxDepth);
+ return errors;
+ });
+
/**
* Generate a data: URI from an nsIFile
@@ -1059,4 +1208,42 @@ Zotero.File = new function(){
this.isDropboxDirectory = function(path) {
return path.toLowerCase().indexOf('dropbox') != -1;
}
+
+
+ this.reveal = Zotero.Promise.coroutine(function* (file) {
+ if (!(yield OS.File.exists(file))) {
+ throw new Error(file + " does not exist");
+ }
+
+ Zotero.debug("Revealing " + file);
+
+ var nsIFile = this.pathToFile(file);
+ nsIFile.QueryInterface(Components.interfaces.nsILocalFile);
+ try {
+ nsIFile.reveal();
+ }
+ catch (e) {
+ Zotero.logError(e);
+ // On platforms that don't support nsILocalFile.reveal() (e.g. Linux),
+ // launch the directory
+ let zp = Zotero.getActiveZoteroPane();
+ if (zp) {
+ try {
+ let info = yield OS.File.stat(file);
+ // Launch parent directory for files
+ if (!info.isDir) {
+ file = OS.Path.dirname(file);
+ }
+ Zotero.launchFile(file);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ return;
+ }
+ }
+ else {
+ Zotero.logError(e);
+ }
+ }
+ });
}
diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js
index c868dd0ef..8638acee0 100644
--- a/chrome/content/zotero/xpcom/zotero.js
+++ b/chrome/content/zotero/xpcom/zotero.js
@@ -36,7 +36,6 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
// Privileged (public) methods
this.getProfileDirectory = getProfileDirectory;
this.getStorageDirectory = getStorageDirectory;
- this.getZoteroDatabase = getZoteroDatabase;
this.chooseZoteroDirectory = chooseZoteroDirectory;
this.debug = debug;
this.log = log;
@@ -67,7 +66,8 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
Components.utils.import("resource://zotero/bluebird.js", this);
this.getActiveZoteroPane = function() {
- return Services.wm.getMostRecentWindow("navigator:browser").ZoteroPane;
+ var win = Services.wm.getMostRecentWindow("navigator:browser");
+ return win ? win.ZoteroPane : null;
};
/**
@@ -103,7 +103,7 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
this.hiDPISuffix = "";
var _startupErrorHandler;
- var _zoteroDirectory = false;
+ var _dataDirectory = false;
var _localizedStringBundle;
var _locked = false;
@@ -310,14 +310,22 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
}
// DEBUG: handle more startup errors
else {
- throw (e);
- return false;
+ throw e;
}
}
- if(Zotero.isStandalone) {
- Zotero.checkForUnsafeDataDirectory(dataDir.path);
+ if (!Zotero.isConnector) {
+ yield Zotero.checkForDataDirectoryMigration(dataDir.path, this.getDefaultDataDir());
+ if (this.skipLoading) {
+ return;
+ }
+
+ // Make sure data directory isn't in Dropbox, etc.
+ if (Zotero.isStandalone) {
+ Zotero.checkForUnsafeDataDirectory(dataDir.path);
+ }
}
+
// Register shutdown handler to call Zotero.shutdown()
var _shutdownObserver = {observe:function() { Zotero.shutdown().done() }};
Services.obs.addObserver(_shutdownObserver, "quit-application", false);
@@ -917,10 +925,10 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
return [defaultProfile, nSections > 1];
}
+
this.getZoteroDirectory = function () {
- if (_zoteroDirectory != false) {
- // Return a clone of the file pointer so that callers can modify it
- return _zoteroDirectory.clone();
+ if (_dataDirectory != false) {
+ return Zotero.File.pathToFile(_dataDirectory);
}
var file = Components.classes["@mozilla.org/file/local;1"]
@@ -951,13 +959,34 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
}
}
else {
- try {
- file = Zotero.File.pathToFile(prefVal);
+ // If there's a migration marker in this directory and no database, migration was
+ // interrupted before the database could be moved (or moving failed), so use the source
+ // directory specified in the marker file.
+ let migrationMarker = OS.Path.join(prefVal, this.DATA_DIR_MIGRATION_MARKER);
+ let dbFile = OS.Path.join(prefVal, this.getDatabaseFilename());
+ if (Zotero.File.pathToFile(migrationMarker).exists()
+ && !(Zotero.File.pathToFile(dbFile).exists())) {
+ let fileStr = Zotero.File.getContents(migrationMarker);
+ try {
+ file = Zotero.File.pathToFile(fileStr);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ Zotero.debug(`Invalid path '${fileStr}' in marker file`, 1);
+ e = { name: "NS_ERROR_FILE_NOT_FOUND" };
+ throw e;
+ }
}
- catch (e) {
- Zotero.debug(`Invalid path '${prefVal}' in dataDir pref`, 1);
- e = { name: "NS_ERROR_FILE_NOT_FOUND" };
- throw e;
+ else {
+ try {
+ file = Zotero.File.pathToFile(prefVal);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ Zotero.debug(`Invalid path '${prefVal}' in dataDir pref`, 1);
+ e = { name: "NS_ERROR_FILE_NOT_FOUND" };
+ throw e;
+ }
}
}
}
@@ -971,10 +1000,10 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
// Check for ~/Zotero/zotero.sqlite
dataDir = Zotero.File.pathToFile(dataDir);
let dbFile = dataDir.clone();
- dbFile.append(ZOTERO_CONFIG.ID + '.sqlite');
+ dbFile.append(this.getDatabaseFilename());
if (dbFile.exists()) {
Zotero.debug("Using data directory " + dataDir.path);
- _zoteroDirectory = dataDir;
+ this._cacheDataDirectory(dataDir.path);
// Set as a custom data directory so that 4.0 uses it
this.setDataDirectory(dataDir.path);
@@ -987,7 +1016,7 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
profileSubdir.append('zotero');
if (profileSubdir.exists()) {
Zotero.debug("Using data directory " + profileSubdir.path);
- _zoteroDirectory = profileSubdir;
+ this._cacheDataDirectory(profileSubdir.path);
return profileSubdir.clone();
}
@@ -1084,12 +1113,16 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
Zotero.File.createDirectoryIfMissing(file);
}
Zotero.debug("Using data directory " + file.path);
- _zoteroDirectory = file;
+ this._cacheDataDirectory(file.path);
return file.clone();
}
this.getDefaultDataDir = function () {
+ // Keep data directory siloed within profile directory for tests
+ if (Zotero.test) {
+ return OS.Path.join(OS.Constants.Path.profileDir, "test-data-dir");
+ }
return OS.Path.join(OS.Constants.Path.homeDir, ZOTERO_CONFIG.CLIENT_NAME);
};
@@ -1102,8 +1135,13 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
return file;
}
- function getZoteroDatabase(name, ext){
- name = name ? name + '.sqlite' : 'zotero.sqlite';
+
+ this.getDatabaseFilename = function (name) {
+ return (name || ZOTERO_CONFIG.ID) + '.sqlite';
+ };
+
+ this.getZoteroDatabase = function (name, ext) {
+ name = this.getDatabaseFilename(name);
ext = ext ? '.' + ext : '';
var file = Zotero.getZoteroDirectory();
@@ -1187,7 +1225,7 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
}
else if (file.directoryEntries.hasMoreElements()) {
let dbfile = file.clone();
- dbfile.append('zotero.sqlite');
+ dbfile.append(this.getDatabaseFilename());
// Warn if non-empty and no zotero.sqlite
if (!dbfile.exists()) {
@@ -1260,6 +1298,11 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
}
+ this._cacheDataDirectory = function (dir) {
+ _dataDirectory = dir;
+ };
+
+
this.forceNewDataDirectory = function(win) {
if (!win) {
win = Services.wm.getMostRecentWindow('navigator:browser');
@@ -1338,6 +1381,314 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
};
+ this.isLegacyDataDirectory = function (dir) {
+ // 'zotero'
+ return OS.Path.basename(dir) == ZOTERO_CONFIG.ID
+ // '69pmactz.default'
+ && OS.Path.basename(OS.Path.dirname(dir)).match(/^[0-9a-z]{8}\..+/)
+ // 'Profiles'
+ && OS.Path.basename(OS.Path.dirname(OS.Path.dirname(dir))) == 'Profiles';
+ }
+
+
+ this.canMigrateDataDirectory = function () {
+ // If (not default location) && (not useDataDir or within legacy location)
+ var currentDir = this.getZoteroDirectory().path;
+ if (currentDir == this.getDefaultDataDir()) {
+ return false;
+ }
+
+ // Legacy default or set to legacy default from other program (Standalone/Z4Fx) to share data
+ if (!Zotero.Prefs.get('useDataDir') || this.isLegacyDataDirectory(currentDir)) {
+ return true;
+ }
+
+ return false;
+ };
+
+
+ this.revealDataDirectory = function () {
+ return Zotero.File.reveal(this.getZoteroDirectory().path);
+ },
+
+
+ this.DATA_DIR_MIGRATION_MARKER = 'migrate-dir';
+
+
+ /**
+ * Migrate data directory if necessary and show any errors
+ *
+ * @param {String} dataDir - Current directory
+ * @param {String} targetDir - Target directory, which may be the same; except in tests, this is
+ * the default data directory
+ */
+ this.checkForDataDirectoryMigration = Zotero.Promise.coroutine(function* (dataDir, newDir) {
+ let migrationMarker = OS.Path.join(dataDir, Zotero.DATA_DIR_MIGRATION_MARKER);
+ try {
+ var exists = yield OS.File.exists(migrationMarker)
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ if (!exists) {
+ return false;
+ }
+
+ let oldDir;
+ let partial = false;
+
+ // Not set to the default directory, so use current as old directory
+ if (dataDir != newDir) {
+ oldDir = dataDir;
+ }
+ // Unfinished migration -- already using new directory, so get path to previous
+ // directory from the migration marker
+ else {
+ try {
+ oldDir = yield Zotero.File.getContentsAsync(migrationMarker);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ return false;
+ }
+ partial = true;
+ }
+
+ let errors;
+ try {
+ errors = yield Zotero.migrateDataDirectory(oldDir, newDir, partial);
+ }
+ catch (e) {
+ // Complete failure (failed to create new directory, copy marker, or move database)
+ Zotero.debug("Migration failed", 1);
+ Zotero.logError(e);
+
+ let ps = Services.prompt;
+ let buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
+ + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING);
+ let index = ps.confirmEx(null,
+ Zotero.getString('dataDir.migration.failure.title'),
+ Zotero.getString('dataDir.migration.failure.full.text1')
+ + "\n\n"
+ + e
+ + "\n\n"
+ + Zotero.getString('dataDir.migration.failure.full.text2', Zotero.appName)
+ + "\n\n"
+ + Zotero.getString('dataDir.migration.failure.full.current', oldDir)
+ + "\n\n"
+ + Zotero.getString('dataDir.migration.failure.full.recommended', newDir),
+ buttonFlags,
+ Zotero.getString('dataDir.migration.failure.full.showCurrentDirectoryAndQuit', Zotero.appName),
+ Zotero.getString('general.notNow'),
+ null, null, {}
+ );
+ if (index == 0) {
+ yield Zotero.File.reveal(oldDir);
+ this.skipLoading = true;
+ Zotero.Utilities.Internal.quitZotero();
+ }
+ return;
+ }
+
+ // Set data directory again
+ Zotero.debug("Using new data directory " + newDir);
+ this._cacheDataDirectory(newDir);
+
+ // At least the database was copied, but other things failed
+ if (errors.length) {
+ let ps = Services.prompt;
+ let buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
+ + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING);
+ let index = ps.confirmEx(null,
+ Zotero.getString('dataDir.migration.failure.title'),
+ Zotero.getString('dataDir.migration.failure.partial.text',
+ [ZOTERO_CONFIG.CLIENT_NAME, Zotero.appName])
+ + "\n\n"
+ + Zotero.getString('dataDir.migration.failure.partial.old', oldDir)
+ + "\n\n"
+ + Zotero.getString('dataDir.migration.failure.partial.new', newDir),
+ buttonFlags,
+ Zotero.getString('dataDir.migration.failure.partial.showDirectoriesAndQuit', Zotero.appName),
+ Zotero.getString('general.notNow'),
+ null, null, {}
+ );
+
+ // Focus the first file/folder in the old directory
+ if (index == 0) {
+ try {
+ let it = new OS.File.DirectoryIterator(oldDir);
+ let entry;
+ try {
+ entry = yield it.next();
+ }
+ catch (e) {
+ if (e != StopIteration) {
+ throw e;
+ }
+ }
+ finally {
+ it.close();
+ }
+ if (entry) {
+ yield Zotero.File.reveal(entry.path);
+ }
+ // Focus the database file in the new directory
+ yield Zotero.File.reveal(OS.Path.join(newDir, this.getDatabaseFilename()));
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+
+ Zotero.skipLoading = true;
+ Zotero.Utilities.Internal.quitZotero();
+ return;
+ }
+ }
+ });
+
+
+ /**
+ * Recursively moves data directory from one location to another
+ * and updates the data directory setting
+ *
+ * If moving the database file fails, an error is thrown.
+ * Otherwise, an array of errors is returned.
+ *
+ * @param {String} oldDir
+ * @param {String} newDir
+ * @return {Error[]}
+ */
+ this.migrateDataDirectory = Zotero.Promise.coroutine(function* (oldDir, newDir, partial) {
+ var dbName = this.getDatabaseFilename();
+ var errors = [];
+
+ function addError(e) {
+ errors.push(e);
+ Zotero.logError(e);
+ }
+
+ if (!(yield OS.File.exists(oldDir))) {
+ Zotero.debug(`Old directory ${oldDir} doesn't exist -- nothing to migrate`);
+ try {
+ let newMigrationMarker = OS.Path.join(newDir, Zotero.DATA_DIR_MIGRATION_MARKER);
+ Zotero.debug("Removing " + newMigrationMarker);
+ yield OS.File.remove(newMigrationMarker);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ return [];
+ }
+
+ if (partial) {
+ Zotero.debug(`Continuing data directory migration from ${oldDir} to ${newDir}`);
+ }
+ else {
+ Zotero.debug(`Migrating data directory from ${oldDir} to ${newDir}`);
+ }
+
+ // Create the new directory
+ if (!partial) {
+ try {
+ yield OS.File.makeDir(
+ newDir,
+ {
+ ignoreExisting: false,
+ unixMode: 0o755
+ }
+ );
+ }
+ catch (e) {
+ // If default dir exists and is non-empty, move it out of the way
+ // ("Zotero-1", "Zotero-2", …)
+ if (e instanceof OS.File.Error && e.becauseExists) {
+ if (!(yield Zotero.File.directoryIsEmpty(newDir))) {
+ let i = 1;
+ while (true) {
+ let backupDir = newDir + "-" + i++;
+ if (yield OS.File.exists(backupDir)) {
+ if (i > 5) {
+ throw new Error("Too many backup directories "
+ + "-- stopped at " + backupDir);
+ }
+ continue;
+ }
+ Zotero.debug(`Moving existing directory to ${backupDir}`);
+ yield Zotero.File.moveDirectory(newDir, backupDir);
+ break;
+ }
+ yield OS.File.makeDir(
+ newDir,
+ {
+ ignoreExisting: false,
+ unixMode: 0o755
+ }
+ );
+ }
+ }
+ else {
+ throw e;
+ }
+ }
+ }
+
+ // Copy marker
+ let oldMarkerFile = OS.Path.join(oldDir, this.DATA_DIR_MIGRATION_MARKER);
+ // Marker won't exist on subsequent attempts after partial failure
+ if (yield OS.File.exists(oldMarkerFile)) {
+ yield OS.File.copy(oldMarkerFile, OS.Path.join(newDir, this.DATA_DIR_MIGRATION_MARKER));
+ }
+
+ // Update the data directory setting first. If moving the database fails, getZoteroDirectory()
+ // will continue to use the old directory based on the migration marker
+ Zotero.setDataDirectory(newDir);
+
+ // Move database
+ if (!partial) {
+ Zotero.debug("Moving " + dbName);
+ yield OS.File.move(OS.Path.join(oldDir, dbName), OS.Path.join(newDir, dbName));
+ }
+
+ // Once the database has been moved, we can clear the migration marker from the old directory.
+ // If the migration is interrupted after this, it can be continued later based on the migration
+ // marker in the new directory.
+ try {
+ yield OS.File.remove(OS.Path.join(oldDir, this.DATA_DIR_MIGRATION_MARKER));
+ }
+ catch (e) {
+ addError(e);
+ }
+
+ errors = errors.concat(yield Zotero.File.moveDirectory(
+ oldDir,
+ newDir,
+ {
+ allowExistingTarget: true,
+ // Don't overwrite root files (except for hidden files like .DS_Store)
+ noOverwrite: path => {
+ return OS.Path.dirname(path) == oldDir && !OS.Path.basename(path).startsWith('.')
+ },
+ }
+ ));
+
+ if (errors.length) {
+ Zotero.logError("Not all files were transferred from " + oldDir + " to " + newDir);
+ }
+ else {
+ try {
+ let newMigrationMarker = OS.Path.join(newDir, Zotero.DATA_DIR_MIGRATION_MARKER);
+ Zotero.debug("Removing " + newMigrationMarker);
+ yield OS.File.remove(newMigrationMarker);
+ }
+ catch (e) {
+ addError(e);
+ }
+ }
+
+ return errors;
+ });
+
+
/**
* Launch a file, the best way we can
diff --git a/chrome/locale/en-US/zotero/preferences.dtd b/chrome/locale/en-US/zotero/preferences.dtd
index 29287568a..6f6dd40a2 100644
--- a/chrome/locale/en-US/zotero/preferences.dtd
+++ b/chrome/locale/en-US/zotero/preferences.dtd
@@ -197,6 +197,7 @@
+
diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties
index f724fe93c..8c22d425a 100644
--- a/chrome/locale/en-US/zotero/zotero.properties
+++ b/chrome/locale/en-US/zotero/zotero.properties
@@ -131,6 +131,16 @@ dataDir.selectedDirEmpty.useNewDir = Use the new directory?
dataDir.moveFilesToNewLocation = Be sure to move files from your existing Zotero data directory to the new location before reopening %1$S.
dataDir.incompatibleDbVersion.title = Incompatible Database Version
dataDir.incompatibleDbVersion.text = The currently selected data directory is not compatible with Zotero Standalone, which can share a database only with Zotero for Firefox 2.1b3 or later.\n\nUpgrade to the latest version of Zotero for Firefox first or select a different data directory for use with Zotero Standalone.
+dataDir.migration.failure.title = Data Directory Migration Error
+dataDir.migration.failure.partial.text = Some files in your old %1$S data directory could not be transferred to the new location. You should close %2$S and attempt to move the remaining files manually.
+dataDir.migration.failure.partial.old = Old directory: %S
+dataDir.migration.failure.partial.new = New directory: %S
+dataDir.migration.failure.partial.showDirectoriesAndQuit = Show Directories and Quit %S
+dataDir.migration.failure.full.text1 = Your data directory could not be migrated.
+dataDir.migration.failure.full.text2 = It is recommended that you close %S and manually move your data directory to the new default location.
+dataDir.migration.failure.full.current = Current location: %S
+dataDir.migration.failure.full.recommended = Recommended location: %S
+dataDir.migration.failure.full.showCurrentDirectoryAndQuit = Show Current Directory and Quit %S
app.standalone = Zotero Standalone
app.firefox = Zotero for Firefox
@@ -621,6 +631,9 @@ zotero.preferences.advanced.resetTranslators = Reset Translators
zotero.preferences.advanced.resetTranslators.changesLost = Any new or modified translators will be lost.
zotero.preferences.advanced.resetStyles = Reset Styles
zotero.preferences.advanced.resetStyles.changesLost = Any new or modified styles will be lost.
+zotero.preferences.advanced.migrateDataDir.title = Migrate Data Directory
+zotero.preferences.advanced.migrateDataDir.directoryWillBeMoved = Your %1$S data directory will be moved to %2$S.
+zotero.preferences.advanced.migrateDataDir.appMustBeRestarted = %S must be restarted to complete the migration.
zotero.preferences.advanced.debug.title = Debug Output Submitted
zotero.preferences.advanced.debug.sent = Debug output has been sent to the Zotero server.\n\nThe Debug ID is D%S.
diff --git a/test/tests/zoteroTest.js b/test/tests/zoteroTest.js
new file mode 100644
index 000000000..561d8979a
--- /dev/null
+++ b/test/tests/zoteroTest.js
@@ -0,0 +1,314 @@
+"use strict";
+
+describe("Zotero Core Functions", function () {
+ var tmpDir, oldDir, newDir, dbFilename, oldDBFile, newDBFile, oldStorageDir, newStorageDir,
+ oldTranslatorsDir, newTranslatorsDir, translatorName1, translatorName2,
+ oldStorageDir1, newStorageDir1, storageFile1, oldStorageDir2, newStorageDir2, storageFile2,
+ str1, str2, str3, str4, str5, str6,
+ oldMigrationMarker, newMigrationMarker,
+ stub1, stub2, stub3;
+
+ before(function* () {
+ tmpDir = yield getTempDirectory();
+ oldDir = OS.Path.join(tmpDir, "old");
+ newDir = OS.Path.join(tmpDir, "new");
+ dbFilename = Zotero.getDatabaseFilename();
+ oldDBFile = OS.Path.join(oldDir, dbFilename);
+ newDBFile = OS.Path.join(newDir, dbFilename);
+ oldStorageDir = OS.Path.join(oldDir, "storage");
+ newStorageDir = OS.Path.join(newDir, "storage");
+ oldTranslatorsDir = OS.Path.join(oldDir, "translators");
+ newTranslatorsDir = OS.Path.join(newDir, "translators");
+ translatorName1 = 'a.js';
+ translatorName2 = 'b.js';
+ oldStorageDir1 = OS.Path.join(oldStorageDir, 'AAAAAAAA');
+ newStorageDir1 = OS.Path.join(newStorageDir, 'AAAAAAAA');
+ storageFile1 = 'test.pdf';
+ oldStorageDir2 = OS.Path.join(oldStorageDir, 'BBBBBBBB');
+ newStorageDir2 = OS.Path.join(newStorageDir, 'BBBBBBBB');
+ storageFile2 = 'test.html';
+ str1 = '1';
+ str2 = '2';
+ str3 = '3';
+ str4 = '4';
+ str5 = '5';
+ str6 = '6';
+ oldMigrationMarker = OS.Path.join(oldDir, Zotero.DATA_DIR_MIGRATION_MARKER);
+ newMigrationMarker = OS.Path.join(newDir, Zotero.DATA_DIR_MIGRATION_MARKER);
+ });
+
+ beforeEach(function* () {
+ stub1 = sinon.stub(Zotero, "setDataDirectory");
+ });
+
+ afterEach(function* () {
+ yield OS.File.removeDir(oldDir);
+ yield OS.File.removeDir(newDir);
+ Zotero._cacheDataDirectory(false);
+
+ stub1.restore();
+ });
+
+ var disableCommandMode = function () {
+ // Force non-mv mode
+ var origFunc = OS.File.exists;
+ stub2 = sinon.stub(OS.File, "exists", function (path) {
+ if (path == '/bin/mv') {
+ return Zotero.Promise.resolve(false);
+ }
+ else {
+ return origFunc(path);
+ }
+ });
+ };
+
+ var resetCommandMode = function () {
+ stub2.restore();
+ };
+
+ var populateDataDirectory = Zotero.Promise.coroutine(function* (dir, srcDir) {
+ yield OS.File.makeDir(dir, { unixMode: 0o755 });
+ let storageDir = OS.Path.join(dir, 'storage');
+ let storageDir1 = OS.Path.join(storageDir, 'AAAAAAAA');
+ let storageDir2 = OS.Path.join(storageDir, 'BBBBBBBB');
+ let translatorsDir = OS.Path.join(dir, 'translators');
+ let migrationMarker = OS.Path.join(dir, Zotero.DATA_DIR_MIGRATION_MARKER);
+
+ // Database
+ yield Zotero.File.putContentsAsync(OS.Path.join(dir, dbFilename), str1);
+ // Database backup
+ yield Zotero.File.putContentsAsync(OS.Path.join(dir, dbFilename + '.bak'), str2);
+ // 'storage' directory
+ yield OS.File.makeDir(storageDir, { unixMode: 0o755 });
+ // 'storage' folders
+ yield OS.File.makeDir(storageDir1, { unixMode: 0o755 });
+ yield Zotero.File.putContentsAsync(OS.Path.join(storageDir1, storageFile1), str2);
+ yield OS.File.makeDir(storageDir2, { unixMode: 0o755 });
+ yield Zotero.File.putContentsAsync(OS.Path.join(storageDir2, storageFile2), str3);
+ // 'translators' and some translators
+ yield OS.File.makeDir(translatorsDir, { unixMode: 0o755 });
+ yield Zotero.File.putContentsAsync(OS.Path.join(translatorsDir, translatorName1), str4);
+ yield Zotero.File.putContentsAsync(OS.Path.join(translatorsDir, translatorName2), str5);
+ // Migration marker
+ yield Zotero.File.putContentsAsync(migrationMarker, srcDir || dir);
+ });
+
+ var checkMigration = Zotero.Promise.coroutine(function* (options = {}) {
+ if (!options.skipOldDir) {
+ assert.isFalse(yield OS.File.exists(oldDir));
+ }
+ yield assert.eventually.equal(Zotero.File.getContentsAsync(newDBFile), str1);
+ yield assert.eventually.equal(Zotero.File.getContentsAsync(newDBFile + '.bak'), str2);
+ if (!options.skipStorageFile1) {
+ yield assert.eventually.equal(
+ Zotero.File.getContentsAsync(OS.Path.join(newStorageDir1, storageFile1)), str2
+ );
+ }
+ yield assert.eventually.equal(
+ Zotero.File.getContentsAsync(OS.Path.join(newStorageDir2, storageFile2)), str3
+ );
+ yield assert.eventually.equal(
+ Zotero.File.getContentsAsync(OS.Path.join(newTranslatorsDir, translatorName1)), str4
+ );
+ yield assert.eventually.equal(
+ Zotero.File.getContentsAsync(OS.Path.join(newTranslatorsDir, translatorName2)), str5
+ );
+ if (!options.skipNewMarker) {
+ assert.isFalse(yield OS.File.exists(newMigrationMarker));
+ }
+
+ if (!options.skipSetDataDirectory) {
+ assert.ok(stub1.calledOnce);
+ assert.ok(stub1.calledWith(newDir));
+ }
+ });
+
+
+ describe("#checkForDataDirectoryMigration()", function () {
+ let stub3;
+
+ before(function () {
+ disableCommandMode();
+ });
+
+ after(function () {
+ resetCommandMode();
+ });
+
+ it("should show error on partial failure", function* () {
+ yield populateDataDirectory(oldDir);
+
+ let origFunc = OS.File.move;
+ let stub3 = sinon.stub(OS.File, "move", function () {
+ if (OS.Path.basename(arguments[0]) == storageFile1) {
+ return Zotero.Promise.reject(new Error("Error"));
+ }
+ else {
+ return origFunc(...arguments);
+ }
+ });
+ let stub4 = sinon.stub(Zotero.File, "reveal").returns(Zotero.Promise.resolve());
+ let stub5 = sinon.stub(Zotero.Utilities.Internal, "quitZotero");
+
+ var promise = waitForDialog();
+ yield Zotero.checkForDataDirectoryMigration(oldDir, newDir);
+ yield promise;
+
+ assert.isTrue(stub4.calledTwice);
+ assert.isTrue(stub4.getCall(0).calledWith(oldStorageDir));
+ assert.isTrue(stub4.getCall(1).calledWith(newDBFile));
+ assert.isTrue(stub5.called);
+
+ stub3.restore();
+ stub4.restore();
+ stub5.restore();
+ });
+
+ it("should show error on full failure", function* () {
+ yield populateDataDirectory(oldDir);
+
+ let origFunc = OS.File.move;
+ let stub3 = sinon.stub(OS.File, "move", function () {
+ if (OS.Path.basename(arguments[0]) == dbFilename) {
+ return Zotero.Promise.reject(new Error("Error"));
+ }
+ else {
+ return origFunc(...arguments);
+ }
+ });
+ let stub4 = sinon.stub(Zotero.File, "reveal").returns(Zotero.Promise.resolve());
+ let stub5 = sinon.stub(Zotero.Utilities.Internal, "quitZotero");
+
+ var promise = waitForDialog();
+ yield Zotero.checkForDataDirectoryMigration(oldDir, newDir);
+ yield promise;
+
+ assert.isTrue(stub4.calledOnce);
+ assert.isTrue(stub4.calledWith(oldDir));
+ assert.isTrue(stub5.called);
+
+ stub3.restore();
+ stub4.restore();
+ stub5.restore();
+ });
+
+ it("should remove marker if old directory doesn't exist", function* () {
+ yield populateDataDirectory(newDir, oldDir);
+ yield Zotero.checkForDataDirectoryMigration(newDir, newDir);
+ yield checkMigration({
+ skipSetDataDirectory: true
+ });
+ });
+ });
+
+
+ describe("#migrateDataDirectory()", function () {
+ // Define tests and store for running in non-mv mode
+ var tests = [];
+ function add(desc, fn) {
+ it(desc, fn);
+ tests.push([desc, fn]);
+ }
+
+ add("should move all files and folders", function* () {
+ yield populateDataDirectory(oldDir);
+ yield Zotero.migrateDataDirectory(oldDir, newDir);
+ yield checkMigration();
+ });
+
+ add("should resume partial migration with just marker copied", function* () {
+ yield populateDataDirectory(oldDir);
+ yield OS.File.makeDir(newDir, { unixMode: 0o755 });
+
+ yield OS.File.copy(oldMigrationMarker, newMigrationMarker);
+
+ yield Zotero.migrateDataDirectory(oldDir, newDir, true);
+ yield checkMigration();
+ });
+
+ add("should resume partial migration with database moved", function* () {
+ yield populateDataDirectory(oldDir);
+ yield OS.File.makeDir(newDir, { unixMode: 0o755 });
+
+ yield OS.File.copy(oldMigrationMarker, newMigrationMarker);
+ yield OS.File.move(OS.Path.join(oldDir, dbFilename), OS.Path.join(newDir, dbFilename));
+
+ yield Zotero.migrateDataDirectory(oldDir, newDir, true);
+ yield checkMigration();
+ });
+
+ add("should resume partial migration with some storage directories moved", function* () {
+ yield populateDataDirectory(oldDir);
+ yield populateDataDirectory(newDir, oldDir);
+
+ // Moved: DB, DB backup, one storage dir
+ // Not moved: one storage dir, translators dir
+ yield OS.File.remove(oldDBFile);
+ yield OS.File.remove(oldDBFile + '.bak');
+ yield OS.File.removeDir(oldStorageDir1);
+ yield OS.File.removeDir(newTranslatorsDir);
+ yield OS.File.removeDir(newStorageDir2);
+
+ yield Zotero.migrateDataDirectory(oldDir, newDir, true);
+ yield checkMigration();
+ });
+
+ add("should move existing directory out of the way", function* () {
+ yield populateDataDirectory(oldDir);
+ yield OS.File.makeDir(newDir, { unixMode: 0o755 });
+ yield Zotero.File.putContentsAsync(OS.Path.join(newDir, 'existing'), '');
+
+ yield Zotero.migrateDataDirectory(oldDir, newDir);
+ yield checkMigration();
+
+ assert.isTrue(yield OS.File.exists(OS.Path.join(newDir + "-1", 'existing')));
+ yield OS.File.removeDir(newDir + "-1");
+ });
+
+ // Run all tests again without using mv
+ //
+ // On Windows these will just be duplicates of the above tests.
+ describe("non-mv mode", function () {
+ tests.forEach(arr => {
+ it(arr[0] + " [non-mv]", arr[1]);
+ });
+
+ before(function () {
+ disableCommandMode();
+ });
+
+ after(function () {
+ resetCommandMode();
+ });
+
+ it("should handle partial failure", function* () {
+ yield populateDataDirectory(oldDir);
+
+ let origFunc = OS.File.move;
+ let stub3 = sinon.stub(OS.File, "move", function () {
+ if (OS.Path.basename(arguments[0]) == storageFile1) {
+ return Zotero.Promise.reject(new Error("Error"));
+ }
+ else {
+ return origFunc(...arguments);
+ }
+ });
+
+ yield Zotero.migrateDataDirectory(oldDir, newDir);
+
+ stub3.restore();
+
+ yield checkMigration({
+ skipOldDir: true,
+ skipStorageFile1: true,
+ skipNewMarker: true
+ });
+
+ assert.isTrue(yield OS.File.exists(OS.Path.join(oldStorageDir1, storageFile1)));
+ assert.isFalse(yield OS.File.exists(OS.Path.join(oldStorageDir2, storageFile2)));
+ assert.isFalse(yield OS.File.exists(oldTranslatorsDir));
+ assert.isTrue(yield OS.File.exists(newMigrationMarker));
+ });
+ });
+ });
+});