diff --git a/chrome/content/zotero/preferences/preferences.js b/chrome/content/zotero/preferences/preferences.js
index 0c3cbbafc..6b193fb49 100644
--- a/chrome/content/zotero/preferences/preferences.js
+++ b/chrome/content/zotero/preferences/preferences.js
@@ -291,7 +291,7 @@ function updateStorageSettings(enabled, protocol, skipWarnings) {
 				var sql = "INSERT OR IGNORE INTO settings VALUES (?,?,?)";
 				Zotero.DB.query(sql, ['storage', 'zfsPurge', 'user']);
 				
-				Zotero.Sync.Storage.purgeDeletedStorageFiles('zfs', function (success) {
+				Zotero.Sync.Storage.ZFS.purgeDeletedStorageFiles(function (success) {
 					if (success) {
 						ps.alert(
 							null,
@@ -363,7 +363,7 @@ function verifyStorageServer() {
 	abortButton.hidden = false;
 	progressMeter.hidden = false;
 	
-	var requestHolder = Zotero.Sync.Storage.checkServer('WebDAV', function (uri, status, callback) {
+	var requestHolder = Zotero.Sync.Storage.WebDAV.checkServer(function (uri, status, callback) {
 		verifyButton.hidden = false;
 		abortButton.hidden = true;
 		progressMeter.hidden = true;
@@ -388,7 +388,7 @@ function verifyStorageServer() {
 				break;
 		}
 		
-		callback(uri, status, window);
+		Zotero.Sync.Storage.WebDAV.checkServerCallback(uri, status, window);
 	});
 	
 	abortButton.onclick = function () {
diff --git a/chrome/content/zotero/preferences/preferences.xul b/chrome/content/zotero/preferences/preferences.xul
index 7cc3afd03..ac98e0c38 100644
--- a/chrome/content/zotero/preferences/preferences.xul
+++ b/chrome/content/zotero/preferences/preferences.xul
@@ -157,7 +157,7 @@ To add a new preference:
 	
 	<prefpane id="zotero-prefpane-sync"
 						label="&zotero.preferences.prefpane.sync;"
-						onpaneload="document.getElementById('sync-password').value = Zotero.Sync.Server.password; document.getElementById('storage-password').value = Zotero.Sync.Storage.password;"
+						onpaneload="document.getElementById('sync-password').value = Zotero.Sync.Server.password; var pass = Zotero.Sync.Storage.WebDAV.password; if (pass) { document.getElementById('storage-password').value = pass; }"
 						image="chrome://zotero/skin/prefs-sync.png"
 						helpTopic="sync">
 		<preferences>
@@ -283,7 +283,7 @@ To add a new preference:
 											preference="pref-storage-username"
 											onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { this.blur(); setTimeout(verifyStorageServer, 1); }"
 											onsynctopreference="unverifyStorageServer();"
-											onchange="var pass = document.getElementById('storage-password'); if (pass.value) { Zotero.Sync.Storage.Session.WebDAV.prototype.password = pass.value; }"/>
+											onchange="var pass = document.getElementById('storage-password'); if (pass.value) { Zotero.Sync.Storage.WebDAV.password = pass.value; }"/>
 									</hbox>
 								</row>
 								<row>
@@ -292,7 +292,7 @@ To add a new preference:
 										<textbox id="storage-password" flex="0" type="password"
 											onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { this.blur(); setTimeout(verifyStorageServer, 1); }"
 											oninput="unverifyStorageServer()"
-											onchange="Zotero.Sync.Storage.Session.WebDAV.prototype.password = this.value"/>
+											onchange="Zotero.Sync.Storage.WebDAV.password = this.value;"/>
 									</hbox>
 								</row>
 								<row>
diff --git a/chrome/content/zotero/xpcom/data/relations.js b/chrome/content/zotero/xpcom/data/relations.js
index 622640e8a..1076b4da6 100644
--- a/chrome/content/zotero/xpcom/data/relations.js
+++ b/chrome/content/zotero/xpcom/data/relations.js
@@ -252,9 +252,13 @@ Zotero.Relations = new function () {
 			}
 			relation.libraryID = parseInt(libraryID);
 		}
-		relation.subject = _getFirstChildContent(relationNode, 'subject');
-		relation.predicate = _getFirstChildContent(relationNode, 'predicate');
-		relation.object = _getFirstChildContent(relationNode, 'object');
+		
+		var elems = Zotero.Utilities.xpath(relationNode, 'subject');
+		relation.subject = elems.length ? elems[0].textContent : "";
+		var elems = Zotero.Utilities.xpath(relationNode, 'predicate');
+		relation.predicate = elems.length ? elems[0].textContent : "";
+		var elems = Zotero.Utilities.xpath(relationNode, 'object');
+		relation.object = elems.length ? elems[0].textContent : "";
 		return relation;
 	}
 	
diff --git a/chrome/content/zotero/xpcom/storage.js b/chrome/content/zotero/xpcom/storage.js
index cfa917787..2c928bfa8 100644
--- a/chrome/content/zotero/xpcom/storage.js
+++ b/chrome/content/zotero/xpcom/storage.js
@@ -87,41 +87,41 @@ Zotero.Sync.Storage = new function () {
 	//
 	// Public methods
 	//
-	this.sync = function (moduleName, observer) {
-		var module = getModuleFromName(moduleName);
+	this.sync = function (modeName, observer) {
+		var mode = getModeFromName(modeName);
 		
 		if (!observer) {
 			throw new Error("Observer not provided");
 		}
-		registerDefaultObserver(moduleName);
-		Zotero.Sync.Storage.EventManager.registerObserver(observer, true, moduleName);
+		registerDefaultObserver(modeName);
+		Zotero.Sync.Storage.EventManager.registerObserver(observer, true, modeName);
 		
-		if (!module.active) {
-			if (!module.enabled) {
-				Zotero.debug(module.name + " file sync is not enabled");
+		if (!mode.active) {
+			if (!mode.enabled) {
+				Zotero.debug(mode.name + " file sync is not enabled");
 				Zotero.Sync.Storage.EventManager.skip();
 				return;
 			}
 			
-			Zotero.debug(module.name + " file sync is not active");
+			Zotero.debug(mode.name + " file sync is not active");
 			
 			// Try to verify server now if it hasn't been
-			if (!module.verified) {
-				module.checkServer(function (uri, status) {
+			if (!mode.verified) {
+				mode.checkServer(function (uri, status) {
 					var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
 								   .getService(Components.interfaces.nsIWindowMediator);
 					var lastWin = wm.getMostRecentWindow("navigator:browser");
 					
-					var success = module.checkServerCallback(uri, status, lastWin, true);
+					var success = mode.checkServerCallback(uri, status, lastWin, true);
 					if (success) {
-						Zotero.debug(module.name + " file sync is successfully set up");
-						Zotero.Sync.Storage.sync(module.name);
+						Zotero.debug(mode.name + " file sync is successfully set up");
+						Zotero.Sync.Storage.sync(mode.name);
 					}
 					else {
-						Zotero.debug(module.name + " verification failed");
+						Zotero.debug(mode.name + " verification failed");
 						
 						var e = new Zotero.Error(
-							Zotero.getString('sync.storage.error.verificationFailed', module.name),
+							Zotero.getString('sync.storage.error.verificationFailed', mode.name),
 							0,
 							{
 								dialogButtonText: Zotero.getString('sync.openSyncPreferences'),
@@ -141,8 +141,8 @@ Zotero.Sync.Storage = new function () {
 			return;
 		}
 		
-		if (!module.includeUserFiles && !module.includeGroupFiles) {
-			Zotero.debug("No libraries are enabled for " + module.name + " syncing");
+		if (!mode.includeUserFiles && !mode.includeGroupFiles) {
+			Zotero.debug("No libraries are enabled for " + mode.name + " syncing");
 			Zotero.Sync.Storage.EventManager.skip();
 			return;
 		}
@@ -153,7 +153,7 @@ Zotero.Sync.Storage = new function () {
 			);
 		}
 		
-		Zotero.debug("Beginning " + module.name + " file sync");
+		Zotero.debug("Beginning " + mode.name + " file sync");
 		_syncInProgress = true;
 		_changesMade = false;
 		
@@ -161,8 +161,8 @@ Zotero.Sync.Storage = new function () {
 			Zotero.Sync.Storage.checkForUpdatedFiles(
 				null,
 				null,
-				module.includeUserFiles && Zotero.Sync.Storage.downloadOnSync(),
-				module.includeGroupFiles && Zotero.Sync.Storage.downloadOnSync('groups')
+				mode.includeUserFiles && Zotero.Sync.Storage.downloadOnSync(),
+				mode.includeGroupFiles && Zotero.Sync.Storage.downloadOnSync('groups')
 			);
 		}
 		catch (e) {
@@ -171,14 +171,14 @@ Zotero.Sync.Storage = new function () {
 		
 		var self = this;
 		
-		module.getLastSyncTime(function (lastSyncTime) {
+		mode.getLastSyncTime(function (lastSyncTime) {
 			// Register the observers again to make sure they're active when we
 			// start the queues. (They'll only be registered once.) Observers are
 			// cleared when all queues finish, so without this another sync
 			// process (e.g., on-demand download) could finish and clear all
 			// observers while getLastSyncTime() is running.
-			registerDefaultObserver(moduleName);
-			Zotero.Sync.Storage.EventManager.registerObserver(observer, true, moduleName);
+			registerDefaultObserver(modeName);
+			Zotero.Sync.Storage.EventManager.registerObserver(observer, true, modeName);
 			
 			var download = true;
 			
@@ -186,17 +186,17 @@ Zotero.Sync.Storage = new function () {
 			var force = !!Zotero.DB.valueQuery(sql, Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD);
 			
 			if (!force && lastSyncTime) {
-				var sql = "SELECT version FROM version WHERE schema='storage_" + moduleName + "'";
+				var sql = "SELECT version FROM version WHERE schema='storage_" + modeName + "'";
 				var version = Zotero.DB.valueQuery(sql);
 				if (version == lastSyncTime) {
-					Zotero.debug("Last " + module.name + " sync time hasn't changed -- skipping file download step");
+					Zotero.debug("Last " + mode.name + " sync time hasn't changed -- skipping file download step");
 					download = false;
 				}
 			}
 			
 			try {
-				var activeDown = download ? _downloadFiles(module) : false;
-				var activeUp = _uploadFiles(module);
+				var activeDown = download ? _downloadFiles(mode) : false;
+				var activeUp = _uploadFiles(mode);
 			}
 			catch (e) {
 				Zotero.Sync.Storage.EventManager.error(e);
@@ -620,9 +620,9 @@ Zotero.Sync.Storage = new function () {
 	 */
 	this.downloadFile = function (item, requestCallbacks) {
 		var itemID = item.id;
-		var module = getModuleFromLibrary(item.libraryID);
+		var mode = getModeFromLibrary(item.libraryID);
 		
-		if (!module || !module.active) {
+		if (!mode || !mode.active) {
 			Zotero.debug("File syncing is not active for item's library -- skipping download");
 			return false;
 		}
@@ -681,7 +681,7 @@ Zotero.Sync.Storage = new function () {
 					requestCallbacks = {};
 				}
 				var onStart = function (request) {
-					module.downloadFile(request);
+					mode.downloadFile(request);
 				};
 				requestCallbacks.onStart = requestCallbacks.onStart
 											? [onStart, requestCallbacks.onStart]
@@ -699,7 +699,7 @@ Zotero.Sync.Storage = new function () {
 		};
 		
 		setup();
-		module.cacheCredentials(function () {
+		mode.cacheCredentials(function () {
 			run();
 		});
 		
@@ -820,8 +820,7 @@ Zotero.Sync.Storage = new function () {
 	}
 	
 	
-	this.checkServer = function (moduleName, callback) {
-		var module = getModuleFromName(moduleName);
+	this.checkServer = function (modeName, callback) {
 		Zotero.Sync.Storage.EventManager.registerObserver({
 			onSuccess: function () {},
 			onError: function (e) {
@@ -833,47 +832,16 @@ Zotero.Sync.Storage = new function () {
 				return true;
 			}
 		}, false, "checkServer");
-		return module.checkServer(function (uri, status) {
+		
+		var mode = getModeFromName(modeName);
+		return mode.checkServer(function (uri, status) {
 			callback(uri, status, function () {
-				module.checkServerCallback(uri, status);
+				mode.checkServerCallback(uri, status);
 			});
 		});
 	}
 	
 	
-	this.purgeDeletedStorageFiles = function (moduleName, callback) {
-		var module = getModuleFromName(moduleName);
-		if (!module.active) {
-			return;
-		}
-		Zotero.Sync.Storage.EventManager.registerObserver({
-			onError: function (e) {
-				error(e);
-			}
-		}, false, "purgeDeletedStorageFiles");
-		module.purgeDeletedStorageFiles(callback);
-	}
-	
-	
-	this.purgeOrphanedStorageFiles = function (moduleName, callback) {
-		var module = getModuleFromName(moduleName);
-		if (!module.active) {
-			return;
-		}
-		Zotero.Sync.Storage.EventManager.registerObserver({
-			onError: function (e) {
-				error(e);
-			}
-		}, false, "purgeOrphanedStorageFiles");
-		module.purgeOrphanedStorageFiles(callback);
-	}
-	
-	
-	this.isActive = function (moduleName) {
-		return getModuleFromName(moduleName).active;
-	}
-	
-	
 	this.resetAllSyncStates = function (syncState, includeUserFiles, includeGroupFiles) {
 		if (!includeUserFiles && !includeGroupFiles) {
 			includeUserFiles = true;
@@ -922,12 +890,12 @@ Zotero.Sync.Storage = new function () {
 	//
 	// Private methods
 	//
-	function getModuleFromName(moduleName) {
-		return new Zotero.Sync.Storage.Module(moduleName);
+	function getModeFromName(modeName) {
+		return Zotero.Sync.Storage[modeName];
 	}
 	
 	
-	function getModuleFromLibrary(libraryID) {
+	function getModeFromLibrary(libraryID) {
 		if (libraryID === undefined) {
 			throw new Error("libraryID not provided");
 		}
@@ -942,10 +910,10 @@ Zotero.Sync.Storage = new function () {
 			var protocol = Zotero.Prefs.get('sync.storage.protocol');
 			switch (protocol) {
 				case 'zotero':
-					return getModuleFromName('ZFS');
+					return getModeFromName('ZFS');
 				
 				case 'webdav':
-					return getModuleFromName('WebDAV');
+					return getModeFromName('WebDAV');
 				
 				default:
 					throw new Error("Invalid storage protocol '" + protocol + "'");
@@ -958,7 +926,7 @@ Zotero.Sync.Storage = new function () {
 				return false;
 			}
 			
-			return getModuleFromName('ZFS');
+			return getModeFromName('ZFS');
 		}
 	}
 	
@@ -968,13 +936,13 @@ Zotero.Sync.Storage = new function () {
 	 *
 	 * @return	{Boolean}
 	 */
-	function _downloadFiles(module) {
+	function _downloadFiles(mode) {
 		if (!_syncInProgress) {
 			_syncInProgress = true;
 		}
 		
-		var includeUserFiles = module.includeUserFiles && Zotero.Sync.Storage.downloadOnSync();
-		var includeGroupFiles = module.includeGroupFiles && Zotero.Sync.Storage.downloadOnSync('groups');
+		var includeUserFiles = mode.includeUserFiles && Zotero.Sync.Storage.downloadOnSync();
+		var includeGroupFiles = mode.includeGroupFiles && Zotero.Sync.Storage.downloadOnSync('groups');
 		
 		if (!includeUserFiles && !includeGroupFiles) {
 			Zotero.debug("No libraries are enabled for on-sync downloading");
@@ -1005,7 +973,7 @@ Zotero.Sync.Storage = new function () {
 				item.libraryID + '/' + item.key,
 				{
 					onStart: function (request) {
-						module.downloadFile(request);
+						mode.downloadFile(request);
 					}
 				}
 			);
@@ -1021,12 +989,12 @@ Zotero.Sync.Storage = new function () {
 	 *
 	 * @return	{Boolean}
 	 */
-	function _uploadFiles(module) {
+	function _uploadFiles(mode) {
 		if (!_syncInProgress) {
 			_syncInProgress = true;
 		}
 		
-		var uploadFileIDs = _getFilesToUpload(module.includeUserFiles, module.includeGroupFiles);
+		var uploadFileIDs = _getFilesToUpload(mode.includeUserFiles, mode.includeGroupFiles);
 		if (!uploadFileIDs) {
 			Zotero.debug("No files to upload");
 			return false;
@@ -1044,7 +1012,7 @@ Zotero.Sync.Storage = new function () {
 				item.libraryID + '/' + item.key,
 				{
 					onStart: function (request) {
-						module.uploadFile(request);
+						mode.uploadFile(request);
 					}
 				}
 			);
@@ -1688,7 +1656,7 @@ Zotero.Sync.Storage = new function () {
 	}
 	
 	
-	function registerDefaultObserver(moduleName) {
+	function registerDefaultObserver(modeName) {
 		var finish = function (cancelled, skipSuccessFile) {
 			// Upload success file when done
 			if (!_resyncOnFinish && !skipSuccessFile) {
@@ -1698,20 +1666,20 @@ Zotero.Sync.Storage = new function () {
 				var uploadQueue = Zotero.Sync.Storage.QueueManager.get('upload', true);
 				var useLastSyncTime = !uploadQueue || (!cancelled && uploadQueue.lastTotalRequests == 0);
 				
-				getModuleFromName(moduleName).setLastSyncTime(function () {
+				getModeFromName(modeName).setLastSyncTime(function () {
 					finish(cancelled, true);
 				}, useLastSyncTime);
 				return false;
 			}
 			
-			Zotero.debug(moduleName + " sync is complete");
+			Zotero.debug(modeName + " sync is complete");
 			
 			_syncInProgress = false;
 			
 			if (_resyncOnFinish) {
 				Zotero.debug("Force-resyncing items in conflict");
 				_resyncOnFinish = false;
-				Zotero.Sync.Storage.sync(moduleName);
+				Zotero.Sync.Storage.sync(modeName);
 				return false;
 			}
 			
diff --git a/chrome/content/zotero/xpcom/storage/eventManager.js b/chrome/content/zotero/xpcom/storage/eventManager.js
index d0ac3f460..a632579c5 100644
--- a/chrome/content/zotero/xpcom/storage/eventManager.js
+++ b/chrome/content/zotero/xpcom/storage/eventManager.js
@@ -130,7 +130,7 @@ Zotero.Sync.Storage.EventManager = (function () {
 			var queues = Zotero.Sync.Storage.QueueManager.getAll();
 			for each(var queue in queues) {
 				if (queue.isRunning()) {
-					Zotero.debug(queue[0].toUpperCase() + queue.substr(1)
+					Zotero.debug(queue.name[0].toUpperCase() + queue.name.substr(1)
 						+ " queue not empty -- not clearing storage sync event observers");
 					return;
 				}
diff --git a/chrome/content/zotero/xpcom/storage/mode.js b/chrome/content/zotero/xpcom/storage/mode.js
new file mode 100644
index 000000000..b289600b4
--- /dev/null
+++ b/chrome/content/zotero/xpcom/storage/mode.js
@@ -0,0 +1,184 @@
+/*
+    ***** BEGIN LICENSE BLOCK *****
+    
+    Copyright © 2009 Center for History and New Media
+                     George Mason University, Fairfax, Virginia, USA
+                     http://zotero.org
+    
+    This file is part of Zotero.
+    
+    Zotero is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+    
+    Zotero is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+    
+    You should have received a copy of the GNU Affero General Public License
+    along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
+    
+    ***** END LICENSE BLOCK *****
+*/
+
+
+Zotero.Sync.Storage.Mode = function () {};
+
+Zotero.Sync.Storage.Mode.prototype.__defineGetter__('enabled', function () {
+	try {
+		return this._enabled;
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+});
+
+Zotero.Sync.Storage.Mode.prototype.__defineGetter__('verified', function () {
+	try {
+		return this._verified;
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+});
+
+Zotero.Sync.Storage.Mode.prototype.__defineGetter__('active', function () {
+	try {
+		return this._enabled && this._verified && this._initFromPrefs();
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+});
+
+Zotero.Sync.Storage.Mode.prototype.__defineGetter__('username', function () {
+	try {
+		return this._username;
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+});
+
+Zotero.Sync.Storage.Mode.prototype.__defineGetter__('password', function () {
+	try {
+		return this._password;
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+});
+
+Zotero.Sync.Storage.Mode.prototype.__defineSetter__('password', function (val) {
+	try {
+		this._password = val;
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+});
+
+Zotero.Sync.Storage.Mode.prototype.init = function () {
+	try {
+		return this._init();
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.initFromPrefs = function () {
+	try {
+		return this._initFromPrefs();
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.sync = function (observer) {
+	Zotero.Sync.Storage.sync(this.name, observer);
+}
+
+Zotero.Sync.Storage.Mode.prototype.downloadFile = function (request) {
+	try {
+		this._downloadFile(request);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.uploadFile = function (request) {
+	try {
+		this._uploadFile(request);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.getLastSyncTime = function (callback) {
+	try {
+		this._getLastSyncTime(callback);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.setLastSyncTime = function (callback, useLastSyncTime) {
+	try {
+		this._setLastSyncTime(callback, useLastSyncTime);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.checkServer = function (callback) {
+	try {
+		return this._checkServer(callback);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.checkServerCallback = function (uri, status, window, skipSuccessMessage) {
+	try {
+		return this._checkServerCallback(uri, status, window, skipSuccessMessage);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.cacheCredentials = function (callback) {
+	try {
+		return this._cacheCredentials(callback);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.purgeDeletedStorageFiles = function (callback) {
+	try {
+		this._purgeDeletedStorageFiles(callback);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
+
+Zotero.Sync.Storage.Mode.prototype.purgeOrphanedStorageFiles = function (callback) {
+	try {
+		this._purgeOrphanedStorageFiles(callback);
+	}
+	catch (e) {
+		Zotero.Sync.Storage.EventManager.error(e);
+	}
+}
diff --git a/chrome/content/zotero/xpcom/storage/module.js b/chrome/content/zotero/xpcom/storage/module.js
deleted file mode 100644
index fc32d83c8..000000000
--- a/chrome/content/zotero/xpcom/storage/module.js
+++ /dev/null
@@ -1,198 +0,0 @@
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    Copyright © 2009 Center for History and New Media
-                     George Mason University, Fairfax, Virginia, USA
-                     http://zotero.org
-    
-    This file is part of Zotero.
-    
-    Zotero is free software: you can redistribute it and/or modify
-    it under the terms of the GNU Affero General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-    
-    Zotero is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU Affero General Public License for more details.
-    
-    You should have received a copy of the GNU Affero General Public License
-    along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
-    
-    ***** END LICENSE BLOCK *****
-*/
-
-
-Zotero.Sync.Storage.Module = function (moduleName) {
-	switch (moduleName) {
-		case 'ZFS':
-			this._module = Zotero.Sync.Storage.Module.ZFS;
-			break;
-		
-		case 'WebDAV':
-			this._module = Zotero.Sync.Storage.Module.WebDAV;
-			break;
-			
-		default:
-			throw ("Invalid storage session module '" + moduleName + "'");
-	}
-};
-
-Zotero.Sync.Storage.Module.prototype.__defineGetter__('name', function () this._module.name);
-Zotero.Sync.Storage.Module.prototype.__defineGetter__('includeUserFiles', function () this._module.includeUserFiles);
-Zotero.Sync.Storage.Module.prototype.__defineGetter__('includeGroupFiles', function () this._module.includeGroupFiles);
-
-Zotero.Sync.Storage.Module.prototype.__defineGetter__('enabled', function () {
-	try {
-		return this._module.enabled;
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-});
-
-Zotero.Sync.Storage.Module.prototype.__defineGetter__('verified', function () {
-	try {
-		return this._module.verified;
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-});
-
-Zotero.Sync.Storage.Module.prototype.__defineGetter__('active', function () {
-	try {
-		return this._module.enabled && this._module.initFromPrefs() && this._module.verified;
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-});
-
-Zotero.Sync.Storage.Module.prototype.__defineGetter__('username', function () {
-	try {
-		return this._module.username;
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-});
-
-Zotero.Sync.Storage.Module.prototype.__defineGetter__('password', function () {
-	try {
-		return this._module.password;
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-});
-
-Zotero.Sync.Storage.Module.prototype.__defineSetter__('password', function (val) {
-	try {
-		this._module.password = val;
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-});
-
-
-Zotero.Sync.Storage.Module.prototype.init = function () {
-	try {
-		return this._module.init();
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.initFromPrefs = function () {
-	try {
-		return this._module.initFromPrefs();
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.downloadFile = function (request) {
-	try {
-		this._module.downloadFile(request);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.uploadFile = function (request) {
-	try {
-		this._module.uploadFile(request);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.getLastSyncTime = function (callback) {
-	try {
-		this._module.getLastSyncTime(callback);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.setLastSyncTime = function (callback, useLastSyncTime) {
-	try {
-		this._module.setLastSyncTime(callback, useLastSyncTime);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.checkServer = function (callback) {
-	try {
-		return this._module.checkServer(callback);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.checkServerCallback = function (uri, status, window, skipSuccessMessage) {
-	try {
-		return this._module.checkServerCallback(uri, status, window, skipSuccessMessage);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.cacheCredentials = function (callback) {
-	try {
-		return this._module.cacheCredentials(callback);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.purgeDeletedStorageFiles = function (callback) {
-	try {
-		this._module.purgeDeletedStorageFiles(callback);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
-
-Zotero.Sync.Storage.Module.prototype.purgeOrphanedStorageFiles = function (callback) {
-	try {
-		this._module.purgeOrphanedStorageFiles(callback);
-	}
-	catch (e) {
-		Zotero.Sync.Storage.EventManager.error(e);
-	}
-}
diff --git a/chrome/content/zotero/xpcom/storage/webdav.js b/chrome/content/zotero/xpcom/storage/webdav.js
index 5206c932b..507bbd435 100644
--- a/chrome/content/zotero/xpcom/storage/webdav.js
+++ b/chrome/content/zotero/xpcom/storage/webdav.js
@@ -24,7 +24,7 @@
 */
 
 
-Zotero.Sync.Storage.Module.WebDAV = (function () {
+Zotero.Sync.Storage.WebDAV = (function () {
 	// TEMP
 	// TODO: localize
 	var _defaultError = "A WebDAV file sync error occurred. Please try syncing again.\n\nIf you receive this message repeatedly, check your WebDAV server settings in the Sync pane of the Zotero preferences.";
@@ -387,7 +387,7 @@ Zotero.Sync.Storage.Module.WebDAV = (function () {
 	 * Create a Zotero directory on the storage server
 	 */
 	function createServerDirectory(callback) {
-		var uri = Zotero.Sync.Storage.Module.WebDAV.rootURI;
+		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
 		Zotero.HTTP.WebDAV.doMkCol(uri, function (req) {
 			Zotero.debug(req.responseText);
 			Zotero.debug(req.status);
@@ -429,7 +429,7 @@ Zotero.Sync.Storage.Module.WebDAV = (function () {
 	 * @return	{nsIURI}					URI of file on storage server
 	 */
 	function getItemURI(item) {
-		var uri = Zotero.Sync.Storage.Module.WebDAV.rootURI;
+		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
 		uri.spec = uri.spec + item.key + '.zip';
 		return uri;
 	}
@@ -443,7 +443,7 @@ Zotero.Sync.Storage.Module.WebDAV = (function () {
 	 * @return	{nsIURI}					URI of property file on storage server
 	 */
 	function getItemPropertyURI(item) {
-		var uri = Zotero.Sync.Storage.Module.WebDAV.rootURI;
+		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
 		uri.spec = uri.spec + item.key + '.prop';
 		return uri;
 	}
@@ -670,31 +670,37 @@ Zotero.Sync.Storage.Module.WebDAV = (function () {
 	}
 	
 	
-	return {
-		name: "WebDAV",
-		
-		get includeUserFiles() {
+	//
+	// Public methods (called via Zotero.Sync.Storage.WebDAV)
+	//
+	var obj = new Zotero.Sync.Storage.Mode;
+	obj.name = "WebDAV";
+	
+	Object.defineProperty(obj, "includeUserFiles", {
+		get: function () {
 			return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'webdav';
-		},
-		includeGroupItems: false,
+		}
+	});
+	obj.includeGroupItems = false;
 		
-		get enabled() {
-			return this.includeUserFiles;
-		},
-		
-		get verified() {
-			return Zotero.Prefs.get("sync.storage.verified");
-		},
-		
-		get username() {
-			return Zotero.Prefs.get('sync.storage.username');
-		},
-		
-		get password() {
-			var username = this.username;
+	Object.defineProperty(obj, "_enabled", {
+		get: function () this.includeUserFiles
+	});
+	
+	Object.defineProperty(obj, "_verified", {
+		get: function () Zotero.Prefs.get("sync.storage.verified")
+	});
+	
+	Object.defineProperty(obj, "_username", {
+		get: function () Zotero.Prefs.get('sync.storage.username')
+	});
+	
+	Object.defineProperty(obj, "_password", {
+		get: function () {
+			var username = this._username;
 			
 			if (!username) {
-				Zotero.debug('Username not set before getting Zotero.Sync.Storage.Module.WebDAV.password');
+				Zotero.debug('Username not set before getting Zotero.Sync.Storage.WebDAV.password');
 				return '';
 			}
 			
@@ -713,10 +719,10 @@ Zotero.Sync.Storage.Module.WebDAV = (function () {
 			return '';
 		},
 		
-		set password(password) {
-			var username = this.username;
+		set: function (password) {
+			var username = this._username;
 			if (!username) {
-				Zotero.debug('Username not set before setting Zotero.Sync.Server.Module.WebDAV.password');
+				Zotero.debug('Username not set before setting Zotero.Sync.Server.Mode.WebDAV.password');
 				return;
 			}
 			
@@ -740,419 +746,552 @@ Zotero.Sync.Storage.Module.WebDAV = (function () {
 					null, username, password, "", "");
 				loginManager.addLogin(loginInfo);
 			}
-		},
-		
-		get rootURI() {
+		}
+	});
+	
+	Object.defineProperty(obj, "rootURI", {
+		get: function () {
 			if (!_rootURI) {
 				throw new Error("Root URI not initialized");
 			}
 			return _rootURI.clone();
-		},
-		
-		get parentURI() {
+		}
+	});
+	
+	Object.defineProperty(obj, "parentURI", {
+		get: function () {
 			if (!_parentURI) {
 				throw new Error("Parent URI not initialized");
 			}
 			return _parentURI.clone();
-		},
+		}
+	});
+	
+	obj._init = function (url, dir, username, password) {
+		if (!url) {
+			var msg = "WebDAV URL not provided";
+			Zotero.debug(msg);
+			throw ({
+				message: msg,
+				name: "Z_ERROR_NO_URL",
+				filename: "webdav.js",
+				toString: function () { return this.message; }
+			});
+		}
 		
+		if (username && !password) {
+			var msg = "WebDAV password not provided";
+			Zotero.debug(msg);
+			throw ({
+				message: msg,
+				name: "Z_ERROR_NO_PASSWORD",
+				filename: "webdav.js",
+				toString: function () { return this.message; }
+			});
+		}
 		
-		init: function (url, dir, username, password) {
-			if (!url) {
-				var msg = "WebDAV URL not provided";
-				Zotero.debug(msg);
-				throw ({
-					message: msg,
-					name: "Z_ERROR_NO_URL",
-					filename: "webdav.js",
-					toString: function () { return this.message; }
-				});
+		var ios = Components.classes["@mozilla.org/network/io-service;1"].
+					getService(Components.interfaces.nsIIOService);
+		try {
+			var uri = ios.newURI(url, null, null);
+			if (username) {
+				uri.username = username;
+				uri.password = password;
+			}
+		}
+		catch (e) {
+			Zotero.debug(e);
+			Components.utils.reportError(e);
+			return false;
+		}
+		if (!uri.spec.match(/\/$/)) {
+			uri.spec += "/";
+		}
+		_parentURI = uri;
+		
+		var uri = uri.clone();
+		uri.spec += "zotero/";
+		_rootURI = uri;
+		return true;
+	};
+	
+	
+	obj._initFromPrefs = function () {
+		var scheme = Zotero.Prefs.get('sync.storage.scheme');
+		switch (scheme) {
+			case 'http':
+			case 'https':
+				break;
+			
+			default:
+				throw new Error("Invalid WebDAV scheme '" + scheme + "'");
+		}
+		
+		var url = Zotero.Prefs.get('sync.storage.url');
+		if (!url) {
+			return false;
+		}
+		
+		url = scheme + '://' + url;
+		var dir = "zotero";
+		var username = this._username;
+		var password = this._password;
+		
+		return this._init(url, dir, username, password);
+	};
+	
+	
+	/**
+	 * Begin download process for individual file
+	 *
+	 * @param	{Zotero.Sync.Storage.Request}	[request]
+	 */
+	obj._downloadFile = function (request) {
+		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
+		if (!item) {
+			throw new Error("Item '" + request.name + "' not found");
+		}
+		
+		// Retrieve modification time from server to store locally afterwards 
+		getStorageModificationTime(item, function (item, mdate) {
+			if (!request.isRunning()) {
+				Zotero.debug("Download request '" + request.name
+					+ "' is no longer running after getting mod time");
+				return;
 			}
 			
-			if (username && !password) {
-				var msg = "WebDAV password not provided";
-				Zotero.debug(msg);
-				throw ({
-					message: msg,
-					name: "Z_ERROR_NO_PASSWORD",
-					filename: "webdav.js",
-					toString: function () { return this.message; }
-				});
+			if (!mdate) {
+				Zotero.debug("Remote file not found for item " + Zotero.Items.getLibraryKeyHash(item));
+				request.finish();
+				return;
 			}
 			
-			var ios = Components.classes["@mozilla.org/network/io-service;1"].
-						getService(Components.interfaces.nsIIOService);
 			try {
-				var uri = ios.newURI(url, null, null);
-				if (username) {
-					uri.username = username;
-					uri.password = password;
-				}
-			}
-			catch (e) {
-				Zotero.debug(e);
-				Components.utils.reportError(e);
-				return false;
-			}
-			if (!uri.spec.match(/\/$/)) {
-				uri.spec += "/";
-			}
-			_parentURI = uri;
-			
-			var uri = uri.clone();
-			uri.spec += "zotero/";
-			_rootURI = uri;
-			return true;
-		},
-		
-		
-		initFromPrefs: function () {
-			var scheme = Zotero.Prefs.get('sync.storage.scheme');
-			switch (scheme) {
-				case 'http':
-				case 'https':
-					break;
+				var syncModTime = mdate.getTime();
 				
-				default:
-					throw new Error("Invalid WebDAV scheme '" + scheme + "'");
-			}
-			
-			var url = Zotero.Prefs.get('sync.storage.url');
-			if (!url) {
-				return false;
-			}
-			
-			url = scheme + '://' + url;
-			var dir = "zotero";
-			var username = this.username;
-			var password = this.password;
-			
-			return this.init(url, dir, username, password);
-		},
-		
-		
-		/**
-		 * Begin download process for individual file
-		 *
-		 * @param	{Zotero.Sync.Storage.Request}	[request]
-		 */
-		downloadFile: function (request) {
-			var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
-			if (!item) {
-				throw new Error("Item '" + request.name + "' not found");
-			}
-			
-			// Retrieve modification time from server to store locally afterwards 
-			getStorageModificationTime(item, function (item, mdate) {
-				if (!request.isRunning()) {
-					Zotero.debug("Download request '" + request.name
-						+ "' is no longer running after getting mod time");
-					return;
-				}
-				
-				if (!mdate) {
-					Zotero.debug("Remote file not found for item " + Zotero.Items.getLibraryKeyHash(item));
+				// Skip download if local file exists and matches mod time
+				var file = item.getFile();
+				if (file && file.exists() && syncModTime == file.lastModifiedTime) {
+					Zotero.debug("File mod time matches remote file -- skipping download");
+					
+					Zotero.DB.beginTransaction();
+					var syncState = Zotero.Sync.Storage.getSyncState(item.id);
+					var updateItem = syncState != 1;
+					Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem);
+					Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
+					Zotero.DB.commitTransaction();
+					onChangesMade();
 					request.finish();
 					return;
 				}
 				
-				try {
-					var syncModTime = mdate.getTime();
-					
-					// Skip download if local file exists and matches mod time
-					var file = item.getFile();
-					if (file && file.exists() && syncModTime == file.lastModifiedTime) {
-						Zotero.debug("File mod time matches remote file -- skipping download");
-						
-						Zotero.DB.beginTransaction();
-						var syncState = Zotero.Sync.Storage.getSyncState(item.id);
-						var updateItem = syncState != 1;
-						Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem);
-						Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
-						Zotero.DB.commitTransaction();
-						onChangesMade();
-						request.finish();
-						return;
-					}
-					
-					var uri = getItemURI(item);
-					var destFile = Zotero.getTempDirectory();
-					destFile.append(item.key + '.zip.tmp');
-					if (destFile.exists()) {
-						destFile.remove(false);
-					}
-					
-					var listener = new Zotero.Sync.Storage.StreamListener(
-						{
-							onStart: function (request, data) {
-								if (data.request.isFinished()) {
-									Zotero.debug("Download request " + data.request.name
-										+ " stopped before download started -- closing channel");
-									request.cancel(0x804b0002); // NS_BINDING_ABORTED
-									return;
-								}
-							},
-							onProgress: function (a, b, c) {
-								request.onProgress(a, b, c)
-							},
-							onStop: function (request, status, response, data) {
-								if (status == 404) {
-									var msg = "Remote ZIP file not found for item " + item.key;
-									Zotero.debug(msg, 2);
-									Components.utils.reportError(msg);
-									
-									// Delete the orphaned prop file
-									deleteStorageFiles([item.key + ".prop"]);
-									
-									data.request.finish();
-									return;
-								}
-								else if (status != 200) {
-									var msg = "Unexpected status code " + status
-										+ " for request " + data.request.name
-										+ " in Zotero.Sync.Storage.Module.WebDAV.downloadFile()";
-									Zotero.debug(msg, 1);
-									Components.utils.reportError(msg);
-									Zotero.Sync.Storage.EventManager.error(_defaultError);
-								}
-								
-								// Don't try to process if the request has been cancelled
-								if (data.request.isFinished()) {
-									Zotero.debug("Download request " + data.request.name
-										+ " is no longer running after file download");
-									return;
-								}
-								
-								Zotero.debug("Finished download of " + destFile.path);
-								
-								try {
-									Zotero.Sync.Storage.processDownload(data);
-									data.request.finish();
-								}
-								catch (e) {
-									Zotero.Sync.Storage.EventManager.error(e);
-								}
-							},
-							request: request,
-							item: item,
-							compressed: true,
-							syncModTime: syncModTime
-						}
-					);
-					
-					// Don't display password in console
-					var disp = uri.clone();
-					if (disp.password) {
-						disp.password = '********';
-					}
-					Zotero.debug('Saving ' + disp.spec + ' with saveURI()');
-					const nsIWBP = Components.interfaces.nsIWebBrowserPersist;
-					var wbp = Components
-						.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
-						.createInstance(nsIWBP);
-					wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE;
-					wbp.progressListener = listener;
-					wbp.saveURI(uri, null, null, null, null, destFile);
+				var uri = getItemURI(item);
+				var destFile = Zotero.getTempDirectory();
+				destFile.append(item.key + '.zip.tmp');
+				if (destFile.exists()) {
+					destFile.remove(false);
 				}
-				catch (e) {
-					request.error(e);
-				}
-			});
-		},
-		
-		
-		uploadFile: function (request) {
-			Zotero.Sync.Storage.createUploadFile(request, function (data) { processUploadFile(data); });
-		},
-		
-		
-		getLastSyncTime: function (callback) {
-			// Cache the credentials at the root URI
-			var self = this;
-			this.cacheCredentials(function () {
-				try {
-					var uri = this.rootURI;
-					var successFileURI = uri.clone();
-					successFileURI.spec += "lastsync";
-					Zotero.HTTP.doGet(successFileURI, function (req) {
-						var ts = undefined;
-						try {
-							if (req.responseText) {
-								Zotero.debug(req.responseText);
+				
+				var listener = new Zotero.Sync.Storage.StreamListener(
+					{
+						onStart: function (request, data) {
+							if (data.request.isFinished()) {
+								Zotero.debug("Download request " + data.request.name
+									+ " stopped before download started -- closing channel");
+								request.cancel(0x804b0002); // NS_BINDING_ABORTED
+								return;
 							}
-							Zotero.debug(req.status);
-							
-							if (req.status == 403) {
-								Zotero.debug("Clearing WebDAV authentication credentials", 2);
-								_cachedCredentials = false;
+						},
+						onProgress: function (a, b, c) {
+							request.onProgress(a, b, c)
+						},
+						onStop: function (request, status, response, data) {
+							if (status == 404) {
+								var msg = "Remote ZIP file not found for item " + item.key;
+								Zotero.debug(msg, 2);
+								Components.utils.reportError(msg);
+								
+								// Delete the orphaned prop file
+								deleteStorageFiles([item.key + ".prop"]);
+								
+								data.request.finish();
+								return;
 							}
-							
-							if (req.status != 200 && req.status != 404) {
-								var msg = "Unexpected status code " + req.status + " for HEAD request "
-									+ "in Zotero.Sync.Storage.Module.WebDAV.getLastSyncTime()";
+							else if (status != 200) {
+								var msg = "Unexpected status code " + status
+									+ " for request " + data.request.name
+									+ " in Zotero.Sync.Storage.WebDAV.downloadFile()";
 								Zotero.debug(msg, 1);
 								Components.utils.reportError(msg);
 								Zotero.Sync.Storage.EventManager.error(_defaultError);
 							}
 							
-							if (req.status == 200) {
-								var lastModified = req.getResponseHeader("Last-Modified");
-								var date = new Date(lastModified);
-								Zotero.debug("Last successful storage sync was " + date);
-								ts = Zotero.Date.toUnixTimestamp(date);
-							}
-							else {
-								ts = null;
+							// Don't try to process if the request has been cancelled
+							if (data.request.isFinished()) {
+								Zotero.debug("Download request " + data.request.name
+									+ " is no longer running after file download");
+								return;
 							}
 							
-							callback(ts);
-						}
-						catch(e) {
-							Zotero.debug(e, 1);
-							Components.utils.reportError(e);
-							Zotero.Sync.Storage.EventManager.error(_defaultError);
-						}
-					});
-					return;
+							Zotero.debug("Finished download of " + destFile.path);
+							
+							try {
+								Zotero.Sync.Storage.processDownload(data);
+								data.request.finish();
+							}
+							catch (e) {
+								Zotero.Sync.Storage.EventManager.error(e);
+							}
+						},
+						request: request,
+						item: item,
+						compressed: true,
+						syncModTime: syncModTime
+					}
+				);
+				
+				// Don't display password in console
+				var disp = uri.clone();
+				if (disp.password) {
+					disp.password = '********';
 				}
-				catch (e) {
-					Zotero.debug(e);
-					Components.utils.reportError(e);
-					Zotero.Sync.Storage.EventManager.error(_defaultError);
-				}
-			});
-		},
-		
-		
-		setLastSyncTime: function (callback) {
+				Zotero.debug('Saving ' + disp.spec + ' with saveURI()');
+				const nsIWBP = Components.interfaces.nsIWebBrowserPersist;
+				var wbp = Components
+					.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
+					.createInstance(nsIWBP);
+				wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE;
+				wbp.progressListener = listener;
+				wbp.saveURI(uri, null, null, null, null, destFile);
+			}
+			catch (e) {
+				request.error(e);
+			}
+		});
+	};
+	
+	
+	obj._uploadFile = function (request) {
+		Zotero.Sync.Storage.createUploadFile(request, function (data) { processUploadFile(data); });
+	};
+	
+	
+	obj._getLastSyncTime = function (callback) {
+		// Cache the credentials at the root URI
+		var self = this;
+		this._cacheCredentials(function () {
 			try {
 				var uri = this.rootURI;
 				var successFileURI = uri.clone();
 				successFileURI.spec += "lastsync";
-				
-				Zotero.HTTP.WebDAV.doPut(successFileURI, " ", function (req) {
-					Zotero.debug(req.responseText);
-					Zotero.debug(req.status);
-					
-					switch (req.status) {
-						case 200:
-						case 201:
-						case 204:
-							getLastSyncTime(function (ts) {
-								if (ts) {
-									var sql = "REPLACE INTO version VALUES ('storage_webdav', ?)";
-									Zotero.DB.query(sql, { int: ts });
-								}
-								if (callback) {
-									callback();
-								}
-							});
-							return;
+				Zotero.HTTP.doGet(successFileURI, function (req) {
+					var ts = undefined;
+					try {
+						if (req.responseText) {
+							Zotero.debug(req.responseText);
+						}
+						Zotero.debug(req.status);
+						
+						if (req.status == 403) {
+							Zotero.debug("Clearing WebDAV authentication credentials", 2);
+							_cachedCredentials = false;
+						}
+						
+						if (req.status != 200 && req.status != 404) {
+							var msg = "Unexpected status code " + req.status + " for HEAD request "
+								+ "in Zotero.Sync.Storage.WebDAV.getLastSyncTime()";
+							Zotero.debug(msg, 1);
+							Components.utils.reportError(msg);
+							Zotero.Sync.Storage.EventManager.error(_defaultError);
+						}
+						
+						if (req.status == 200) {
+							var lastModified = req.getResponseHeader("Last-Modified");
+							var date = new Date(lastModified);
+							Zotero.debug("Last successful storage sync was " + date);
+							ts = Zotero.Date.toUnixTimestamp(date);
+						}
+						else {
+							ts = null;
+						}
+						
+						callback(ts);
 					}
-					
-					var msg = "Unexpected error code " + req.status + " uploading storage success file";
-					Zotero.debug(msg, 2);
-					Components.utils.reportError(msg);
-					if (callback) {
-						callback();
+					catch(e) {
+						Zotero.debug(e, 1);
+						Components.utils.reportError(e);
+						Zotero.Sync.Storage.EventManager.error(_defaultError);
 					}
 				});
+				return;
 			}
 			catch (e) {
 				Zotero.debug(e);
 				Components.utils.reportError(e);
-				if (callback) {
-					callback();
-				}
-				return;
-			}
-		},
-		
-		
-		cacheCredentials: function (callback) {
-			if (_cachedCredentials) {
-				Zotero.debug("Credentials are already cached");
-				setTimeout(function () {
-					callback();
-				}, 0);
-				return false;
+				Zotero.Sync.Storage.EventManager.error(_defaultError);
 			}
+		});
+	};
+	
+	
+	obj._setLastSyncTime = function (callback) {
+		try {
+			var uri = this.rootURI;
+			var successFileURI = uri.clone();
+			successFileURI.spec += "lastsync";
 			
-			Zotero.HTTP.doOptions(this.rootURI, function (req) {
-				checkResponse(req);
-				
-				if (req.status != 200) {
-					var msg = "Unexpected status code " + req.status + " for OPTIONS request "
-						+ "in Zotero.Sync.Storage.Module.WebDAV.getLastSyncTime()";
-					Zotero.debug(msg, 1);
-					Components.utils.reportError(msg);
-					Zotero.Sync.Storage.EventManager.error(_defaultErrorRestart);
-				}
-				Zotero.debug("Credentials are cached");
-				_cachedCredentials = true;
-				callback();
-			});
-			return true;
-		},
-		
-		
-		/**
-		 * @param	{Function}	callback			Function to pass URI and result value to
-		 * @param	{Object}		errorCallbacks
-		 */
-		checkServer: function (callback) {
-			this.initFromPrefs();
-			
-			try {
-				var parentURI = this.parentURI;
-				var uri = this.rootURI;
-			}
-			catch (e) {
-				switch (e.name) {
-					case 'Z_ERROR_NO_URL':
-						callback(null, Zotero.Sync.Storage.ERROR_NO_URL);
-						return;
-					
-					case 'Z_ERROR_NO_PASSWORD':
-						callback(null, Zotero.Sync.Storage.ERROR_NO_PASSWORD);
-						return;
-						
-					default:
-						Zotero.debug(e);
-						Components.utils.reportError(e);
-						callback(null, Zotero.Sync.Storage.ERROR_UNKNOWN);
-						return;
-				}
-			}
-			
-			var requestHolder = { request: null };
-			
-			var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n';
-			var D = new Namespace("D", "DAV:");
-			var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"';
-			
-			var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>');
-			requestXML.D::prop = '';
-			// IIS 5.1 requires at least one property in PROPFIND
-			requestXML.D::prop.D::getcontentlength = '';
-			
-			var xmlstr = prolog + requestXML.toXMLString();
-			
-			// Test whether URL is WebDAV-enabled
-			var request = Zotero.HTTP.doOptions(uri, function (req) {
-				// Timeout
-				if (req.status == 0) {
-					checkResponse(req);
-					
-					callback(uri, Zotero.Sync.Storage.ERROR_UNREACHABLE);
-					return;
-				}
-				
-				Zotero.debug(req.getAllResponseHeaders());
+			Zotero.HTTP.WebDAV.doPut(successFileURI, " ", function (req) {
 				Zotero.debug(req.responseText);
 				Zotero.debug(req.status);
 				
 				switch (req.status) {
+					case 200:
+					case 201:
+					case 204:
+						getLastSyncTime(function (ts) {
+							if (ts) {
+								var sql = "REPLACE INTO version VALUES ('storage_webdav', ?)";
+								Zotero.DB.query(sql, { int: ts });
+							}
+							if (callback) {
+								callback();
+							}
+						});
+						return;
+				}
+				
+				var msg = "Unexpected error code " + req.status + " uploading storage success file";
+				Zotero.debug(msg, 2);
+				Components.utils.reportError(msg);
+				if (callback) {
+					callback();
+				}
+			});
+		}
+		catch (e) {
+			Zotero.debug(e);
+			Components.utils.reportError(e);
+			if (callback) {
+				callback();
+			}
+			return;
+		}
+	};
+	
+	
+	obj._cacheCredentials = function (callback) {
+		if (_cachedCredentials) {
+			Zotero.debug("Credentials are already cached");
+			setTimeout(function () {
+				callback();
+			}, 0);
+			return false;
+		}
+		
+		Zotero.HTTP.doOptions(this.rootURI, function (req) {
+			checkResponse(req);
+			
+			if (req.status != 200) {
+				var msg = "Unexpected status code " + req.status + " for OPTIONS request "
+					+ "in Zotero.Sync.Storage.WebDAV.getLastSyncTime()";
+				Zotero.debug(msg, 1);
+				Components.utils.reportError(msg);
+				Zotero.Sync.Storage.EventManager.error(_defaultErrorRestart);
+			}
+			Zotero.debug("Credentials are cached");
+			_cachedCredentials = true;
+			callback();
+		});
+		return true;
+	};
+	
+	
+	/**
+	 * @param	{Function}	callback			Function to pass URI and result value to
+	 * @param	{Object}		errorCallbacks
+	 */
+	obj._checkServer = function (callback) {
+		this._initFromPrefs();
+		
+		try {
+			var parentURI = this.parentURI;
+			var uri = this.rootURI;
+		}
+		catch (e) {
+			switch (e.name) {
+				case 'Z_ERROR_NO_URL':
+					callback(null, Zotero.Sync.Storage.ERROR_NO_URL);
+					return;
+				
+				case 'Z_ERROR_NO_PASSWORD':
+					callback(null, Zotero.Sync.Storage.ERROR_NO_PASSWORD);
+					return;
+					
+				default:
+					Zotero.debug(e);
+					Components.utils.reportError(e);
+					callback(null, Zotero.Sync.Storage.ERROR_UNKNOWN);
+					return;
+			}
+		}
+		
+		var requestHolder = { request: null };
+		
+		var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n';
+		var D = new Namespace("D", "DAV:");
+		var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"';
+		
+		var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>');
+		requestXML.D::prop = '';
+		// IIS 5.1 requires at least one property in PROPFIND
+		requestXML.D::prop.D::getcontentlength = '';
+		
+		var xmlstr = prolog + requestXML.toXMLString();
+		
+		// Test whether URL is WebDAV-enabled
+		var request = Zotero.HTTP.doOptions(uri, function (req) {
+			// Timeout
+			if (req.status == 0) {
+				checkResponse(req);
+				
+				callback(uri, Zotero.Sync.Storage.ERROR_UNREACHABLE);
+				return;
+			}
+			
+			Zotero.debug(req.getAllResponseHeaders());
+			Zotero.debug(req.responseText);
+			Zotero.debug(req.status);
+			
+			switch (req.status) {
+				case 400:
+					callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST);
+					return;
+				
+				case 401:
+					callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
+					return;
+				
+				case 403:
+					callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
+					return;
+				
+				case 500:
+					callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR);
+					return;
+			}
+			
+			var dav = req.getResponseHeader("DAV");
+			if (dav == null) {
+				callback(uri, Zotero.Sync.Storage.ERROR_NOT_DAV);
+				return;
+			}
+			
+			// Get the Authorization header used in case we need to do a request
+			// on the parent below
+			var channelAuthorization = Zotero.HTTP.getChannelAuthorization(req.channel);
+			
+			var headers = { Depth: 0 };
+			
+			// Test whether Zotero directory exists
+			Zotero.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) {
+				Zotero.debug(req.responseText);
+				Zotero.debug(req.status);
+				
+				switch (req.status) {
+					case 207:
+						// Test if Zotero directory is writable
+						var testFileURI = uri.clone();
+						testFileURI.spec += "zotero-test-file";
+						Zotero.HTTP.WebDAV.doPut(testFileURI, " ", function (req) {
+							Zotero.debug(req.responseText);
+							Zotero.debug(req.status);
+							
+							switch (req.status) {
+								case 200:
+								case 201:
+								case 204:
+									Zotero.HTTP.doGet(
+										testFileURI,
+										function (req) {
+											Zotero.debug(req.responseText);
+											Zotero.debug(req.status);
+											
+											switch (req.status) {
+												case 200:
+													// Delete test file
+													Zotero.HTTP.WebDAV.doDelete(
+														testFileURI,
+														function (req) {
+															Zotero.debug(req.responseText);
+															Zotero.debug(req.status);
+															
+															switch (req.status) {
+																case 200: // IIS 5.1 and Sakai return 200
+																case 204:
+																	callback(uri, Zotero.Sync.Storage.SUCCESS);
+																	return;
+																
+																case 401:
+																	callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
+																	return;
+																
+																case 403:
+																	callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
+																	return;
+																
+																default:
+																	callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
+																	return;
+															}
+														}
+													);
+													return;
+												
+												case 401:
+													callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
+													return;
+												
+												case 403:
+													callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
+													return;
+												
+												// IIS 6+ configured not to serve extensionless files or .prop files
+												// http://support.microsoft.com/kb/326965
+												case 404:
+													callback(uri, Zotero.Sync.Storage.ERROR_FILE_MISSING_AFTER_UPLOAD);
+													return;
+												
+												case 500:
+													callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR);
+													return;
+												
+												default:
+													callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
+													return;
+											}
+										}
+									);
+									return;
+								
+								case 401:
+									callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
+									return;
+								
+								case 403:
+									callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
+									return;
+								
+								case 500:
+									callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR);
+									return;
+								
+								default:
+									callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
+									return;
+							}
+						});
+						return;
+					
 					case 400:
 						callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST);
 						return;
@@ -1165,530 +1304,399 @@ Zotero.Sync.Storage.Module.WebDAV = (function () {
 						callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
 						return;
 					
-					case 500:
-						callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR);
-						return;
-				}
-				
-				var dav = req.getResponseHeader("DAV");
-				if (dav == null) {
-					callback(uri, Zotero.Sync.Storage.ERROR_NOT_DAV);
-					return;
-				}
-				
-				// Get the Authorization header used in case we need to do a request
-				// on the parent below
-				var channelAuthorization = Zotero.HTTP.getChannelAuthorization(req.channel);
-				
-				var headers = { Depth: 0 };
-				
-				// Test whether Zotero directory exists
-				Zotero.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) {
-					Zotero.debug(req.responseText);
-					Zotero.debug(req.status);
-					
-					switch (req.status) {
-						case 207:
-							// Test if Zotero directory is writable
-							var testFileURI = uri.clone();
-							testFileURI.spec += "zotero-test-file";
-							Zotero.HTTP.WebDAV.doPut(testFileURI, " ", function (req) {
+					case 404:
+						// Include Authorization header from /zotero request,
+						// since Firefox probably won't apply it to the parent request
+						var newHeaders = {};
+						for (var header in headers) {
+							newHeaders[header] = headers[header];
+						}
+						newHeaders["Authorization"] = channelAuthorization;
+						
+						// Zotero directory wasn't found, so see if at least
+						// the parent directory exists
+						Zotero.HTTP.WebDAV.doProp("PROPFIND", parentURI, xmlstr,
+							function (req) {
 								Zotero.debug(req.responseText);
 								Zotero.debug(req.status);
 								
 								switch (req.status) {
-									case 200:
-									case 201:
-									case 204:
-										Zotero.HTTP.doGet(
-											testFileURI,
-											function (req) {
-												Zotero.debug(req.responseText);
-												Zotero.debug(req.status);
-												
-												switch (req.status) {
-													case 200:
-														// Delete test file
-														Zotero.HTTP.WebDAV.doDelete(
-															testFileURI,
-															function (req) {
-																Zotero.debug(req.responseText);
-																Zotero.debug(req.status);
-																
-																switch (req.status) {
-																	case 200: // IIS 5.1 and Sakai return 200
-																	case 204:
-																		callback(
-																			uri,
-																			Zotero.Sync.Storage.SUCCESS
-																		);
-																		return;
-																	
-																	case 401:
-																		callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
-																		return;
-																	
-																	case 403:
-																		callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
-																		return;
-																	
-																	default:
-																		callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
-																		return;
-																}
-															}
-														);
-														return;
-													
-													case 401:
-														callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
-														return;
-													
-													case 403:
-														callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
-														return;
-													
-													// IIS 6+ configured not to serve extensionless files or .prop files
-													// http://support.microsoft.com/kb/326965
-													case 404:
-														callback(uri, Zotero.Sync.Storage.ERROR_FILE_MISSING_AFTER_UPLOAD);
-														return;
-													
-													case 500:
-														callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR);
-														return;
-													
-													default:
-														callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
-														return;
-												}
-											}
-										);
+									// Parent directory existed
+									case 207:
+										callback(uri, Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND);
+										return;
+									
+									case 400:
+										callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST);
 										return;
 									
 									case 401:
 										callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
 										return;
 									
-									case 403:
-										callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
-										return;
-									
-									case 500:
-										callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR);
+									// Parent directory wasn't found either
+									case 404:
+										callback(uri, Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND);
 										return;
 									
 									default:
 										callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
 										return;
 								}
-							});
-							return;
-						
-						case 400:
-							callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST);
-							return;
-						
-						case 401:
-							callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
-							return;
-						
-						case 403:
-							callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN);
-							return;
-						
-						case 404:
-							// Include Authorization header from /zotero request,
-							// since Firefox probably won't apply it to the parent request
-							var newHeaders = {};
-							for (var header in headers) {
-								newHeaders[header] = headers[header];
-							}
-							newHeaders["Authorization"] = channelAuthorization;
-							
-							// Zotero directory wasn't found, so see if at least
-							// the parent directory exists
-							Zotero.HTTP.WebDAV.doProp("PROPFIND", this.parentURI, xmlstr,
-								function (req) {
-									Zotero.debug(req.responseText);
-									Zotero.debug(req.status);
-									
-									switch (req.status) {
-										// Parent directory existed
-										case 207:
-											callback(uri, Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND);
-											return;
-										
-										case 400:
-											callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST);
-											return;
-										
-										case 401:
-											callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED);
-											return;
-										
-										// Parent directory wasn't found either
-										case 404:
-											callback(uri, Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND);
-											return;
-										
-										default:
-											callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
-											return;
-									}
-								},  newHeaders);
-							return;
-						
-						case 500:
-							callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR);
-							return;
-							
-						default:
-							callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
-							return;
-					}
-				}, headers);
-			});
-			
-			if (!request) {
-				callback(uri, Zotero.Sync.Storage.ERROR_OFFLINE);
-			}
-			
-			requestHolder.request = request;
-			return requestHolder;
-		},
-		
-		
-		checkServerCallback: function (uri, status, window, skipSuccessMessage) {
-			var promptService =
-				Components.classes["@mozilla.org/embedcomp/prompt-service;1"].
-					createInstance(Components.interfaces.nsIPromptService);
-			if (uri) {
-				var spec = uri.scheme + '://' + uri.hostPort + uri.path;
-			}
-			
-			switch (status) {
-				case Zotero.Sync.Storage.SUCCESS:
-					if (!skipSuccessMessage) {
-						promptService.alert(
-							window,
-							Zotero.getString('sync.storage.serverConfigurationVerified'),
-							Zotero.getString('sync.storage.fileSyncSetUp')
-						);
-					}
-					Zotero.Prefs.set("sync.storage.verified", true);
-					return true;
-				
-				case Zotero.Sync.Storage.ERROR_NO_URL:
-					var errorMessage = Zotero.getString('sync.storage.error.webdav.enterURL');
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_NO_PASSWORD:
-					var errorMessage = Zotero.getString('sync.error.enterPassword');
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_UNREACHABLE:
-					var errorMessage = Zotero.getString('sync.storage.error.serverCouldNotBeReached', uri.host);
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_NOT_DAV:
-					var errorMessage = Zotero.getString('sync.storage.error.webdav.invalidURL', spec);
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_AUTH_FAILED:
-					var errorTitle = Zotero.getString('general.permissionDenied');
-					var errorMessage = Zotero.localeJoin([
-						Zotero.getString('sync.storage.error.webdav.invalidLogin'),
-						Zotero.getString('sync.storage.error.checkFileSyncSettings')
-					]);
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_FORBIDDEN:
-					var errorTitle = Zotero.getString('general.permissionDenied');
-					var errorMessage = Zotero.localeJoin([
-						Zotero.getString('sync.storage.error.webdav.permissionDenied', uri.path),
-						Zotero.getString('sync.storage.error.checkFileSyncSettings')
-					]);
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND:
-					var errorTitle = Zotero.getString('sync.storage.error.directoryNotFound');
-					var parentSpec = spec.replace(/\/zotero\/$/, "");
-					var errorMessage = Zotero.getString('sync.storage.error.doesNotExist', parentSpec);
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND:
-					var create = promptService.confirmEx(
-						window,
-						Zotero.getString('sync.storage.error.directoryNotFound'),
-						Zotero.getString('sync.storage.error.doesNotExist', spec) + "\n\n"
-							+ Zotero.getString('sync.storage.error.createNow'),
-						promptService.BUTTON_POS_0
-							* promptService.BUTTON_TITLE_IS_STRING
-						+ promptService.BUTTON_POS_1
-							* promptService.BUTTON_TITLE_CANCEL,
-						Zotero.getString('general.create'),
-						null, null, null, {}
-					);
-					
-					if (create != 0) {
+							},  newHeaders);
 						return;
-					}
 					
-					createServerDirectory(function (uri, status) {
-						switch (status) {
-							case Zotero.Sync.Storage.SUCCESS:
-								if (!skipSuccessMessage) {
-									promptService.alert(
-										window,
-										Zotero.getString('sync.storage.serverConfigurationVerified'),
-										Zotero.getString('sync.storage.fileSyncSetUp')
-									);
-								}
-								Zotero.Prefs.set("sync.storage.verified", true);
-								return true;
-							
-							case Zotero.Sync.Storage.ERROR_FORBIDDEN:
-								var errorTitle = Zotero.getString('general.permissionDenied');
-								var errorMessage = Zotero.getString('sync.storage.error.permissionDeniedAtAddress') + "\n\n"
-									+ spec + "\n\n"
-									+ Zotero.getString('sync.storage.error.checkFileSyncSettings');
-								break;
-						}
+					case 500:
+						callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR);
+						return;
 						
-						// TEMP
-						if (!errorMessage) {
-							var errorMessage = status;
-						}
-						promptService.alert(window, errorTitle, errorMessage);
-					});
-					
-					return false;
-				
-				case Zotero.Sync.Storage.ERROR_FILE_MISSING_AFTER_UPLOAD:
-					// TODO: localize
-					var errorTitle = "WebDAV Server Configuration Error";
-					var errorMessage = "Your WebDAV server must be configured to serve files without extensions "
-						+ "and files with .prop extensions in order to work with Zotero.";
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_SERVER_ERROR:
-					// TODO: localize
-					var errorTitle = "WebDAV Server Configuration Error";
-					var errorMessage = "Your WebDAV server returned an internal error."
-						+ "\n\n" + Zotero.getString('sync.storage.error.checkFileSyncSettings');
-					break;
-				
-				case Zotero.Sync.Storage.ERROR_UNKNOWN:
-					var errorMessage = Zotero.localeJoin([
-						Zotero.getString('general.unknownErrorOccurred'),
-						Zotero.getString('sync.storage.error.checkFileSyncSettings')
-					]);
-					break;
-			}
-			
-			if (!skipSuccessMessage) {
-				if (!errorTitle) {
-					var errorTitle = Zotero.getString("general.error");
+					default:
+						callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN);
+						return;
 				}
-				// TEMP
-				if (!errorMessage) {
-					var errorMessage = status;
-				}
-				promptService.alert(window, errorTitle, errorMessage);
-			}
-			return false;
-		},
+			}, headers);
+		});
 		
+		if (!request) {
+			callback(uri, Zotero.Sync.Storage.ERROR_OFFLINE);
+		}
 		
-		/**
-		 * Remove files on storage server that were deleted locally more than
-		 * sync.storage.deleteDelayDays days ago
-		 *
-		 * @param	{Function}	callback		Passed number of files deleted
-		 */
-		purgeDeletedStorageFiles: function (callback) {
-			if (!this.active) {
-				return;
-			}
-			
-			Zotero.debug("Purging deleted storage files");
-			var files = Zotero.Sync.Storage.getDeletedFiles();
-			if (!files) {
-				Zotero.debug("No files to delete remotely");
-				if (callback) {
-					callback();
+		requestHolder.request = request;
+		return requestHolder;
+	};
+	
+	
+	obj._checkServerCallback = function (uri, status, window, skipSuccessMessage) {
+		var promptService =
+			Components.classes["@mozilla.org/embedcomp/prompt-service;1"].
+				createInstance(Components.interfaces.nsIPromptService);
+		if (uri) {
+			var spec = uri.scheme + '://' + uri.hostPort + uri.path;
+		}
+		
+		switch (status) {
+			case Zotero.Sync.Storage.SUCCESS:
+				if (!skipSuccessMessage) {
+					promptService.alert(
+						window,
+						Zotero.getString('sync.storage.serverConfigurationVerified'),
+						Zotero.getString('sync.storage.fileSyncSetUp')
+					);
 				}
-				Zotero.Sync.Storage.EventManager.skip();
-				return;
-			}
+				Zotero.Prefs.set("sync.storage.verified", true);
+				return true;
 			
-			// Add .zip extension
-			var files = files.map(function (file) file + ".zip");
+			case Zotero.Sync.Storage.ERROR_NO_URL:
+				var errorMessage = Zotero.getString('sync.storage.error.webdav.enterURL');
+				break;
 			
-			deleteStorageFiles(files, function (results) {
-				// Remove deleted and nonexistent files from storage delete log
-				var toPurge = results.deleted.concat(results.missing);
-				if (toPurge.length > 0) {
-					var done = 0;
-					var maxFiles = 999;
-					var numFiles = toPurge.length;
-					
-					Zotero.DB.beginTransaction();
-					
-					do {
-						var chunk = toPurge.splice(0, maxFiles);
-						var sql = "DELETE FROM storageDeleteLog WHERE key IN ("
-							+ chunk.map(function () '?').join() + ")";
-						Zotero.DB.query(sql, chunk);
-						done += chunk.length;
+			case Zotero.Sync.Storage.ERROR_NO_PASSWORD:
+				var errorMessage = Zotero.getString('sync.error.enterPassword');
+				break;
+			
+			case Zotero.Sync.Storage.ERROR_UNREACHABLE:
+				var errorMessage = Zotero.getString('sync.storage.error.serverCouldNotBeReached', uri.host);
+				break;
+			
+			case Zotero.Sync.Storage.ERROR_NOT_DAV:
+				var errorMessage = Zotero.getString('sync.storage.error.webdav.invalidURL', spec);
+				break;
+			
+			case Zotero.Sync.Storage.ERROR_AUTH_FAILED:
+				var errorTitle = Zotero.getString('general.permissionDenied');
+				var errorMessage = Zotero.localeJoin([
+					Zotero.getString('sync.storage.error.webdav.invalidLogin'),
+					Zotero.getString('sync.storage.error.checkFileSyncSettings')
+				]);
+				break;
+			
+			case Zotero.Sync.Storage.ERROR_FORBIDDEN:
+				var errorTitle = Zotero.getString('general.permissionDenied');
+				var errorMessage = Zotero.localeJoin([
+					Zotero.getString('sync.storage.error.webdav.permissionDenied', uri.path),
+					Zotero.getString('sync.storage.error.checkFileSyncSettings')
+				]);
+				break;
+			
+			case Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND:
+				var errorTitle = Zotero.getString('sync.storage.error.directoryNotFound');
+				var parentSpec = spec.replace(/\/zotero\/$/, "");
+				var errorMessage = Zotero.getString('sync.storage.error.doesNotExist', parentSpec);
+				break;
+			
+			case Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND:
+				var create = promptService.confirmEx(
+					window,
+					Zotero.getString('sync.storage.error.directoryNotFound'),
+					Zotero.getString('sync.storage.error.doesNotExist', spec) + "\n\n"
+						+ Zotero.getString('sync.storage.error.createNow'),
+					promptService.BUTTON_POS_0
+						* promptService.BUTTON_TITLE_IS_STRING
+					+ promptService.BUTTON_POS_1
+						* promptService.BUTTON_TITLE_CANCEL,
+					Zotero.getString('general.create'),
+					null, null, null, {}
+				);
+				
+				if (create != 0) {
+					return;
+				}
+				
+				createServerDirectory(function (uri, status) {
+					switch (status) {
+						case Zotero.Sync.Storage.SUCCESS:
+							if (!skipSuccessMessage) {
+								promptService.alert(
+									window,
+									Zotero.getString('sync.storage.serverConfigurationVerified'),
+									Zotero.getString('sync.storage.fileSyncSetUp')
+								);
+							}
+							Zotero.Prefs.set("sync.storage.verified", true);
+							return true;
+						
+						case Zotero.Sync.Storage.ERROR_FORBIDDEN:
+							var errorTitle = Zotero.getString('general.permissionDenied');
+							var errorMessage = Zotero.getString('sync.storage.error.permissionDeniedAtAddress') + "\n\n"
+								+ spec + "\n\n"
+								+ Zotero.getString('sync.storage.error.checkFileSyncSettings');
+							break;
 					}
-					while (done < numFiles);
 					
-					Zotero.DB.commitTransaction();
+					// TEMP
+					if (!errorMessage) {
+						var errorMessage = status;
+					}
+					promptService.alert(window, errorTitle, errorMessage);
+				});
+				
+				return false;
+			
+			case Zotero.Sync.Storage.ERROR_FILE_MISSING_AFTER_UPLOAD:
+				// TODO: localize
+				var errorTitle = "WebDAV Server Configuration Error";
+				var errorMessage = "Your WebDAV server must be configured to serve files without extensions "
+					+ "and files with .prop extensions in order to work with Zotero.";
+				break;
+			
+			case Zotero.Sync.Storage.ERROR_SERVER_ERROR:
+				// TODO: localize
+				var errorTitle = "WebDAV Server Configuration Error";
+				var errorMessage = "Your WebDAV server returned an internal error."
+					+ "\n\n" + Zotero.getString('sync.storage.error.checkFileSyncSettings');
+				break;
+			
+			case Zotero.Sync.Storage.ERROR_UNKNOWN:
+				var errorMessage = Zotero.localeJoin([
+					Zotero.getString('general.unknownErrorOccurred'),
+					Zotero.getString('sync.storage.error.checkFileSyncSettings')
+				]);
+				break;
+		}
+		
+		if (!skipSuccessMessage) {
+			if (!errorTitle) {
+				var errorTitle = Zotero.getString("general.error");
+			}
+			// TEMP
+			if (!errorMessage) {
+				var errorMessage = status;
+			}
+			promptService.alert(window, errorTitle, errorMessage);
+		}
+		return false;
+	};
+	
+	
+	/**
+	 * Remove files on storage server that were deleted locally more than
+	 * sync.storage.deleteDelayDays days ago
+	 *
+	 * @param	{Function}	callback		Passed number of files deleted
+	 */
+	obj._purgeDeletedStorageFiles = function (callback) {
+		if (!this._active) {
+			return;
+		}
+		
+		Zotero.debug("Purging deleted storage files");
+		var files = Zotero.Sync.Storage.getDeletedFiles();
+		if (!files) {
+			Zotero.debug("No files to delete remotely");
+			if (callback) {
+				callback();
+			}
+			Zotero.Sync.Storage.EventManager.skip();
+			return;
+		}
+		
+		// Add .zip extension
+		var files = files.map(function (file) file + ".zip");
+		
+		deleteStorageFiles(files, function (results) {
+			// Remove deleted and nonexistent files from storage delete log
+			var toPurge = results.deleted.concat(results.missing);
+			if (toPurge.length > 0) {
+				var done = 0;
+				var maxFiles = 999;
+				var numFiles = toPurge.length;
+				
+				Zotero.DB.beginTransaction();
+				
+				do {
+					var chunk = toPurge.splice(0, maxFiles);
+					var sql = "DELETE FROM storageDeleteLog WHERE key IN ("
+						+ chunk.map(function () '?').join() + ")";
+					Zotero.DB.query(sql, chunk);
+					done += chunk.length;
+				}
+				while (done < numFiles);
+				
+				Zotero.DB.commitTransaction();
+			}
+			
+			if (callback) {
+				callback(results.deleted.length);
+			}
+			
+			Zotero.Sync.Storage.EventManager.success();
+		});
+	};
+	
+	
+	/**
+	 * Delete orphaned storage files older than a day before last sync time
+	 *
+	 * @param	{Function}	callback
+	 */
+	obj._purgeOrphanedStorageFiles = function (callback) {
+		const daysBeforeSyncTime = 1;
+		
+		if (!this._active) {
+			Zotero.Sync.Storage.EventManager.skip();
+			return;
+		}
+		
+		// If recently purged, skip
+		var lastpurge = Zotero.Prefs.get('lastWebDAVOrphanPurge');
+		var days = 10;
+		if (lastpurge && new Date(lastpurge * 1000) > (new Date() - (1000 * 60 * 60 * 24 * days))) {
+			Zotero.Sync.Storage.EventManager.skip();
+			return;
+		}
+		
+		Zotero.debug("Purging orphaned storage files");
+		
+		var uri = this.rootURI;
+		var path = uri.path;
+		
+		var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n';
+		var D = new Namespace("D", "DAV:");
+		var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"';
+		
+		var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>');
+		requestXML.D::prop = '';
+		requestXML.D::prop.D::getlastmodified = '';
+		
+		var xmlstr = prolog + requestXML.toXMLString();
+		
+		var lastSyncDate = new Date(Zotero.Sync.Server.lastLocalSyncTime * 1000);
+		
+		Zotero.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) {
+			Zotero.debug(req.responseText);
+				
+			var funcName = "Zotero.Sync.Storage.purgeOrphanedStorageFiles()";
+			
+			// Strip XML declaration and convert to E4X
+			var xml = new XML(req.responseText.replace(/<\?xml.*\?>/, ''));
+			
+			var deleteFiles = [];
+			var trailingSlash = !!path.match(/\/$/);
+			for each(var response in xml.D::response) {
+				var href = response.D::href.toString();
+				
+				// Strip trailing slash if there isn't one on the root path
+				if (!trailingSlash) {
+					href = href.replace(/\/$/, "")
 				}
 				
+				// Absolute
+				if (href.match(/^https?:\/\//)) {
+					var ios = Components.classes["@mozilla.org/network/io-service;1"].
+								getService(Components.interfaces.nsIIOService);
+					var href = ios.newURI(href, null, null);
+					href = href.path;
+				}
+				
+				// Skip root URI
+				if (href == path
+						// Some Apache servers respond with a "/zotero" href
+						// even for a "/zotero/" request
+						|| (trailingSlash && href + '/' == path)
+						// Try URL-encoded as well, as above
+						|| decodeURIComponent(href) == path) {
+					continue;
+				}
+				
+				if (href.indexOf(path) == -1
+						// Try URL-encoded as well, in case there's a '~' or similar
+						// character in the URL and the server (e.g., Sakai) is
+						// encoding the value
+						&& decodeURIComponent(href).indexOf(path) == -1) {
+					Zotero.Sync.Storage.EventManager.error(
+						"DAV:href '" + href + "' does not begin with path '"
+							+ path + "' in " + funcName
+					);
+				}
+				
+				var matches = href.match(/[^\/]+$/);
+				if (!matches) {
+					Zotero.Sync.Storage.EventManager.error(
+						"Unexpected href '" + href + "' in " + funcName
+					)
+				}
+				var file = matches[0];
+				
+				if (file.indexOf('.') == 0) {
+					Zotero.debug("Skipping hidden file " + file);
+					continue;
+				}
+				if (!file.match(/\.zip$/) && !file.match(/\.prop$/)) {
+					Zotero.debug("Skipping file " + file);
+					continue;
+				}
+				
+				var key = file.replace(/\.(zip|prop)$/, '');
+				var item = Zotero.Items.getByLibraryAndKey(null, key);
+				if (item) {
+					Zotero.debug("Skipping existing file " + file);
+					continue;
+				}
+				
+				Zotero.debug("Checking orphaned file " + file);
+				
+				// TODO: Parse HTTP date properly
+				var lastModified = response..*::getlastmodified.toString();
+				lastModified = Zotero.Date.strToISO(lastModified);
+				lastModified = Zotero.Date.sqlToDate(lastModified);
+				
+				// Delete files older than a day before last sync time
+				var days = (lastSyncDate - lastModified) / 1000 / 60 / 60 / 24;
+				
+				if (days > daysBeforeSyncTime) {
+					deleteFiles.push(file);
+				}
+			}
+			
+			deleteStorageFiles(deleteFiles, function (results) {
+				Zotero.Prefs.set("lastWebDAVOrphanPurge", Math.round(new Date().getTime() / 1000))
 				if (callback) {
-					callback(results.deleted.length);
+					callback(results);
 				}
-				
 				Zotero.Sync.Storage.EventManager.success();
 			});
-		},
-		
-		
-		/**
-		 * Delete orphaned storage files older than a day before last sync time
-		 *
-		 * @param	{Function}	callback
-		 */
-		purgeOrphanedStorageFiles: function (callback) {
-			const daysBeforeSyncTime = 1;
-			
-			if (!this.active) {
-				Zotero.Sync.Storage.EventManager.skip();
-				return;
-			}
-			
-			// If recently purged, skip
-			var lastpurge = Zotero.Prefs.get('lastWebDAVOrphanPurge');
-			var days = 10;
-			if (lastpurge && new Date(lastpurge * 1000) > (new Date() - (1000 * 60 * 60 * 24 * days))) {
-				Zotero.Sync.Storage.EventManager.skip();
-				return;
-			}
-			
-			Zotero.debug("Purging orphaned storage files");
-			
-			var uri = this.rootURI;
-			var path = uri.path;
-			
-			var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n';
-			var D = new Namespace("D", "DAV:");
-			var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"';
-			
-			var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>');
-			requestXML.D::prop = '';
-			requestXML.D::prop.D::getlastmodified = '';
-			
-			var xmlstr = prolog + requestXML.toXMLString();
-			
-			var lastSyncDate = new Date(Zotero.Sync.Server.lastLocalSyncTime * 1000);
-			
-			Zotero.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) {
-				Zotero.debug(req.responseText);
-					
-				var funcName = "Zotero.Sync.Storage.purgeOrphanedStorageFiles()";
-				
-				// Strip XML declaration and convert to E4X
-				var xml = new XML(req.responseText.replace(/<\?xml.*\?>/, ''));
-				
-				var deleteFiles = [];
-				var trailingSlash = !!path.match(/\/$/);
-				for each(var response in xml.D::response) {
-					var href = response.D::href.toString();
-					
-					// Strip trailing slash if there isn't one on the root path
-					if (!trailingSlash) {
-						href = href.replace(/\/$/, "")
-					}
-					
-					// Absolute
-					if (href.match(/^https?:\/\//)) {
-						var ios = Components.classes["@mozilla.org/network/io-service;1"].
-									getService(Components.interfaces.nsIIOService);
-						var href = ios.newURI(href, null, null);
-						href = href.path;
-					}
-					
-					// Skip root URI
-					if (href == path
-							// Some Apache servers respond with a "/zotero" href
-							// even for a "/zotero/" request
-							|| (trailingSlash && href + '/' == path)
-							// Try URL-encoded as well, as above
-							|| decodeURIComponent(href) == path) {
-						continue;
-					}
-					
-					if (href.indexOf(path) == -1
-							// Try URL-encoded as well, in case there's a '~' or similar
-							// character in the URL and the server (e.g., Sakai) is
-							// encoding the value
-							&& decodeURIComponent(href).indexOf(path) == -1) {
-						Zotero.Sync.Storage.EventManager.error(
-							"DAV:href '" + href + "' does not begin with path '"
-								+ path + "' in " + funcName
-						);
-					}
-					
-					var matches = href.match(/[^\/]+$/);
-					if (!matches) {
-						Zotero.Sync.Storage.EventManager.error(
-							"Unexpected href '" + href + "' in " + funcName
-						)
-					}
-					var file = matches[0];
-					
-					if (file.indexOf('.') == 0) {
-						Zotero.debug("Skipping hidden file " + file);
-						continue;
-					}
-					if (!file.match(/\.zip$/) && !file.match(/\.prop$/)) {
-						Zotero.debug("Skipping file " + file);
-						continue;
-					}
-					
-					var key = file.replace(/\.(zip|prop)$/, '');
-					var item = Zotero.Items.getByLibraryAndKey(null, key);
-					if (item) {
-						Zotero.debug("Skipping existing file " + file);
-						continue;
-					}
-					
-					Zotero.debug("Checking orphaned file " + file);
-					
-					// TODO: Parse HTTP date properly
-					var lastModified = response..*::getlastmodified.toString();
-					lastModified = Zotero.Date.strToISO(lastModified);
-					lastModified = Zotero.Date.sqlToDate(lastModified);
-					
-					// Delete files older than a day before last sync time
-					var days = (lastSyncDate - lastModified) / 1000 / 60 / 60 / 24;
-					
-					if (days > daysBeforeSyncTime) {
-						deleteFiles.push(file);
-					}
-				}
-				
-				deleteStorageFiles(deleteFiles, function (results) {
-					Zotero.Prefs.set("lastWebDAVOrphanPurge", Math.round(new Date().getTime() / 1000))
-					if (callback) {
-						callback(results);
-					}
-					Zotero.Sync.Storage.EventManager.success();
-				});
-			}, { Depth: 1 });
-		}
+		}, { Depth: 1 });
 	};
+	
+	return obj;
 }());
diff --git a/chrome/content/zotero/xpcom/storage/zfs.js b/chrome/content/zotero/xpcom/storage/zfs.js
index e7c479535..64a3aeedf 100644
--- a/chrome/content/zotero/xpcom/storage/zfs.js
+++ b/chrome/content/zotero/xpcom/storage/zfs.js
@@ -24,7 +24,7 @@
 */
 
 
-Zotero.Sync.Storage.Module.ZFS = (function () {
+Zotero.Sync.Storage.ZFS = (function () {
 	var _rootURI;
 	var _userURI;
 	var _cachedCredentials = false;
@@ -40,7 +40,7 @@ Zotero.Sync.Storage.Module.ZFS = (function () {
 		var uri = getItemInfoURI(item);
 		
 		Zotero.HTTP.doGet(uri, function (req) {
-			var funcName = "Zotero.Sync.Storage.Module.ZFS.getStorageFileInfo()";
+			var funcName = "Zotero.Sync.Storage.ZFS.getStorageFileInfo()";
 			
 			if (req.status == 404) {
 				callback(item, false);
@@ -237,7 +237,7 @@ Zotero.Sync.Storage.Module.ZFS = (function () {
 		}
 		
 		Zotero.HTTP.doPost(uri, body, function (req) {
-			var funcName = "Zotero.Sync.Storage.Module.ZFS.getFileUploadParameters()";
+			var funcName = "Zotero.Sync.Storage.ZFS.getFileUploadParameters()";
 			
 			if (req.status == 413) {
 				var retry = req.getResponseHeader('Retry-After');
@@ -597,7 +597,7 @@ Zotero.Sync.Storage.Module.ZFS = (function () {
 	 * @return	{nsIURI}					URI of file on storage server
 	 */
 	function getItemURI(item) {
-		var uri = Zotero.Sync.Storage.Module.ZFS.rootURI;
+		var uri = Zotero.Sync.Storage.ZFS.rootURI;
 		// Be sure to mirror parameter changes to getItemInfoURI() below
 		uri.spec += Zotero.URI.getItemPath(item) + '/file?auth=1&iskey=1&version=1';
 		return uri;
@@ -612,7 +612,7 @@ Zotero.Sync.Storage.Module.ZFS = (function () {
 	 * @return	{nsIURI}					URI of file on storage server with info flag
 	 */
 	function getItemInfoURI(item) {
-		var uri = Zotero.Sync.Storage.Module.ZFS.rootURI;
+		var uri = Zotero.Sync.Storage.ZFS.rootURI;
 		uri.spec += Zotero.URI.getItemPath(item) + '/file?auth=1&iskey=1&version=1&info=1';
 		return uri;
 	}
@@ -631,435 +631,445 @@ Zotero.Sync.Storage.Module.ZFS = (function () {
 	}
 	
 	
-	return {
-		name: "ZFS",
-		
-		get includeUserFiles() {
+	//
+	// Public methods (called via Zotero.Sync.Storage.ZFS)
+	//
+	var obj = new Zotero.Sync.Storage.Mode;
+	obj.name = "ZFS";
+	
+	Object.defineProperty(obj, "includeUserFiles", {
+		get: function () {
 			return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'zotero';
-		},
-		
-		get includeGroupFiles() {
+		}
+	});
+	
+	Object.defineProperty(obj, "includeGroupFiles", {
+		get: function () {
 			return Zotero.Prefs.get("sync.storage.groups.enabled");
-		},
-		
-		get enabled() {
-			return this.includeUserFiles || this.includeGroupFiles;
-		},
-		
-		get verified() {
-			return true;
-		},
-		
-		get rootURI() {
+		}
+	});
+	
+	Object.defineProperty(obj, "_enabled", {
+		get: function () this.includeUserFiles || this.includeGroupFiles
+	});
+	
+	obj._verified = true;
+	
+	Object.defineProperty(obj, "rootURI", {
+		get: function () {
 			if (!_rootURI) {
 				throw ("Root URI not initialized in Zotero.Sync.Storage.ZFS.rootURI");
 			}
 			return _rootURI.clone();
-		},
-		
-		get userURI() {
+		}
+	});
+	
+	Object.defineProperty(obj, "userURI", {
+		get: function () {
 			if (!_userURI) {
 				throw ("User URI not initialized in Zotero.Sync.Storage.ZFS.userURI");
 			}
 			return _userURI.clone();
-		},
+		}
+	});
+	
+	
+	obj._init = function (url, username, password) {
+		var ios = Components.classes["@mozilla.org/network/io-service;1"].
+					getService(Components.interfaces.nsIIOService);
+		try {
+			var uri = ios.newURI(url, null, null);
+			if (username) {
+				uri.username = username;
+				uri.password = password;
+			}
+		}
+		catch (e) {
+			Zotero.debug(e, 1);
+			Components.utils.reportError(e);
+			return false;
+		}
+		_rootURI = uri;
 		
+		uri = uri.clone();
+		uri.spec += 'users/' + Zotero.userID + '/';
+		_userURI = uri;
 		
-		init: function (url, username, password) {
-			var ios = Components.classes["@mozilla.org/network/io-service;1"].
-						getService(Components.interfaces.nsIIOService);
+		return true;
+	};
+	
+	
+	obj._initFromPrefs = function () {
+		var url = ZOTERO_CONFIG.API_URL;
+		var username = Zotero.Sync.Server.username;
+		var password = Zotero.Sync.Server.password;
+		return this._init(url, username, password);
+	};
+	
+	
+	/**
+	 * Begin download process for individual file
+	 *
+	 * @param	{Zotero.Sync.Storage.Request}	[request]
+	 */
+	obj._downloadFile = function (request) {
+		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
+		if (!item) {
+			throw new Error("Item '" + request.name + "' not found");
+		}
+		
+		// Retrieve file info from server to store locally afterwards
+		getStorageFileInfo(item, function (item, info) {
+			if (!request.isRunning()) {
+				Zotero.debug("Download request '" + request.name
+					+ "' is no longer running after getting remote file info");
+				return;
+			}
+			
+			if (!info) {
+				Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key);
+				request.finish();
+				return;
+			}
+			
 			try {
-				var uri = ios.newURI(url, null, null);
-				if (username) {
-					uri.username = username;
-					uri.password = password;
-				}
-			}
-			catch (e) {
-				Zotero.debug(e, 1);
-				Components.utils.reportError(e);
-				return false;
-			}
-			_rootURI = uri;
-			
-			uri = uri.clone();
-			uri.spec += 'users/' + Zotero.userID + '/';
-			_userURI = uri;
-			
-			return true;
-		},
-		
-		
-		initFromPrefs: function () {
-			var url = ZOTERO_CONFIG.API_URL;
-			var username = Zotero.Sync.Server.username;
-			var password = Zotero.Sync.Server.password;
-			return this.init(url, username, password);
-		},
-		
-		
-		/**
-		 * Begin download process for individual file
-		 *
-		 * @param	{Zotero.Sync.Storage.Request}	[request]
-		 */
-		downloadFile: function (request) {
-			var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
-			if (!item) {
-				throw new Error("Item '" + request.name + "' not found");
-			}
-			
-			// Retrieve file info from server to store locally afterwards
-			getStorageFileInfo(item, function (item, info) {
-				if (!request.isRunning()) {
-					Zotero.debug("Download request '" + request.name
-						+ "' is no longer running after getting remote file info");
-					return;
+				var syncModTime = info.mtime;
+				var syncHash = info.hash;
+				
+				var file = item.getFile();
+				// Skip download if local file exists and matches mod time
+				if (file && file.exists()) {
+					if (syncModTime == file.lastModifiedTime) {
+						Zotero.debug("File mod time matches remote file -- skipping download");
+						
+						Zotero.DB.beginTransaction();
+						var syncState = Zotero.Sync.Storage.getSyncState(item.id);
+						//var updateItem = syncState != 1;
+						var updateItem = false;
+						Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem);
+						Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
+						Zotero.DB.commitTransaction();
+						Zotero.Sync.Storage.EventManager.changesMade();
+						request.finish();
+						return;
+					}
+					// If not compressed, check hash, in case only timestamp changed
+					else if (!info.compressed && item.attachmentHash == syncHash) {
+						Zotero.debug("File hash matches remote file -- skipping download");
+						
+						Zotero.DB.beginTransaction();
+						var syncState = Zotero.Sync.Storage.getSyncState(item.id);
+						//var updateItem = syncState != 1;
+						var updateItem = false;
+						if (!info.compressed) {
+							Zotero.Sync.Storage.setSyncedHash(item.id, syncHash, false);
+						}
+						Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem);
+						Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
+						Zotero.DB.commitTransaction();
+						Zotero.Sync.Storage.EventManager.changesMade();
+						request.finish();
+						return;
+					}
 				}
 				
-				if (!info) {
-					Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key);
-					request.finish();
-					return;
+				var destFile = Zotero.getTempDirectory();
+				if (info.compressed) {
+					destFile.append(item.key + '.zip.tmp');
+				}
+				else {
+					destFile.append(item.key + '.tmp');
 				}
 				
-				try {
-					var syncModTime = info.mtime;
-					var syncHash = info.hash;
-					
-					var file = item.getFile();
-					// Skip download if local file exists and matches mod time
-					if (file && file.exists()) {
-						if (syncModTime == file.lastModifiedTime) {
-							Zotero.debug("File mod time matches remote file -- skipping download");
-							
-							Zotero.DB.beginTransaction();
-							var syncState = Zotero.Sync.Storage.getSyncState(item.id);
-							//var updateItem = syncState != 1;
-							var updateItem = false;
-							Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem);
-							Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
-							Zotero.DB.commitTransaction();
-							Zotero.Sync.Storage.EventManager.changesMade();
-							request.finish();
-							return;
-						}
-						// If not compressed, check hash, in case only timestamp changed
-						else if (!info.compressed && item.attachmentHash == syncHash) {
-							Zotero.debug("File hash matches remote file -- skipping download");
-							
-							Zotero.DB.beginTransaction();
-							var syncState = Zotero.Sync.Storage.getSyncState(item.id);
-							//var updateItem = syncState != 1;
-							var updateItem = false;
-							if (!info.compressed) {
-								Zotero.Sync.Storage.setSyncedHash(item.id, syncHash, false);
-							}
-							Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem);
-							Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
-							Zotero.DB.commitTransaction();
-							Zotero.Sync.Storage.EventManager.changesMade();
-							request.finish();
-							return;
-						}
-					}
-					
-					var destFile = Zotero.getTempDirectory();
-					if (info.compressed) {
-						destFile.append(item.key + '.zip.tmp');
-					}
-					else {
-						destFile.append(item.key + '.tmp');
-					}
-					
-					if (destFile.exists()) {
-						try {
-							destFile.remove(false);
-						}
-						catch (e) {
-							Zotero.File.checkFileAccessError(e, destFile, 'delete');
-						}
-					}
-					
-					// saveURI() below appears not to create empty files for Content-Length: 0,
-					// so we create one here just in case
+				if (destFile.exists()) {
 					try {
-						destFile.create(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644);
+						destFile.remove(false);
 					}
 					catch (e) {
-						Zotero.File.checkFileAccessError(e, destFile, 'create');
+						Zotero.File.checkFileAccessError(e, destFile, 'delete');
 					}
-					
-					var listener = new Zotero.Sync.Storage.StreamListener(
-						{
-							onStart: function (request, data) {
-								if (data.request.isFinished()) {
-									Zotero.debug("Download request " + data.request.name
-										+ " stopped before download started -- closing channel");
-									request.cancel(0x804b0002); // NS_BINDING_ABORTED
-									return;
-								}
-							},
-							onProgress: function (a, b, c) {
-								request.onProgress(a, b, c)
-							},
-							onStop: function (request, status, response, data) {
-								if (status != 200) {
-									var msg = "Unexpected status code " + status
-										+ " for request " + data.request.name
-										+ " in Zotero.Sync.Storage.Module.ZFS.downloadFile()";
-									Zotero.debug(msg, 1);
-									Components.utils.reportError(msg);
-									Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError);
-								}
-								
-								// Don't try to process if the request has been cancelled
-								if (data.request.isFinished()) {
-									Zotero.debug("Download request " + data.request.name
-										+ " is no longer running after file download", 2);
-									return;
-								}
-								
-								Zotero.debug("Finished download of " + destFile.path);
-								
-								try {
-									Zotero.Sync.Storage.processDownload(data);
-									data.request.finish();
-								}
-								catch (e) {
-									Zotero.Sync.Storage.EventManager.error(e);
-								}
-							},
-							request: request,
-							item: item,
-							compressed: info.compressed,
-							syncModTime: syncModTime,
-							syncHash: syncHash
-						}
-					);
-					
-					var uri = getItemURI(item);
-					
-					// Don't display password in console
-					var disp = uri.clone();
-					if (disp.password) {
-						disp.password = "********";
-					}
-					Zotero.debug('Saving ' + disp.spec + ' with saveURI()');
-					const nsIWBP = Components.interfaces.nsIWebBrowserPersist;
-					var wbp = Components
-						.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
-						.createInstance(nsIWBP);
-					wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE;
-					wbp.progressListener = listener;
-					wbp.saveURI(uri, null, null, null, null, destFile);
+				}
+				
+				// saveURI() below appears not to create empty files for Content-Length: 0,
+				// so we create one here just in case
+				try {
+					destFile.create(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644);
 				}
 				catch (e) {
-					Zotero.Sync.Storage.EventManager.error(e);
-				}
-			});
-		},
-		
-		
-		uploadFile: function (request) {
-			var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
-			if (Zotero.Attachments.getNumFiles(item) > 1) {
-				Zotero.Sync.Storage.createUploadFile(request, function (data) { processUploadFile(data); });
-			}
-			else {
-				processUploadFile({ request: request });
-			}
-		},
-		
-		
-		getLastSyncTime: function (callback) {
-			var uri = this.userURI;
-			var successFileURI = uri.clone();
-			successFileURI.spec += "laststoragesync?auth=1";
-			
-			// Cache the credentials at the root
-			var self = this;
-			this.cacheCredentials(function () {
-				Zotero.HTTP.doGet(successFileURI, function (req) {
-					if (req.responseText) {
-						Zotero.debug(req.responseText);
-					}
-					Zotero.debug(req.status);
-					
-					if (req.status == 401 || req.status == 403) {
-						Zotero.debug("Clearing ZFS authentication credentials", 2);
-						_cachedCredentials = false;
-					}
-					
-					if (req.status != 200 && req.status != 404) {
-						Zotero.Sync.Storage.EventManager.error(
-							"Unexpected status code " + req.status + " getting "
-								+ "last file sync time"
-						);
-					}
-					
-					if (req.status == 200) {
-						var ts = req.responseText;
-						var date = new Date(ts * 1000);
-						Zotero.debug("Last successful storage sync was " + date);
-						_lastSyncTime = ts;
-					}
-					else {
-						var ts = null;
-						_lastSyncTime = null;
-					}
-					callback(ts);
-				});
-			});
-		},
-		
-		
-		setLastSyncTime: function (callback, useLastSyncTime) {
-			if (useLastSyncTime) {
-				if (!_lastSyncTime) {
-					if (callback) {
-						callback();
-					}
-					return;
+					Zotero.File.checkFileAccessError(e, destFile, 'create');
 				}
 				
-				var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)";
-				Zotero.DB.query(sql, { int: _lastSyncTime });
+				var listener = new Zotero.Sync.Storage.StreamListener(
+					{
+						onStart: function (request, data) {
+							if (data.request.isFinished()) {
+								Zotero.debug("Download request " + data.request.name
+									+ " stopped before download started -- closing channel");
+								request.cancel(0x804b0002); // NS_BINDING_ABORTED
+								return;
+							}
+						},
+						onProgress: function (a, b, c) {
+							request.onProgress(a, b, c)
+						},
+						onStop: function (request, status, response, data) {
+							if (status != 200) {
+								var msg = "Unexpected status code " + status
+									+ " for request " + data.request.name
+									+ " in Zotero.Sync.Storage.ZFS.downloadFile()";
+								Zotero.debug(msg, 1);
+								Components.utils.reportError(msg);
+								Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError);
+							}
+							
+							// Don't try to process if the request has been cancelled
+							if (data.request.isFinished()) {
+								Zotero.debug("Download request " + data.request.name
+									+ " is no longer running after file download", 2);
+								return;
+							}
+							
+							Zotero.debug("Finished download of " + destFile.path);
+							
+							try {
+								Zotero.Sync.Storage.processDownload(data);
+								data.request.finish();
+							}
+							catch (e) {
+								Zotero.Sync.Storage.EventManager.error(e);
+							}
+						},
+						request: request,
+						item: item,
+						compressed: info.compressed,
+						syncModTime: syncModTime,
+						syncHash: syncHash
+					}
+				);
 				
-				Zotero.debug("Clearing ZFS authentication credentials", 2);
-				_lastSyncTime = null;
-				_cachedCredentials = false;
+				var uri = getItemURI(item);
 				
-				if (callback) {
-					callback();
+				// Don't display password in console
+				var disp = uri.clone();
+				if (disp.password) {
+					disp.password = "********";
 				}
-				
-				return;
+				Zotero.debug('Saving ' + disp.spec + ' with saveURI()');
+				const nsIWBP = Components.interfaces.nsIWebBrowserPersist;
+				var wbp = Components
+					.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
+					.createInstance(nsIWBP);
+				wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE;
+				wbp.progressListener = listener;
+				wbp.saveURI(uri, null, null, null, null, destFile);
 			}
-			_lastSyncTime = null;
-			
-			var uri = this.userURI;
-			var successFileURI = uri.clone();
-			successFileURI.spec += "laststoragesync?auth=1";
-			
-			Zotero.HTTP.doPost(successFileURI, "", function (req) {
-				Zotero.debug(req.responseText);
+			catch (e) {
+				Zotero.Sync.Storage.EventManager.error(e);
+			}
+		});
+	};
+	
+	
+	obj._uploadFile = function (request) {
+		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
+		if (Zotero.Attachments.getNumFiles(item) > 1) {
+			Zotero.Sync.Storage.createUploadFile(request, function (data) { processUploadFile(data); });
+		}
+		else {
+			processUploadFile({ request: request });
+		}
+	};
+	
+	
+	obj._getLastSyncTime = function (callback) {
+		var uri = this.userURI;
+		var successFileURI = uri.clone();
+		successFileURI.spec += "laststoragesync?auth=1";
+		
+		// Cache the credentials at the root
+		var self = this;
+		this._cacheCredentials(function () {
+			Zotero.HTTP.doGet(successFileURI, function (req) {
+				if (req.responseText) {
+					Zotero.debug(req.responseText);
+				}
 				Zotero.debug(req.status);
 				
-				if (req.status != 200) {
-					var msg = "Unexpected status code " + req.status + " setting last file sync time";
-					Zotero.debug(msg, 1);
-					Components.utils.reportError(msg);
-					Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError);
+				if (req.status == 401 || req.status == 403) {
+					Zotero.debug("Clearing ZFS authentication credentials", 2);
+					_cachedCredentials = false;
 				}
 				
-				var ts = req.responseText;
-				
-				var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)";
-				Zotero.DB.query(sql, { int: ts });
-				
-				Zotero.debug("Clearing ZFS authentication credentials", 2);
-				_cachedCredentials = false;
-				
-				if (callback) {
-					callback();
-				}
-			});
-		},
-		
-		
-		cacheCredentials: function (callback) {
-			if (_cachedCredentials) {
-				Zotero.debug("Credentials are already cached");
-				setTimeout(function () {
-					callback();
-				}, 0);
-				return false;
-			}
-			
-			var uri = this.rootURI;
-			// TODO: move to root uri
-			uri.spec += "?auth=1";
-			Zotero.HTTP.doGet(uri, function (req) {
-				if (req.status == 401) {
-					// TODO: localize
-					var msg = "File sync login failed\n\nCheck your username and password in the Sync pane of the Zotero preferences.";
-					Zotero.Sync.Storage.EventManager.error(msg);
-				}
-				else if (req.status != 200) {
-					var msg = "Unexpected status code " + req.status + " caching "
-						+ "authentication credentials in Zotero.Sync.Storage.Module.ZFS.cacheCredentials()";
-					Zotero.debug(msg, 1);
-					Components.utils.reportError(msg);
-					Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultErrorRestart);
-				}
-				Zotero.debug("Credentials are cached");
-				_cachedCredentials = true;
-				callback();
-			});
-			return true;
-		},
-		
-		
-		/**
-		 * Remove all synced files from the server
-		 */
-		purgeDeletedStorageFiles: function (callback) {
-			// If we don't have a user id we've never synced and don't need to bother
-			if (!Zotero.userID) {
-				Zotero.Sync.Storage.EventManager.skip();
-				return;
-			}
-			
-			var sql = "SELECT value FROM settings WHERE setting=? AND key=?";
-			var values = Zotero.DB.columnQuery(sql, ['storage', 'zfsPurge']);
-			if (!values) {
-				Zotero.Sync.Storage.EventManager.skip();
-				return;
-			}
-			
-			Zotero.debug("Unlinking synced files on ZFS");
-			
-			var uri = this.userURI;
-			uri.spec += "removestoragefiles?";
-			// Unused
-			for each(var value in values) {
-				switch (value) {
-					case 'user':
-						uri.spec += "user=1&";
-						break;
-					
-					case 'group':
-						uri.spec += "group=1&";
-						break;
-					
-					default:
-						Zotero.Sync.Storage.EventManager.error(
-							"Invalid zfsPurge value '" + value + "' in ZFS purgeDeletedStorageFiles()"
-						);
-				}
-			}
-			uri.spec = uri.spec.substr(0, uri.spec.length - 1);
-			
-			Zotero.HTTP.doPost(uri, "", function (xmlhttp) {
-				if (xmlhttp.status != 204) {
-					if (callback) {
-						callback(false);
-					}
+				if (req.status != 200 && req.status != 404) {
 					Zotero.Sync.Storage.EventManager.error(
-						"Unexpected status code " + xmlhttp.status + " purging ZFS files"
+						"Unexpected status code " + req.status + " getting "
+							+ "last file sync time"
 					);
 				}
 				
-				var sql = "DELETE FROM settings WHERE setting=? AND key=?";
-				Zotero.DB.query(sql, ['storage', 'zfsPurge']);
-				
-				if (callback) {
-					callback(true);
+				if (req.status == 200) {
+					var ts = req.responseText;
+					var date = new Date(ts * 1000);
+					Zotero.debug("Last successful storage sync was " + date);
+					_lastSyncTime = ts;
 				}
-				
-				Zotero.Sync.Storage.EventManager.success();
+				else {
+					var ts = null;
+					_lastSyncTime = null;
+				}
+				callback(ts);
 			});
+		});
+	};
+	
+	
+	obj._setLastSyncTime = function (callback, useLastSyncTime) {
+		if (useLastSyncTime) {
+			if (!_lastSyncTime) {
+				if (callback) {
+					callback();
+				}
+				return;
+			}
+			
+			var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)";
+			Zotero.DB.query(sql, { int: _lastSyncTime });
+			
+			Zotero.debug("Clearing ZFS authentication credentials", 2);
+			_lastSyncTime = null;
+			_cachedCredentials = false;
+			
+			if (callback) {
+				callback();
+			}
+			
+			return;
 		}
-	}
+		_lastSyncTime = null;
+		
+		var uri = this.userURI;
+		var successFileURI = uri.clone();
+		successFileURI.spec += "laststoragesync?auth=1";
+		
+		Zotero.HTTP.doPost(successFileURI, "", function (req) {
+			Zotero.debug(req.responseText);
+			Zotero.debug(req.status);
+			
+			if (req.status != 200) {
+				var msg = "Unexpected status code " + req.status + " setting last file sync time";
+				Zotero.debug(msg, 1);
+				Components.utils.reportError(msg);
+				Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError);
+			}
+			
+			var ts = req.responseText;
+			
+			var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)";
+			Zotero.DB.query(sql, { int: ts });
+			
+			Zotero.debug("Clearing ZFS authentication credentials", 2);
+			_cachedCredentials = false;
+			
+			if (callback) {
+				callback();
+			}
+		});
+	};
+	
+	
+	obj._cacheCredentials = function (callback) {
+		if (_cachedCredentials) {
+			Zotero.debug("Credentials are already cached");
+			setTimeout(function () {
+				callback();
+			}, 0);
+			return false;
+		}
+		
+		var uri = this.rootURI;
+		// TODO: move to root uri
+		uri.spec += "?auth=1";
+		Zotero.HTTP.doGet(uri, function (req) {
+			if (req.status == 401) {
+				// TODO: localize
+				var msg = "File sync login failed\n\nCheck your username and password in the Sync pane of the Zotero preferences.";
+				Zotero.Sync.Storage.EventManager.error(msg);
+			}
+			else if (req.status != 200) {
+				var msg = "Unexpected status code " + req.status + " caching "
+					+ "authentication credentials in Zotero.Sync.Storage.ZFS.cacheCredentials()";
+				Zotero.debug(msg, 1);
+				Components.utils.reportError(msg);
+				Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultErrorRestart);
+			}
+			Zotero.debug("Credentials are cached");
+			_cachedCredentials = true;
+			callback();
+		});
+		return true;
+	};
+	
+	
+	/**
+	 * Remove all synced files from the server
+	 */
+	obj._purgeDeletedStorageFiles = function (callback) {
+		// If we don't have a user id we've never synced and don't need to bother
+		if (!Zotero.userID) {
+			Zotero.Sync.Storage.EventManager.skip();
+			return;
+		}
+		
+		var sql = "SELECT value FROM settings WHERE setting=? AND key=?";
+		var values = Zotero.DB.columnQuery(sql, ['storage', 'zfsPurge']);
+		if (!values) {
+			Zotero.Sync.Storage.EventManager.skip();
+			return;
+		}
+		
+		Zotero.debug("Unlinking synced files on ZFS");
+		
+		var uri = this.userURI;
+		uri.spec += "removestoragefiles?";
+		// Unused
+		for each(var value in values) {
+			switch (value) {
+				case 'user':
+					uri.spec += "user=1&";
+					break;
+				
+				case 'group':
+					uri.spec += "group=1&";
+					break;
+				
+				default:
+					Zotero.Sync.Storage.EventManager.error(
+						"Invalid zfsPurge value '" + value + "' in ZFS purgeDeletedStorageFiles()"
+					);
+			}
+		}
+		uri.spec = uri.spec.substr(0, uri.spec.length - 1);
+		
+		Zotero.HTTP.doPost(uri, "", function (xmlhttp) {
+			if (xmlhttp.status != 204) {
+				if (callback) {
+					callback(false);
+				}
+				Zotero.Sync.Storage.EventManager.error(
+					"Unexpected status code " + xmlhttp.status + " purging ZFS files"
+				);
+			}
+			
+			var sql = "DELETE FROM settings WHERE setting=? AND key=?";
+			Zotero.DB.query(sql, ['storage', 'zfsPurge']);
+			
+			if (callback) {
+				callback(true);
+			}
+			
+			Zotero.Sync.Storage.EventManager.success();
+		});
+	};
+	
+	return obj;
 }());
diff --git a/chrome/content/zotero/xpcom/sync.js b/chrome/content/zotero/xpcom/sync.js
index a9a510af0..62babac68 100644
--- a/chrome/content/zotero/xpcom/sync.js
+++ b/chrome/content/zotero/xpcom/sync.js
@@ -421,7 +421,7 @@ Zotero.Sync.EventListener = new function () {
 			var sql = "REPLACE INTO syncDeleteLog VALUES (?, ?, ?, ?)";
 			var syncStatement = Zotero.DB.getStatement(sql);
 			
-			if (isItem && Zotero.Sync.Storage.isActive('WebDAV')) {
+			if (isItem && Zotero.Sync.Storage.WebDAV.active) {
 				var storageEnabled = true;
 				var sql = "INSERT INTO storageDeleteLog VALUES (?, ?, ?)";
 				var storageStatement = Zotero.DB.getStatement(sql);
@@ -559,7 +559,7 @@ Zotero.Sync.Runner = new function () {
 			Zotero.Sync.Runner.setSyncStatus(Zotero.getString('sync.status.syncingFiles'));
 			
 			var zfsSync = function (skipSyncNeeded) {
-				Zotero.Sync.Storage.sync('ZFS', {
+				Zotero.Sync.Storage.ZFS.sync({
 					// ZFS success
 					onSuccess: function () {
 						setTimeout(function () {
@@ -593,7 +593,7 @@ Zotero.Sync.Runner = new function () {
 				})
 			};
 			
-			Zotero.Sync.Storage.sync('WebDAV', {
+			Zotero.Sync.Storage.WebDAV.sync({
 				// WebDAV success
 				onSuccess: function () {
 					zfsSync(true);
diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js
index 201dac23f..be8db2ec8 100644
--- a/chrome/content/zotero/xpcom/zotero.js
+++ b/chrome/content/zotero/xpcom/zotero.js
@@ -1801,12 +1801,12 @@ const ZOTERO_CONFIG = {
 		Zotero.Relations.purge();
 		
 		if (!skipStoragePurge && Math.random() < 1/10) {
-			Zotero.Sync.Storage.purgeDeletedStorageFiles('ZFS');
-			Zotero.Sync.Storage.purgeDeletedStorageFiles('WebDAV');
+			Zotero.Sync.Storage.ZFS.purgeDeletedStorageFiles();
+			Zotero.Sync.Storage.WebDAV.purgeDeletedStorageFiles();
 		}
 		
 		if (!skipStoragePurge) {
-			Zotero.Sync.Storage.purgeOrphanedStorageFiles('WebDAV');
+			Zotero.Sync.Storage.WebDAV.purgeOrphanedStorageFiles();
 		}
 	}
 	
diff --git a/components/zotero-service.js b/components/zotero-service.js
index fa0dde08c..14cae9129 100644
--- a/components/zotero-service.js
+++ b/components/zotero-service.js
@@ -100,7 +100,7 @@ const xpcomFilesLocal = [
 	'storage/queueManager',
 	'storage/queue',
 	'storage/request',
-	'storage/module',
+	'storage/mode',
 	'storage/zfs',
 	'storage/webdav',
 	'timeline',