diff --git a/chrome/content/zotero/bindings/attachmentbox.xml b/chrome/content/zotero/bindings/attachmentbox.xml
index 6d98e7a5f..fd53b1a06 100644
--- a/chrome/content/zotero/bindings/attachmentbox.xml
+++ b/chrome/content/zotero/bindings/attachmentbox.xml
@@ -57,6 +57,7 @@
 					Zotero.debug("Setting mode to '" + val + "'");
 					
 					this.editable = false;
+					this.synchronous = false;
 					this.displayURL = false;
 					this.displayFileName = false;
 					this.clickableLink = false;
@@ -93,6 +94,7 @@
 							break;
 						
 						case 'merge':
+							this.synchronous = true;
 							this.displayURL = true;
 							this.displayFileName = true;
 							this.displayAccessed = true;
@@ -102,6 +104,7 @@
 							break;
 						
 						case 'mergeedit':
+							this.synchronous = true;
 							this.editable = true;
 							this.displayURL = true;
 							this.displayFileName = true;
@@ -112,6 +115,13 @@
 							this.displayDateModified = true;
 							break;
 						
+						case 'filemerge':
+							this.synchronous = true;
+							this.displayURL = true;
+							this.displayFileName = true;
+							this.displayDateModified = true;
+							break;
+						
 						default:
 							throw ("Invalid mode '" + val + "' in attachmentbox.xml");
 					}
@@ -123,18 +133,16 @@
 			</property>
 			
 			<field name="_item"/>
-			<property name="item"
-				onget="return this._item;"
-				onset="this._item = val; this.refresh();">
+			<property name="item" onget="return this._item;">
+				<setter><![CDATA[
+					if (!(val instanceof Zotero.Item)) {
+						throw new Error("'item' must be a Zotero.Item");
+					}
+					this._item = val;
+					this.refresh();
+				]]></setter>
 			</property>
 			
-			<!-- .ref is an alias for .item -->
-			<property name="ref"
-				onget="return this._item;"
-				onset="this._item = val; this.refresh();">
-			</property>
-			
-			
 			<!-- Methods -->
 			
 			<constructor>
@@ -167,125 +175,122 @@
 			
 			<method name="refresh">
 				<body><![CDATA[
-					Zotero.spawn(function* () {
-						Zotero.debug('Refreshing attachment box');
+					Zotero.debug('Refreshing attachment box');
+					
+					var attachmentBox = document.getAnonymousNodes(this)[0];
+					var title = this._id('title');
+					var fileNameRow = this._id('fileNameRow');
+					var urlField = this._id('url');
+					var accessed = this._id('accessedRow');
+					var pagesRow = this._id('pagesRow');
+					var dateModifiedRow = this._id('dateModifiedRow');
+					var indexStatusRow = this._id('indexStatusRow');
+					var selectButton = this._id('select-button');
+					
+					// DEBUG: this is annoying -- we really want to use an abstracted
+					// version of createValueElement() from itemPane.js
+					// (ideally in an XBL binding)
+					
+					// Wrap title to multiple lines if necessary
+					while (title.hasChildNodes()) {
+						title.removeChild(title.firstChild);
+					}
+					var val = this.item.getField('title');
+					
+					if (typeof val != 'string') {
+						val += "";
+					}
+					
+					var firstSpace = val.indexOf(" ");
+					// Crop long uninterrupted text
+					if ((firstSpace == -1 && val.length > 29 ) || firstSpace > 29) {
+						title.setAttribute('crop', 'end');
+						title.setAttribute('value', val);
+					}
+					// Create a <description> element, essentially
+					else {
+						title.removeAttribute('value');
+						title.appendChild(document.createTextNode(val));
+					}
+					
+					if (this.editable) {
+						title.className = 'zotero-clicky';
 						
-						yield Zotero.Promise.all([this.item.loadItemData(), this.item.loadNote()])
-							.tap(() => Zotero.Promise.check(this.item));
+						// For the time being, use a silly little popup
+						title.addEventListener('click', this.editTitle, false);
+					}
+					
+					var isImportedURL = this.item.attachmentLinkMode ==
+											Zotero.Attachments.LINK_MODE_IMPORTED_URL;
+					
+					// Metadata for URL's
+					if (this.item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL
+							|| isImportedURL) {
 						
-						var attachmentBox = document.getAnonymousNodes(this)[0];
-						var title = this._id('title');
-						var fileNameRow = this._id('fileNameRow');
-						var urlField = this._id('url');
-						var accessed = this._id('accessedRow');
-						var pagesRow = this._id('pagesRow');
-						var dateModifiedRow = this._id('dateModifiedRow');
-						var indexStatusRow = this._id('indexStatusRow');
-						var selectButton = this._id('select-button');
-						
-						// DEBUG: this is annoying -- we really want to use an abstracted
-						// version of createValueElement() from itemPane.js
-						// (ideally in an XBL binding)
-						
-						// Wrap title to multiple lines if necessary
-						while (title.hasChildNodes()) {
-							title.removeChild(title.firstChild);
-						}
-						var val = this.item.getField('title');
-						
-						if (typeof val != 'string') {
-							val += "";
-						}
-						
-						var firstSpace = val.indexOf(" ");
-						// Crop long uninterrupted text
-						if ((firstSpace == -1 && val.length > 29 ) || firstSpace > 29) {
-							title.setAttribute('crop', 'end');
-							title.setAttribute('value', val);
-						}
-						// Create a <description> element, essentially
-						else {
-							title.removeAttribute('value');
-							title.appendChild(document.createTextNode(val));
-						}
-						
-						if (this.editable) {
-							title.className = 'zotero-clicky';
-							
-							// For the time being, use a silly little popup
-							title.addEventListener('click', this.editTitle, false);
-						}
-						
-						var isImportedURL = this.item.attachmentLinkMode ==
-												Zotero.Attachments.LINK_MODE_IMPORTED_URL;
-						
-						// Metadata for URL's
-						if (this.item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL
-								|| isImportedURL) {
-							
-							// URL
-							if (this.displayURL) {
-								var urlSpec = this.item.getField('url');
-								urlField.setAttribute('value', urlSpec);
-								urlField.setAttribute('hidden', false);
-								if (this.clickableLink) {
-									 urlField.onclick = function (event) {
-										ZoteroPane_Local.loadURI(this.value, event)
-									};
-									urlField.className = 'zotero-text-link';
-								}
-								else {
-									urlField.className = '';
-								}
-								urlField.hidden = false;
+						// URL
+						if (this.displayURL) {
+							var urlSpec = this.item.getField('url');
+							urlField.setAttribute('value', urlSpec);
+							urlField.setAttribute('hidden', false);
+							if (this.clickableLink) {
+								 urlField.onclick = function (event) {
+									ZoteroPane_Local.loadURI(this.value, event)
+								};
+								urlField.className = 'zotero-text-link';
 							}
 							else {
-								urlField.hidden = true;
-							}
-							
-							// Access date
-							if (this.displayAccessed) {
-								this._id("accessed-label").value = Zotero.getString('itemFields.accessDate')
-									+ Zotero.getString('punctuation.colon');
-								this._id("accessed").value = Zotero.Date.sqlToDate(
-										this.item.getField('accessDate'), true
-									).toLocaleString();
-								accessed.hidden = false;
-							}
-							else {
-								accessed.hidden = true;
+								urlField.className = '';
 							}
+							urlField.hidden = false;
 						}
-						// Metadata for files
 						else {
 							urlField.hidden = true;
-							accessed.hidden = true;
 						}
 						
-						if (this.item.attachmentLinkMode
-									!= Zotero.Attachments.LINK_MODE_LINKED_URL
-								&& this.displayFileName) {
-							var fileName = this.item.getFilename();
-							
-							if (fileName) {
-								this._id("fileName-label").value = Zotero.getString('pane.item.attachments.filename')
-									+ Zotero.getString('punctuation.colon');
-								this._id("fileName").value = fileName;
-								fileNameRow.hidden = false;
-							}
-							else {
-								fileNameRow.hidden = true;
-							}
+						// Access date
+						if (this.displayAccessed) {
+							this._id("accessed-label").value = Zotero.getString('itemFields.accessDate')
+								+ Zotero.getString('punctuation.colon');
+							this._id("accessed").value = Zotero.Date.sqlToDate(
+									this.item.getField('accessDate'), true
+								).toLocaleString();
+							accessed.hidden = false;
+						}
+						else {
+							accessed.hidden = true;
+						}
+					}
+					// Metadata for files
+					else {
+						urlField.hidden = true;
+						accessed.hidden = true;
+					}
+					
+					if (this.item.attachmentLinkMode
+								!= Zotero.Attachments.LINK_MODE_LINKED_URL
+							&& this.displayFileName) {
+						var fileName = this.item.attachmentFilename;
+						
+						if (fileName) {
+							this._id("fileName-label").value = Zotero.getString('pane.item.attachments.filename')
+								+ Zotero.getString('punctuation.colon');
+							this._id("fileName").value = fileName;
+							fileNameRow.hidden = false;
 						}
 						else {
 							fileNameRow.hidden = true;
 						}
-						
-						// Page count
-						if (this.displayPages) {
-							var pages = yield Zotero.Fulltext.getPages(this.item.id)
-								.tap(() => Zotero.Promise.check(this.item));
-							var pages = pages ? pages.total : null;
+					}
+					else {
+						fileNameRow.hidden = true;
+					}
+					
+					// Page count
+					if (this.displayPages) {
+						Zotero.Fulltext.getPages(this.item.id)
+						.tap(() => Zotero.Promise.check(this.item))
+						.then(function (pages) {
+							pages = pages ? pages.total : null;
 							if (pages) {
 								this._id("pages-label").value = Zotero.getString('itemFields.pages')
 									+ Zotero.getString('punctuation.colon');
@@ -295,77 +300,85 @@
 							else {
 								pagesRow.hidden = true;
 							}
-						}
-						else {
-							pagesRow.hidden = true;
-						}
-						
-						if (this.displayDateModified) {
-							this._id("dateModified-label").value = Zotero.getString('itemFields.dateModified')
-								+ Zotero.getString('punctuation.colon');
-							var mtime = yield this.item.attachmentModificationTime
-								.tap(() => Zotero.Promise.check(this.item));
-							if (mtime) {
-								this._id("dateModified").value = new Date(mtime).toLocaleString();
-							}
-							// Use the item's mod time as a backup (e.g., when sync
-							// passes in the mod time for the nonexistent remote file)
-							else {
-								this._id("dateModified").value = Zotero.Date.sqlToDate(
-									this.item.getField('dateModified'), true
-								).toLocaleString();
-							}
+						});
+					}
+					else {
+						pagesRow.hidden = true;
+					}
+					
+					if (this.displayDateModified) {
+						this._id("dateModified-label").value = Zotero.getString('itemFields.dateModified')
+							+ Zotero.getString('punctuation.colon');
+						// Conflict resolution uses a modal window, so promises won't work, but
+						// the sync process passes in the file mod time as dateModified
+						if (this.synchronous) {
+							this._id("dateModified").value = Zotero.Date.sqlToDate(
+								this.item.getField('dateModified'), true
+							).toLocaleString();
 							dateModifiedRow.hidden = false;
 						}
 						else {
-							dateModifiedRow.hidden = true;
+							this.item.attachmentModificationTime
+							.tap(() => Zotero.Promise.check(this._id))
+							.then(function (mtime) {
+								if (!this._id) return;
+								if (mtime) {
+									this._id("dateModified").value = new Date(mtime).toLocaleString();
+								}
+								dateModifiedRow.hidden = false;
+							});
 						}
-						
-						// Full-text index information
-						if (this.displayIndexed) {
-							yield this.updateItemIndexedState()
-								.tap(() => Zotero.Promise.check(this.item));
+					}
+					else {
+						dateModifiedRow.hidden = true;
+					}
+					
+					// Full-text index information
+					if (this.displayIndexed) {
+						this.updateItemIndexedState()
+						.tap(() => Zotero.Promise.check(this.item))
+						.then(function () {
 							indexStatusRow.hidden = false;
-						}
-						else {
-							indexStatusRow.hidden = true;
-						}
-						
-						// Note editor
-						var noteEditor = this._id('attachment-note-editor');
-						if (this.displayNote) {
-							if (this.displayNoteIfEmpty || this.item.getNote() != '') {
-								Zotero.debug("setting links on top");
-								noteEditor.linksOnTop = true;
-								noteEditor.hidden = false;
-								
-								// Don't make note editable (at least for now)
-								if (this.mode == 'merge' || this.mode == 'mergeedit') {
-									noteEditor.mode = 'merge';
-									noteEditor.displayButton = false;
-								}
-								else {
-									noteEditor.mode = this.mode;
-								}
-								noteEditor.parent = null;
-								noteEditor.item = this.item;
-							}
-						}
-						else {
-							noteEditor.hidden = true;
-						}
+						});
+					}
+					else {
+						indexStatusRow.hidden = true;
+					}
+					
+					// Note editor
+					var noteEditor = this._id('attachment-note-editor');
+					if (this.displayNote) {
+						if (this.displayNoteIfEmpty || this.item.getNote() != '') {
+							Zotero.debug("setting links on top");
+							noteEditor.linksOnTop = true;
+							noteEditor.hidden = false;
 							
+							// Don't make note editable (at least for now)
+							if (this.mode == 'merge' || this.mode == 'mergeedit') {
+								noteEditor.mode = 'merge';
+								noteEditor.displayButton = false;
+							}
+							else {
+								noteEditor.mode = this.mode;
+							}
+							noteEditor.parent = null;
+							noteEditor.item = this.item;
+						}
+					}
+					else {
+						noteEditor.hidden = true;
+					}
 						
-						if (this.displayButton) {
-							selectButton.label = this.buttonCaption;
-							selectButton.hidden = false;
-							selectButton.setAttribute('oncommand',
-								'document.getBindingParent(this).clickHandler(this)');
-						}
-						else {
-							selectButton.hidden = true;
-						}
-					}, this);
+					
+					if (this.displayButton) {
+						selectButton.label = this.buttonCaption;
+						selectButton.hidden = false;
+						selectButton.setAttribute('oncommand',
+							'document.getBindingParent(this).clickHandler(this)');
+					}
+					else {
+						selectButton.hidden = true;
+					}
 				]]></body>
 			</method>
 			
diff --git a/chrome/content/zotero/bindings/itembox.xml b/chrome/content/zotero/bindings/itembox.xml
index c1ec439b3..622fd0b66 100644
--- a/chrome/content/zotero/bindings/itembox.xml
+++ b/chrome/content/zotero/bindings/itembox.xml
@@ -71,6 +71,7 @@
 					
 					switch (val) {
 						case 'view':
+						case 'merge':
 							break;
 						
 						case 'edit':
@@ -99,10 +100,9 @@
 			
 			<field name="_item"/>
 			<property name="item" onget="return this._item;">
-				<setter>
-				<![CDATA[
+				<setter><![CDATA[
 					if (!(val instanceof Zotero.Item)) {
-						throw ("<zoteroitembox>.item must be a Zotero.Item");
+						throw new Error("'item' must be a Zotero.Item");
 					}
 					
 					// When changing items, reset truncation of creator list
@@ -112,8 +112,7 @@
 					
 					this._item = val;
 					this.refresh();
-				]]>
-				</setter>
+				]]></setter>
 			</property>
 			
 			<!-- .ref is an alias for .item -->
diff --git a/chrome/content/zotero/bindings/merge.xml b/chrome/content/zotero/bindings/merge.xml
index 045fcca39..3cf96f722 100644
--- a/chrome/content/zotero/bindings/merge.xml
+++ b/chrome/content/zotero/bindings/merge.xml
@@ -99,9 +99,11 @@
 					}
 					
 					// Check for note or attachment
-					this.type = this._getTypeFromObject(
-						this._data.left.deleted ?  this._data.right : this._data.left
-					);
+					if (!this.type) {
+						this.type = this._getTypeFromObject(
+							this._data.left.deleted ?  this._data.right : this._data.left
+						);
+					}
 					
 					var showButton = this.type != 'item';
 					
@@ -109,7 +111,6 @@
 					this._rightpane.showButton = showButton;
 					this._leftpane.data = this._data.left;
 					this._rightpane.data = this._data.right;
-					this._mergepane.type = this.type;
 					this._mergepane.data = this._data.merge;
 					
 					if (this._data.selected == 'left') {
@@ -313,6 +314,7 @@
 							break;
 						
 						case 'attachment':
+						case 'file':
 							elementName = 'zoteroattachmentbox';
 							break;
 						
@@ -320,13 +322,8 @@
 							elementName = 'zoteronoteeditor';
 							break;
 						
-						case 'file':
-							elementName = 'zoterostoragefilebox';
-							break;
-						
 						default:
-							throw ("Object type '" + this.type
-								+ "' not supported in <zoteromergepane>.ref");
+							throw new Error("Object type '" + this.type + "' not supported");
 					}
 					
 					var objbox = document.createElement(elementName);
@@ -342,8 +339,7 @@
 					
 					objbox.setAttribute("anonid", "objectbox");
 					objbox.setAttribute("flex", "1");
-					
-					objbox.mode = 'view';
+					objbox.mode = this.type == 'file' ? 'filemerge' : 'merge';
 					
 					var button = this._id('choose-button');
 					if (this.showButton) {
@@ -363,7 +359,7 @@
 					// Create item from JSON for metadata box
 					var item = new Zotero.Item(val.itemType);
 					item.fromJSON(val);
-					objbox.ref = item;
+					objbox.item = item;
 				]]>
 				</setter>
 			</property>
diff --git a/chrome/content/zotero/bindings/noteeditor.xml b/chrome/content/zotero/bindings/noteeditor.xml
index a0a13a43b..d9b17c03e 100644
--- a/chrome/content/zotero/bindings/noteeditor.xml
+++ b/chrome/content/zotero/bindings/noteeditor.xml
@@ -64,6 +64,7 @@
 					
 					switch (val) {
 						case 'view':
+						case 'merge':
 							break;
 						
 						case 'edit':
diff --git a/chrome/content/zotero/bindings/relatedbox.xml b/chrome/content/zotero/bindings/relatedbox.xml
index 8ecbfba43..aa8cc30ef 100644
--- a/chrome/content/zotero/bindings/relatedbox.xml
+++ b/chrome/content/zotero/bindings/relatedbox.xml
@@ -109,6 +109,7 @@
 			]]>
 			</destructor>
 			
+			<!-- TODO: Asyncify -->
 			<method name="notify">
 				<parameter name="event"/>
 				<parameter name="type"/>
diff --git a/chrome/content/zotero/merge.js b/chrome/content/zotero/merge.js
index f35acc5ce..ee527c4d4 100644
--- a/chrome/content/zotero/merge.js
+++ b/chrome/content/zotero/merge.js
@@ -47,13 +47,20 @@ var Zotero_Merge_Window = new function () {
 		
 		_wizard.getButton('cancel').setAttribute('label', Zotero.getString('sync.cancel'));
 		
-		_io = window.arguments[0].wrappedJSObject;
+		_io = window.arguments[0];
+		// Not totally clear when this is necessary
+		if (window.arguments[0].wrappedJSObject) {
+			_io = window.arguments[0].wrappedJSObject;
+		}
 		_conflicts = _io.dataIn.conflicts;
 		if (!_conflicts.length) {
 			// TODO: handle no conflicts
 			return;
 		}
 		
+		if (_io.dataIn.type) {
+			_mergeGroup.type = _io.dataIn.type;
+		}
 		_mergeGroup.leftCaption = _io.dataIn.captions[0];
 		_mergeGroup.rightCaption = _io.dataIn.captions[1];
 		_mergeGroup.mergeCaption = _io.dataIn.captions[2];
@@ -240,7 +247,7 @@ var Zotero_Merge_Window = new function () {
 		}
 		// Apply changes from each side and pick most recent version for conflicting fields
 		var mergeInfo = {
-			data: {} 
+			data: {}
 		};
 		Object.assign(mergeInfo.data, _conflicts[pos].left)
 		Zotero.DataObjectUtilities.applyChanges(mergeInfo.data, _conflicts[pos].changes);
@@ -251,7 +258,9 @@ var Zotero_Merge_Window = new function () {
 		else {
 			var side = 1;
 		}
-		Zotero.DataObjectUtilities.applyChanges(mergeInfo.data, _conflicts[pos].conflicts.map(x => x[side]));
+		Zotero.DataObjectUtilities.applyChanges(
+			mergeInfo.data, _conflicts[pos].conflicts.map(x => x[side])
+		);
 		mergeInfo.selected = side ? 'right' : 'left';
 		return mergeInfo;
 	}
@@ -284,13 +293,22 @@ var Zotero_Merge_Window = new function () {
 	
 	
 	function _updateResolveAllCheckbox() {
-		if (_mergeGroup.rightpane.getAttribute("selected") == 'true') {
-			var label = 'resolveAllRemoteFields';
+		if (_mergeGroup.type == 'file') {
+			if (_mergeGroup.rightpane.getAttribute("selected") == 'true') {
+				var label = 'resolveAllRemote';
+			}
+			else {
+				var label = 'resolveAllLocal';
+			}
 		}
 		else {
-			var label = 'resolveAllLocalFields';
+			if (_mergeGroup.rightpane.getAttribute("selected") == 'true') {
+				var label = 'resolveAllRemoteFields';
+			}
+			else {
+				var label = 'resolveAllLocalFields';
+			}
 		}
-		// TODO: files
 		_resolveAllCheckbox.label = Zotero.getString('sync.conflict.' + label);
 	}
 	
diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js
index c8d4ee8cf..b5565ac35 100644
--- a/chrome/content/zotero/xpcom/data/item.js
+++ b/chrome/content/zotero/xpcom/data/item.js
@@ -50,7 +50,6 @@ Zotero.Item = function(itemTypeOrID) {
 	this._attachmentLinkMode = null;
 	this._attachmentContentType = null;
 	this._attachmentPath = null;
-	this._attachmentSyncState = null;
 	
 	// loadCreators
 	this._creators = [];
@@ -1453,14 +1452,13 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) {
 	
 	if (this._changed.attachmentData) {
 		let sql = "REPLACE INTO itemAttachments (itemID, parentItemID, linkMode, "
-			+ "contentType, charsetID, path, syncState) VALUES (?,?,?,?,?,?,?)";
+			+ "contentType, charsetID, path) VALUES (?,?,?,?,?,?)";
 		let linkMode = this.attachmentLinkMode;
 		let contentType = this.attachmentContentType;
 		let charsetID = this.attachmentCharset
 			? Zotero.CharacterSets.getID(this.attachmentCharset)
 			: null;
 		let path = this.attachmentPath;
-		let syncState = this.attachmentSyncState;
 		
 		if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE && libraryType != 'user') {
 			throw new Error("Linked files can only be added to user library");
@@ -1472,8 +1470,7 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) {
 			{ int: linkMode },
 			contentType ? { string: contentType } : null,
 			charsetID ? { int: charsetID } : null,
-			path ? { string: path } : null,
-			syncState ? { int: syncState } : 0
+			path ? { string: path } : null
 		];
 		yield Zotero.DB.queryAsync(sql, params);
 		
@@ -2295,8 +2292,10 @@ Zotero.Item.prototype.renameAttachmentFile = Zotero.Promise.coroutine(function*
 		yield this.relinkAttachmentFile(destPath);
 		
 		yield Zotero.DB.executeTransaction(function* () {
-			yield Zotero.Sync.Storage.setSyncedHash(this.id, null, false);
-			yield Zotero.Sync.Storage.setSyncState(this.id, Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD);
+			yield Zotero.Sync.Storage.Local.setSyncedHash(this.id, null, false);
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				this.id, Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD
+			);
 		}.bind(this));
 		
 		return true;
@@ -2317,11 +2316,10 @@ Zotero.Item.prototype.renameAttachmentFile = Zotero.Promise.coroutine(function*
 
 /**
  * @param {string} path  File path
- * @param {Boolean} [skipItemUpdate] Don't update attachment item mod time,
- *                                   so that item doesn't sync. Used when a file
- *                                   needs to be renamed to be accessible but the
- *                                   user doesn't have access to modify the
- *                                   attachment metadata
+ * @param {Boolean} [skipItemUpdate] Don't update attachment item mod time, so that item doesn't
+ *     sync. Used when a file needs to be renamed to be accessible but the user doesn't have
+ *     access to modify the attachment metadata. This also allows a save when the library is
+ *     read-only.
  */
 Zotero.Item.prototype.relinkAttachmentFile = Zotero.Promise.coroutine(function* (path, skipItemUpdate) {
 	if (path instanceof Components.interfaces.nsIFile) {
@@ -2382,7 +2380,8 @@ Zotero.Item.prototype.relinkAttachmentFile = Zotero.Promise.coroutine(function*
 	
 	yield this.saveTx({
 		skipDateModifiedUpdate: true,
-		skipClientDateModifiedUpdate: skipItemUpdate
+		skipClientDateModifiedUpdate: skipItemUpdate,
+		skipEditCheck: skipItemUpdate
 	});
 	
 	return true;
@@ -3606,9 +3605,6 @@ Zotero.Item.prototype.clone = Zotero.Promise.coroutine(function* (libraryID, ski
 				if (this.attachmentPath) {
 					newItem.attachmentPath = this.attachmentPath;
 				}
-				if (this.attachmentSyncState) {
-					newItem.attachmentSyncState = this.attachmentSyncState;
-				}
 			}
 		}
 	}
diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js
index dacd06967..8733dd03c 100644
--- a/chrome/content/zotero/xpcom/data/items.js
+++ b/chrome/content/zotero/xpcom/data/items.js
@@ -84,8 +84,7 @@ Zotero.Items = function() {
 				attachmentCharset: "CS.charset AS attachmentCharset",
 				attachmentLinkMode: "IA.linkMode AS attachmentLinkMode",
 				attachmentContentType: "IA.contentType AS attachmentContentType",
-				attachmentPath: "IA.path AS attachmentPath",
-				attachmentSyncState: "IA.syncState AS attachmentSyncState"
+				attachmentPath: "IA.path AS attachmentPath"
 			};
 		}
 	}, {lazy: true});
diff --git a/chrome/content/zotero/xpcom/file.js b/chrome/content/zotero/xpcom/file.js
index b289dab54..82976e9da 100644
--- a/chrome/content/zotero/xpcom/file.js
+++ b/chrome/content/zotero/xpcom/file.js
@@ -48,7 +48,7 @@ Zotero.File = new function(){
 		else if (pathOrFile instanceof Ci.nsIFile) {
 			return pathOrFile;
 		}
-		throw new Error('Unexpected value provided to Zotero.File.pathToFile() (' + pathOrFile + ')');
+		throw new Error("Unexpected value '" + pathOrFile + "'");
 	}
 	
 	
@@ -348,7 +348,7 @@ Zotero.File = new function(){
 	 * @param {String} [charset] - The character set; defaults to UTF-8
 	 * @return {Promise} - A promise that is resolved when the file has been written
 	 */
-	this.putContentsAsync = function putContentsAsync(path, data, charset) {
+	this.putContentsAsync = function (path, data, charset) {
 		if (path instanceof Ci.nsIFile) {
 			path = path.path;
 		}
@@ -424,18 +424,17 @@ Zotero.File = new function(){
 	 * iterator when done
 	 *
 	 * The DirectoryInterator is passed as the first parameter to the generator.
-	 * A StopIteration error will be caught automatically.
 	 *
 	 * Zotero.File.iterateDirectory(path, function* (iterator) {
 	 *    while (true) {
 	 *        var entry = yield iterator.next();
 	 *        [...]
 	 *    }
-	 * }).done()
+	 * })
 	 *
 	 * @return {Promise}
 	 */
-	this.iterateDirectory = function iterateDirectory(path, generator) {
+	this.iterateDirectory = function (path, generator) {
 		var iterator = new OS.File.DirectoryIterator(path);
 		return Zotero.Promise.coroutine(generator)(iterator)
 		.catch(function (e) {
@@ -470,6 +469,8 @@ Zotero.File = new function(){
 	
 	
 	this.createShortened = function (file, type, mode, maxBytes) {
+		file = this.pathToFile(file);
+		
 		if (!maxBytes) {
 			maxBytes = 255;
 		}
@@ -575,6 +576,8 @@ Zotero.File = new function(){
 			}
 			break;
 		}
+		
+		return file.leafName;
 	}
 	
 	
@@ -902,29 +905,28 @@ Zotero.File = new function(){
 	
 	
 	this.checkFileAccessError = function (e, file, operation) {
+		var str = 'file.accessError.';
 		if (file) {
-			var str = Zotero.getString('file.accessError.theFile', file.path);
+			str += 'theFile'
 		}
 		else {
-			var str = Zotero.getString('file.accessError.aFile');
+			str += 'aFile'
 		}
+		str += 'CannotBe';
 		
 		switch (operation) {
 			case 'create':
-				var opWord = Zotero.getString('file.accessError.created');
-				break;
-				
-			case 'update':
-				var opWord = Zotero.getString('file.accessError.updated');
+				str += 'Created';
 				break;
 				
 			case 'delete':
-				var opWord = Zotero.getString('file.accessError.deleted');
+				str += 'Deleted';
 				break;
 				
 			default:
-				var opWord = Zotero.getString('file.accessError.updated');
+				str += 'Updated';
 		}
+		str = Zotero.getString(str, file.path ? file.path : undefined);
 		
 		Zotero.debug(file.path);
 		Zotero.debug(e, 1);
@@ -962,4 +964,64 @@ Zotero.File = new function(){
 		
 		throw (e);
 	}
+	
+	
+	this.checkPathAccessError = function (e, path, operation) {
+		var str = 'file.accessError.';
+		if (path) {
+			str += 'theFile'
+		}
+		else {
+			str += 'aFile'
+		}
+		str += 'CannotBe';
+		
+		switch (operation) {
+			case 'create':
+				str += 'Created';
+				break;
+				
+			case 'delete':
+				str += 'Deleted';
+				break;
+				
+			default:
+				str += 'Updated';
+		}
+		str = Zotero.getString(str, path ? path : undefined);
+		
+		Zotero.debug(path);
+		Zotero.debug(e, 1);
+		Components.utils.reportError(e);
+		
+		// TODO: Check for specific errors?
+		if (e instanceof OS.File.Error) {
+			let checkFileWindows = Zotero.getString('file.accessError.message.windows');
+			let checkFileOther = Zotero.getString('file.accessError.message.other');
+			var msg = str + "\n\n"
+					+ (Zotero.isWin ? checkFileWindows : checkFileOther)
+					+ "\n\n"
+					+ Zotero.getString('file.accessError.restart');
+			
+			var e = new Zotero.Error(
+				msg,
+				0,
+				{
+					dialogButtonText: Zotero.getString('file.accessError.showParentDir'),
+					dialogButtonCallback: function () {
+						try {
+							file.parent.QueryInterface(Components.interfaces.nsILocalFile);
+							file.parent.reveal();
+						}
+						// Unsupported on some platforms
+						catch (e2) {
+							Zotero.launchFile(file.parent);
+						}
+					}
+				}
+			);
+		}
+		
+		throw e;
+	}
 }
diff --git a/chrome/content/zotero/xpcom/storage.js b/chrome/content/zotero/xpcom/storage.js
index f382bcfb1..719bb47ed 100644
--- a/chrome/content/zotero/xpcom/storage.js
+++ b/chrome/content/zotero/xpcom/storage.js
@@ -33,6 +33,7 @@ Zotero.Sync.Storage = new function () {
 	this.SYNC_STATE_IN_SYNC = 2;
 	this.SYNC_STATE_FORCE_UPLOAD = 3;
 	this.SYNC_STATE_FORCE_DOWNLOAD = 4;
+	this.SYNC_STATE_IN_CONFLICT = 5;
 	
 	this.SUCCESS = 1;
 	this.ERROR_NO_URL = -1;
@@ -57,14 +58,11 @@ Zotero.Sync.Storage = new function () {
 	this.__defineGetter__("defaultError", function () Zotero.getString('sync.storage.error.default', Zotero.appName));
 	this.__defineGetter__("defaultErrorRestart", function () Zotero.getString('sync.storage.error.defaultRestart', Zotero.appName));
 	
+	var _itemDownloadPercentages = {};
+	
 	//
 	// Public properties
 	//
-	
-	
-	this.__defineGetter__("syncInProgress", function () _syncInProgress);
-	this.__defineGetter__("updatesInProgress", function () _updatesInProgress);
-	
 	this.compressionTracker = {
 		compressed: 0,
 		uncompressed: 0,
@@ -75,539 +73,6 @@ Zotero.Sync.Storage = new function () {
 		}
 	}
 	
-	Zotero.Notifier.registerObserver(this, ['file']);
-	
-	
-	//
-	// Private properties
-	//
-	var _maxCheckAgeInSeconds = 10800; // maximum age for upload modification check (3 hours)
-	var _syncInProgress;
-	var _updatesInProgress;
-	var _itemDownloadPercentages = {};
-	var _uploadCheckFiles = [];
-	var _lastFullFileCheck = {};
-	
-	
-	this.sync = function (options) {
-		if (options.libraries) {
-			Zotero.debug("Starting file sync for libraries " + options.libraries);
-		}
-		else {
-			Zotero.debug("Starting file sync");
-		}
-		
-		var self = this;
-		
-		var libraryModes = {};
-		var librarySyncTimes = {};
-		
-		// Get personal library file sync mode
-		return Zotero.Promise.try(function () {
-			// TODO: Make sure modes are active
-			
-			if (options.libraries && options.libraries.indexOf(0) == -1) {
-				return;
-			}
-			
-			if (Zotero.Sync.Storage.ZFS.includeUserFiles) {
-				libraryModes[0] = Zotero.Sync.Storage.ZFS;
-			}
-			else if (Zotero.Sync.Storage.WebDAV.includeUserFiles) {
-				libraryModes[0] = Zotero.Sync.Storage.WebDAV;
-			}
-		})
-		.then(function () {
-			// Get group library file sync modes
-			if (Zotero.Sync.Storage.ZFS.includeGroupFiles) {
-				var groups = Zotero.Groups.getAll();
-				for each(var group in groups) {
-					if (options.libraries && options.libraries.indexOf(group.libraryID) == -1) {
-						continue;
-					}
-					// TODO: if library file syncing enabled
-					libraryModes[group.libraryID] = Zotero.Sync.Storage.ZFS;
-				}
-			}
-			
-			// Cache auth credentials for each mode
-			var modes = [];
-			var promises = [];
-			for each(var mode in libraryModes) {
-				if (modes.indexOf(mode) == -1) {
-					modes.push(mode);
-					
-					// Try to verify WebDAV server first if it hasn't been
-					if (mode == Zotero.Sync.Storage.WebDAV
-							&& !Zotero.Sync.Storage.WebDAV.verified) {
-						Zotero.debug("WebDAV file sync is not active");
-						var promise = Zotero.Sync.Storage.checkServerPromise(Zotero.Sync.Storage.WebDAV)
-						.then(function () {
-							return mode.cacheCredentials();
-						});
-					}
-					else {
-						var promise = mode.cacheCredentials();
-					}
-					promises.push(Zotero.Promise.allSettled([mode, promise]));
-				}
-			}
-			
-			return Zotero.Promise.all(promises)
-			// Get library last-sync times
-			.then(function (cacheCredentialsPromises) {
-				var promises = [];
-				
-				// Mark WebDAV verification failure as user library error.
-				// We ignore credentials-caching errors for ZFS and let the
-				// later requests fail.
-				cacheCredentialsPromises.forEach(function (results) {
-					let mode = results[0].value;
-					if (mode == Zotero.Sync.Storage.WebDAV) {
-						if (results[1].state == "rejected") {
-							promises.push(Zotero.Promise.allSettled(
-								[0, Zotero.Promise.reject(results[1].reason)]
-							));
-							// Skip further syncing of user library
-							delete libraryModes[0];
-						}
-					}
-				});
-				
-				for (var libraryID in libraryModes) {
-					libraryID = parseInt(libraryID);
-					
-					// Get the last sync time for each library
-					if (self.downloadOnSync(libraryID)) {
-						promises.push(Zotero.Promise.allSettled(
-							[libraryID, libraryModes[libraryID].getLastSyncTime(libraryID)]
-						));
-					}
-					// If download-as-needed, we don't need the last sync time
-					else {
-						promises.push(Zotero.Promise.allSettled([libraryID, null]));
-					}
-				}
-				return Zotero.Promise.all(promises);
-			});
-		})
-		.then(function (promises) {
-			if (!promises.length) {
-				Zotero.debug("No libraries are active for file sync");
-				return [];
-			}
-			
-			var libraryQueues = [];
-			
-			// Get the libraries we have sync times for
-			promises.forEach(function (results) {
-				let libraryID = results[0].value;
-				let lastSyncTime = results[1].value;
-				if (results[1].state == "fulfilled") {
-					librarySyncTimes[libraryID] = lastSyncTime;
-				}
-				else {
-					Zotero.debug(lastSyncTime.reason);
-					Components.utils.reportError(lastSyncTime.reason);
-					// Pass rejected promise through
-					libraryQueues.push(results);
-				}
-			});
-			
-			// Check for updated files to upload in each library
-			var promises = [];
-			for (let libraryID in librarySyncTimes) {
-				let promise;
-				libraryID = parseInt(libraryID);
-				
-				if (!Zotero.Libraries.isFilesEditable(libraryID)) {
-					Zotero.debug("No file editing access -- skipping file "
-						+ "modification check for library " + libraryID);
-					continue;
-				}
-				// If this is a background sync, it's not the first sync of
-				// the session, the library has had at least one full check
-				// this session, and it's been less than _maxCheckAgeInSeconds
-				// since the last full check of this library, check only files
-				// that were previously modified or opened recently
-				else if (options.background
-						&& !options.firstInSession
-						&& _lastFullFileCheck[libraryID]
-						&& (_lastFullFileCheck[libraryID] + (_maxCheckAgeInSeconds * 1000))
-							> new Date().getTime()) {
-					let itemIDs = _getFilesToCheck(libraryID);
-					promise = self.checkForUpdatedFiles(libraryID, itemIDs);
-				}
-				// Otherwise check all files in the library
-				else {
-					_lastFullFileCheck[libraryID] = new Date().getTime();
-					promise = self.checkForUpdatedFiles(libraryID);
-				}
-				promises.push(promise);
-			}
-			return Zotero.Promise.all(promises)
-			.then(function () {
-				// Queue files to download and upload from each library
-				for (let libraryID in librarySyncTimes) {
-					libraryID = parseInt(libraryID);
-					
-					var downloadAll = self.downloadOnSync(libraryID);
-					
-					// Forced downloads happen even in on-demand mode
-					var sql = "SELECT COUNT(*) FROM items "
-						+ "JOIN itemAttachments USING (itemID) "
-						+ "WHERE libraryID=? AND syncState=?";
-					var downloadForced = !!Zotero.DB.valueQuery(
-						sql,
-						[libraryID, Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD]
-					);
-					
-					// If we don't have any forced downloads, we can skip
-					// downloads if the last sync time hasn't changed
-					// or doesn't exist on the server (meaning there are no files)
-					if (downloadAll && !downloadForced) {
-						let lastSyncTime = librarySyncTimes[libraryID];
-						if (lastSyncTime) {
-							var version = self.getStoredLastSyncTime(
-								libraryModes[libraryID], libraryID
-							);
-							if (version == lastSyncTime) {
-								Zotero.debug("Last " + libraryModes[libraryID].name
-									+ " sync id hasn't changed for library "
-									+ libraryID + " -- skipping file downloads");
-								downloadAll = false;
-							}
-						}
-						else {
-							Zotero.debug("No last " + libraryModes[libraryID].name
-								+ " sync time for library " + libraryID
-								+ " -- skipping file downloads");
-							downloadAll = false;
-						}
-					}
-					
-					if (downloadAll || downloadForced) {
-						for each(var itemID in _getFilesToDownload(libraryID, !downloadAll)) {
-							var item = Zotero.Items.get(itemID);
-							self.queueItem(item);
-						}
-					}
-					
-					// Get files to upload
-					if (Zotero.Libraries.isFilesEditable(libraryID)) {
-						for each(var itemID in _getFilesToUpload(libraryID)) {
-							var item = Zotero.Items.get(itemID);
-							self.queueItem(item);
-						}
-					}
-					else {
-						Zotero.debug("No file editing access -- skipping file uploads for library " + libraryID);
-					}
-				}
-				
-				// Start queues for each library
-				for (let libraryID in librarySyncTimes) {
-					libraryID = parseInt(libraryID);
-					libraryQueues.push(Zotero.Promise.allSettled(
-						[libraryID, Zotero.Sync.Storage.QueueManager.start(libraryID)]
-					));
-				}
-				
-				// The promise is done when all libraries are done
-				return Zotero.Promise.all(libraryQueues);
-			});
-		})
-		.then(function (promises) {
-			Zotero.debug('Queue manager is finished');
-			
-			var changedLibraries = [];
-			var finalPromises = [];
-			
-			promises.forEach(function (results) {
-				var libraryID = results[0].value;
-				var libraryQueues = results[1].value;
-				
-				if (results[1].state == "fulfilled") {
-					libraryQueues.forEach(function (queuePromise) {
-						if (queueZotero.Promise.isFulfilled()) {
-							let result = queueZotero.Promise.value();
-							Zotero.debug("File " + result.type + " sync finished "
-								+ "for library " + libraryID);
-							if (result.localChanges) {
-								changedLibraries.push(libraryID);
-							}
-							finalPromises.push(Zotero.Promise.allSettled([
-								libraryID,
-								libraryModes[libraryID].setLastSyncTime(
-									libraryID,
-									result.remoteChanges ? false : librarySyncTimes[libraryID]
-								)
-							]));
-						}
-						else {
-							let e = queueZotero.Promise.reason();
-							Zotero.debug("File " + e.type + " sync failed "
-								+ "for library " + libraryID);
-							finalPromises.push(Zotero.Promise.allSettled(
-								[libraryID, Zotero.Promise.reject(e)]
-							));
-						}
-					});
-				}
-				else {
-					Zotero.debug("File sync failed for library " + libraryID);
-					finalPromises.push([libraryID, libraryQueues]);
-				}
-				
-				// If WebDAV sync enabled, purge deleted and orphaned files
-				if (libraryID == Zotero.Libraries.userLibraryID
-						&& Zotero.Sync.Storage.WebDAV.includeUserFiles) {
-					Zotero.Sync.Storage.WebDAV.purgeDeletedStorageFiles()
-					.then(function () {
-						return Zotero.Sync.Storage.WebDAV.purgeOrphanedStorageFiles();
-					})
-					.catch(function (e) {
-						Zotero.debug(e, 1);
-						Components.utils.reportError(e);
-					});
-				}
-			});
-			
-			Zotero.Sync.Storage.ZFS.purgeDeletedStorageFiles()
-			.catch(function (e) {
-				Zotero.debug(e, 1);
-				Components.utils.reportError(e);
-			});
-			
-			if (promises.length && !changedLibraries.length) {
-				Zotero.debug("No local changes made during file sync");
-			}
-			
-			return Zotero.Promise.all(finalPromises)
-			.then(function (promises) {
-				var results = {
-					changesMade: !!changedLibraries.length,
-					errors: []
-				};
-				
-				promises.forEach(function (promiseResults) {
-					var libraryID = promiseResults[0].value;
-					if (promiseResults[1].state == "rejected") {
-						let e = promiseResults[1].reason;
-						if (typeof e == 'string') {
-							e = new Error(e);
-						}
-						e.libraryID = libraryID;
-						results.errors.push(e);
-					}
-				});
-				
-				return results;
-			});
-		});
-	}
-	
-	
-	//
-	// Public methods
-	//
-	this.queueItem = function (item, highPriority) {
-		var library = item.libraryID;
-		if (libraryID) {
-			var mode = Zotero.Sync.Storage.ZFS;
-		}
-		else {
-			var mode = Zotero.Sync.Storage.ZFS.includeUserFiles
-				? Zotero.Sync.Storage.ZFS : Zotero.Sync.Storage.WebDAV;
-		}
-		switch (Zotero.Sync.Storage.getSyncState(item.id)) {
-			case this.SYNC_STATE_TO_DOWNLOAD:
-			case this.SYNC_STATE_FORCE_DOWNLOAD:
-				var queue = 'download';
-				var callbacks = {
-					onStart: function (request) {
-						return mode.downloadFile(request);
-					}
-				};
-				break;
-			
-			case this.SYNC_STATE_TO_UPLOAD:
-			case this.SYNC_STATE_FORCE_UPLOAD:
-				var queue = 'upload';
-				var callbacks = {
-					onStart: function (request) {
-						return mode.uploadFile(request);
-					}
-				}; 
-				break;
-			
-			case false:
-				Zotero.debug("Sync state for item " + item.id + " not found", 2);
-				return;
-		}
-		
-		var queue = Zotero.Sync.Storage.QueueManager.get(queue, library);
-		var request = new Zotero.Sync.Storage.Request(
-			item.libraryID + '/' + item.key, callbacks
-		);
-		if (queue.type == 'upload') {
-			try {
-				request.setMaxSize(Zotero.Attachments.getTotalFileSize(item));
-			}
-			// If this fails, ignore it, though we might fail later
-			catch (e) {
-				// But if the file doesn't exist yet, don't try to upload it
-				//
-				// This isn't a perfect test, because the file could still be
-				// in the process of being downloaded. It'd be better to
-				// download files to a temp directory and move them into place.
-				if (!item.getFile()) {
-					Zotero.debug("File " + item.libraryKey + " not yet available to upload -- skipping");
-					return;
-				}
-				
-				Components.utils.reportError(e);
-				Zotero.debug(e, 1);
-			}
-		}
-		queue.addRequest(request, highPriority);
-	};
-	
-	
-	this.getStoredLastSyncTime = function (mode, libraryID) {
-		var sql = "SELECT version FROM version WHERE schema=?";
-		return Zotero.DB.valueQuery(
-			sql, "storage_" + mode.name.toLowerCase() + "_" + libraryID
-		);
-	};
-	
-	
-	this.setStoredLastSyncTime = function (mode, libraryID, time) {
-		var sql = "REPLACE INTO version SET version=? WHERE schema=?";
-		Zotero.DB.query(
-			sql,
-			[
-				time,
-				"storage_" + mode.name.toLowerCase() + "_" + libraryID
-			]
-		);
-	};
-	
-	
-	/**
-	 * @param	{Integer}		itemID
-	 */
-	this.getSyncState = function (itemID) {
-		var sql = "SELECT syncState FROM itemAttachments WHERE itemID=?";
-		return Zotero.DB.valueQueryAsync(sql, itemID);
-	}
-	
-	
-	/**
-	 * @param	{Integer}		itemID
-	 * @param	{Integer}		syncState		Constant from Zotero.Sync.Storage
-	 */
-	this.setSyncState = Zotero.Promise.method(function (itemID, syncState) {
-		switch (syncState) {
-			case this.SYNC_STATE_TO_UPLOAD:
-			case this.SYNC_STATE_TO_DOWNLOAD:
-			case this.SYNC_STATE_IN_SYNC:
-			case this.SYNC_STATE_FORCE_UPLOAD:
-			case this.SYNC_STATE_FORCE_DOWNLOAD:
-				break;
-			
-			default:
-				throw new Error("Invalid sync state '" + syncState);
-		}
-		
-		var sql = "UPDATE itemAttachments SET syncState=? WHERE itemID=?";
-		return Zotero.DB.valueQueryAsync(sql, [syncState, itemID]);
-	});
-	
-	
-	/**
-	 * @param	{Integer}			itemID
-	 * @return	{Integer|NULL}					Mod time as timestamp in ms,
-	 *												or NULL if never synced
-	 */
-	this.getSyncedModificationTime = function (itemID) {
-		var sql = "SELECT storageModTime FROM itemAttachments WHERE itemID=?";
-		var mtime = Zotero.DB.valueQuery(sql, itemID);
-		if (mtime === false) {
-			throw "Item " + itemID + " not found in "
-				+ "Zotero.Sync.Storage.getSyncedModificationTime()";
-		}
-		return mtime;
-	}
-	
-	
-	/**
-	 * @param	{Integer}	itemID
-	 * @param	{Integer}	mtime				File modification time as
-	 *												timestamp in ms
-	 * @param	{Boolean}	[updateItem=FALSE]	Update dateModified field of
-	 *												attachment item
-	 */
-	this.setSyncedModificationTime = function (itemID, mtime, updateItem) {
-		if (mtime < 0) {
-			Components.utils.reportError("Invalid file mod time " + mtime
-				+ " in Zotero.Storage.setSyncedModificationTime()");
-			mtime = 0;
-		}
-		
-		Zotero.DB.beginTransaction();
-		
-		var sql = "UPDATE itemAttachments SET storageModTime=? WHERE itemID=?";
-		Zotero.DB.valueQuery(sql, [mtime, itemID]);
-		
-		if (updateItem) {
-			// Update item date modified so the new mod time will be synced
-			var sql = "UPDATE items SET clientDateModified=? WHERE itemID=?";
-			Zotero.DB.query(sql, [Zotero.DB.transactionDateTime, itemID]);
-		}
-		
-		Zotero.DB.commitTransaction();
-	}
-	
-	
-	/**
-	 * @param {Integer} itemID
-	 * @return {Promise<String|null|false>} - File hash, null if never synced, if false if
-	 *     file doesn't exist
-	 */
-	this.getSyncedHash = Zotero.Promise.coroutine(function* (itemID) {
-		var sql = "SELECT storageHash FROM itemAttachments WHERE itemID=?";
-		var hash = yield Zotero.DB.valueQueryAsync(sql, itemID);
-		if (hash === false) {
-			throw new Error("Item " + itemID + " not found");
-		}
-		return hash;
-	})
-	
-	
-	/**
-	 * @param	{Integer}	itemID
-	 * @param	{String}	hash				File hash
-	 * @param	{Boolean}	[updateItem=FALSE]	Update dateModified field of
-	 *												attachment item
-	 */
-	this.setSyncedHash = Zotero.Promise.coroutine(function* (itemID, hash, updateItem) {
-		if (hash !== null && hash.length != 32) {
-			throw ("Invalid file hash '" + hash + "' in Zotero.Storage.setSyncedHash()");
-		}
-		
-		Zotero.DB.requireTransaction();
-		
-		var sql = "UPDATE itemAttachments SET storageHash=? WHERE itemID=?";
-		yield Zotero.DB.queryAsync(sql, [hash, itemID]);
-		
-		if (updateItem) {
-			// Update item date modified so the new mod time will be synced
-			var sql = "UPDATE items SET clientDateModified=? WHERE itemID=?";
-			yield Zotero.DB.queryAsync(sql, [Zotero.DB.transactionDateTime, itemID]);
-		}
-	});
-	
 	
 	/**
 	 * Check if modification time of file on disk matches the mod time
@@ -646,518 +111,6 @@ Zotero.Sync.Storage = new function () {
 	}
 	
 	
-	/**
-	 * @param {Integer|'groups'} [libraryID]
-	 */
-	this.downloadAsNeeded = function (libraryID) {
-		// Personal library
-		if (!libraryID) {
-			return Zotero.Prefs.get('sync.storage.downloadMode.personal') == 'on-demand';
-		}
-		// Group library (groupID or 'groups')
-		else {
-			return Zotero.Prefs.get('sync.storage.downloadMode.groups') == 'on-demand';
-		}
-	}
-	
-	
-	/**
-	 * @param {Integer|'groups'} [libraryID]
-	 */
-	this.downloadOnSync = function (libraryID) {
-		// Personal library
-		if (!libraryID) {
-			return Zotero.Prefs.get('sync.storage.downloadMode.personal') == 'on-sync';
-		}
-		// Group library (groupID or 'groups')
-		else {
-			return Zotero.Prefs.get('sync.storage.downloadMode.groups') == 'on-sync';
-		}
-	}
-	
-	
-	
-	/**
-	 * Scans local files and marks any that have changed for uploading
-	 * and any that are missing for downloading
-	 *
-	 * @param {Integer} [libraryID]
-	 * @param {Integer[]} [itemIDs]
-	 * @param {Object} [itemModTimes]  Item mod times indexed by item ids;
-	 *                                 items with stored mod times
-	 *                                 that differ from the provided
-	 *                                 time but file mod times
-	 *                                 matching the stored time will
-	 *                                 be marked for download
-	 * @return {Promise} Promise resolving to TRUE if any items changed state,
-	 *                   FALSE otherwise
-	 */
-	this.checkForUpdatedFiles = function (libraryID, itemIDs, itemModTimes) {
-		return Zotero.Promise.try(function () {
-			libraryID = parseInt(libraryID);
-			if (isNaN(libraryID)) {
-				libraryID = false;
-			}
-			
-			var msg = "Checking for locally changed attachment files";
-			
-			var memmgr = Components.classes["@mozilla.org/memory-reporter-manager;1"]
-				.getService(Components.interfaces.nsIMemoryReporterManager);
-			memmgr.init();
-			//Zotero.debug("Memory usage: " + memmgr.resident);
-			
-			if (libraryID !== false) {
-				if (itemIDs) {
-					if (!itemIDs.length) {
-						var msg = "No files to check for local changes in library " + libraryID;
-						Zotero.debug(msg);
-						return false;
-					}
-				}
-				if (itemModTimes) {
-					throw new Error("itemModTimes is not allowed when libraryID is set");
-				}
-				
-				msg += " in library " + libraryID;
-			}
-			else if (itemIDs) {
-				throw new Error("libraryID not provided");
-			}
-			else if (itemModTimes) {
-				if (!Object.keys(itemModTimes).length) {
-					return false;
-				}
-				msg += " in download-marking mode";
-			}
-			else {
-				throw new Error("libraryID, itemIDs, or itemModTimes must be provided");
-			}
-			Zotero.debug(msg);
-			
-			var changed = false;
-			
-			if (!itemIDs) {
-				itemIDs = Object.keys(itemModTimes ? itemModTimes : {});
-			}
-			
-			// Can only handle a certain number of bound parameters at a time
-			var numIDs = itemIDs.length;
-			var maxIDs = Zotero.DB.MAX_BOUND_PARAMETERS - 10;
-			var done = 0;
-			var rows = [];
-			
-			Zotero.DB.beginTransaction();
-			
-			do {
-				var chunk = itemIDs.splice(0, maxIDs);
-				var sql = "SELECT itemID, linkMode, path, storageModTime, storageHash, syncState "
-							+ "FROM itemAttachments JOIN items USING (itemID) "
-							+ "WHERE linkMode IN (?,?) AND syncState IN (?,?)";
-				var params = [];
-				params.push(
-					Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
-					Zotero.Attachments.LINK_MODE_IMPORTED_URL,
-					Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD,
-					Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
-				);
-				if (libraryID !== false) {
-					sql += " AND libraryID=?";
-					params.push(libraryID);
-				}
-				if (chunk.length) {
-					sql += " AND itemID IN (" + chunk.map(function () '?').join() + ")";
-					params = params.concat(chunk);
-				}
-				var chunkRows = Zotero.DB.query(sql, params);
-				if (chunkRows) {
-					rows = rows.concat(chunkRows);
-				}
-				done += chunk.length;
-			}
-			while (done < numIDs);
-			
-			Zotero.DB.commitTransaction();
-			
-			// If no files, or everything is already marked for download,
-			// we don't need to do anything
-			if (!rows.length) {
-				var msg = "No in-sync or to-upload files found";
-				if (libraryID !== false) {
-					msg += " in library " + libraryID;
-				}
-				Zotero.debug(msg);
-				return false;
-			}
-			
-			// Index attachment data by item id
-			itemIDs = [];
-			var attachmentData = {};
-			for each(let row in rows) {
-				var id = row.itemID;
-				itemIDs.push(id);
-				attachmentData[id] = {
-					linkMode: row.linkMode,
-					path: row.path,
-					mtime: row.storageModTime,
-					hash: row.storageHash,
-					state: row.syncState
-				};
-			}
-			rows = null;
-			
-			var t = new Date();
-			var items = Zotero.Items.get(itemIDs);
-			var numItems = items.length;
-			var updatedStates = {};
-			
-			let checkItems = function () {
-				if (!items.length) return Zotero.Promise.resolve();
-				
-				//Zotero.debug("Memory usage: " + memmgr.resident);
-				
-				let item = items.shift();
-				let row = attachmentData[item.id];
-				let lk = item.libraryKey;
-				Zotero.debug("Checking attachment file for item " + lk);
-				
-				let nsIFile = item.getFile(row, true);
-				if (!nsIFile) {
-					Zotero.debug("Marking pathless attachment " + lk + " as in-sync");
-					updatedStates[item.id] = Zotero.Sync.Storage.SYNC_STATE_IN_SYNC;
-					return checkItems();
-				}
-				let file = null;
-				return Zotero.Promise.resolve(OS.File.open(nsIFile.path))
-				.then(function (promisedFile) {
-					file = promisedFile;
-					return file.stat()
-					.then(function (info) {
-						//Zotero.debug("Memory usage: " + memmgr.resident);
-						
-						var fmtime = info.lastModificationDate.getTime();
-						//Zotero.debug("File modification time for item " + lk + " is " + fmtime);
-						
-						if (fmtime < 1) {
-							Zotero.debug("File mod time " + fmtime + " is less than 1 -- interpreting as 1", 2);
-							fmtime = 1;
-						}
-						
-						// If file is already marked for upload, skip check. Even if this
-						// is download-marking mode (itemModTimes) and the file was
-						// changed remotely, conflicts are checked at upload time, so we
-						// don't need to worry about it here.
-						if (row.state == Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD) {
-							return;
-						}
-						
-						//Zotero.debug("Stored mtime is " + row.mtime);
-						//Zotero.debug("File mtime is " + fmtime);
-						
-						// Download-marking mode
-						if (itemModTimes) {
-							Zotero.debug("Remote mod time for item " + lk + " is " + itemModTimes[item.id]);
-							
-							// Ignore attachments whose stored mod times haven't changed
-							if (row.storageModTime == itemModTimes[item.id]) {
-								Zotero.debug("Storage mod time (" + row.storageModTime + ") "
-									+ "hasn't changed for item " + lk);
-								return;
-							}
-							
-							Zotero.debug("Marking attachment " + lk + " for download "
-								+ "(stored mtime: " + itemModTimes[item.id] + ")");
-							updatedStates[item.id] = Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD;
-						}
-						
-						var mtime = row.mtime;
-						
-						// If stored time matches file, it hasn't changed locally
-						if (mtime == fmtime) {
-							return;
-						}
-						
-						// Allow floored timestamps for filesystems that don't support
-						// millisecond precision (e.g., HFS+)
-						if (Math.floor(mtime / 1000) * 1000 == fmtime || Math.floor(fmtime / 1000) * 1000 == mtime) {
-							Zotero.debug("File mod times are within one-second precision "
-								+ "(" + fmtime + " ≅ " + mtime + ") for " + file.leafName
-								+ " for item " + lk + " -- ignoring");
-							return;
-						}
-						
-						// Allow timestamp to be exactly one hour off to get around
-						// time zone issues -- there may be a proper way to fix this
-						if (Math.abs(fmtime - mtime) == 3600000
-								// And check with one-second precision as well
-								|| Math.abs(fmtime - Math.floor(mtime / 1000) * 1000) == 3600000
-								|| Math.abs(Math.floor(fmtime / 1000) * 1000 - mtime) == 3600000) {
-							Zotero.debug("File mod time (" + fmtime + ") is exactly one "
-								+ "hour off remote file (" + mtime + ") for item " + lk
-								+ "-- assuming time zone issue and skipping upload");
-							return;
-						}
-						
-						// If file hash matches stored hash, only the mod time changed, so skip
-						return Zotero.Utilities.Internal.md5Async(file)
-						.then(function (fileHash) {
-							if (row.hash && row.hash == fileHash) {
-								// We have to close the file before modifying it from the main
-								// thread (at least on Windows, where assigning lastModifiedTime
-								// throws an NS_ERROR_FILE_IS_LOCKED otherwise)
-								return Zotero.Promise.resolve(file.close())
-								.then(function () {
-									Zotero.debug("Mod time didn't match (" + fmtime + "!=" + mtime + ") "
-										+ "but hash did for " + nsIFile.leafName + " for item " + lk
-										+ " -- updating file mod time");
-									try {
-										nsIFile.lastModifiedTime = row.mtime;
-									}
-									catch (e) {
-										Zotero.File.checkFileAccessError(e, nsIFile, 'update');
-									}
-								});
-							}
-							
-							// Mark file for upload
-							Zotero.debug("Marking attachment " + lk + " as changed "
-								+ "(" + mtime + " != " + fmtime + ")");
-							updatedStates[item.id] = Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD;
-						});
-					});
-				})
-				.finally(function () {
-					if (file) {
-						//Zotero.debug("Closing file for item " + lk);
-						file.close();
-					}
-				})
-				.catch(function (e) {
-					if (e instanceof OS.File.Error &&
-							(e.becauseNoSuchFile
-							// This can happen if a path is too long on Windows,
-							// e.g. a file is being accessed on a VM through a share
-							// (and probably in other cases).
-							|| (e.winLastError && e.winLastError == 3)
-							// Handle long filenames on OS X/Linux
-							|| (e.unixErrno && e.unixErrno == 63))) {
-						Zotero.debug("Marking attachment " + lk + " as missing");
-						updatedStates[item.id] = Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD;
-						return;
-					}
-					
-					if (e instanceof OS.File.Error) {
-						if (e.becauseClosed) {
-							Zotero.debug("File was closed", 2);
-						}
-						Zotero.debug(e);
-						Zotero.debug(e.toString());
-						throw new Error("Error for operation '" + e.operation + "' for " + nsIFile.path);
-					}
-					
-					throw e;
-				})
-				.then(function () {
-					return checkItems();
-				});
-			};
-			
-			return checkItems()
-			.then(function () {
-				for (let itemID in updatedStates) {
-					Zotero.Sync.Storage.setSyncState(itemID, updatedStates[itemID]);
-					changed = true;
-				}
-				
-				if (!changed) {
-					Zotero.debug("No synced files have changed locally");
-				}
-				
-				let msg = "Checked " + numItems + " files in ";
-				if (libraryID !== false) {
-					msg += "library " + libraryID + " in ";
-				}
-				msg += (new Date() - t) + "ms";
-				Zotero.debug(msg);
-				
-				return changed;
-			});
-		});
-	};
-	
-	
-	/**
-	 * Download a single file
-	 *
-	 * If no queue is active, start one. Otherwise, add to existing queue.
-	 */
-	this.downloadFile = function (item, requestCallbacks) {
-		var itemID = item.id;
-		var mode = getModeFromLibrary(item.libraryID);
-		
-		// TODO: verify WebDAV on-demand?
-		if (!mode || !mode.verified) {
-			Zotero.debug("File syncing is not active for item's library -- skipping download");
-			return false;
-		}
-		
-		if (!item.isImportedAttachment()) {
-			throw new Error("Not an imported attachment");
-		}
-		
-		if (item.getFile()) {
-			Zotero.debug("File already exists -- replacing");
-		}
-		
-		// TODO: start sync icon in cacheCredentials
-		return Zotero.Promise.try(function () {
-			return mode.cacheCredentials();
-		})
-		.then(function () {
-			// TODO: start sync icon
-			var library = item.libraryID;
-			var queue = Zotero.Sync.Storage.QueueManager.get(
-				'download', library
-			);
-			
-			if (!requestCallbacks) {
-				requestCallbacks = {};
-			}
-			var onStart = function (request) {
-				return mode.downloadFile(request);
-			};
-			requestCallbacks.onStart = requestCallbacks.onStart
-										? [onStart, requestCallbacks.onStart]
-										: onStart;
-			
-			var request = new Zotero.Sync.Storage.Request(
-				library + '/' + item.key, requestCallbacks
-			);
-			
-			queue.addRequest(request, true);
-			queue.start();
-			
-			return request.promise;
-		});
-	}
-	
-	
-	/**
-	 * Extract a downloaded file and update the database metadata
-	 *
-	 * This is called from Zotero.Sync.Server.StreamListener.onStopRequest()
-	 *
-	 * @return {Promise<Object>} data - Promise for object with properties 'request', 'item',
-	 *                                  'compressed', 'syncModTime', 'syncHash'
-	 */
-	this.processDownload = Zotero.Promise.coroutine(function* (data) {
-		var funcName = "Zotero.Sync.Storage.processDownload()";
-		
-		if (!data) {
-			throw "'data' not set in " + funcName;
-		}
-		
-		if (!data.item) {
-			throw "'data.item' not set in " + funcName;
-		}
-		
-		if (!data.syncModTime) {
-			throw "'data.syncModTime' not set in " + funcName;
-		}
-		
-		if (!data.compressed && !data.syncHash) {
-			throw "'data.syncHash' is required if 'data.compressed' is false in " + funcName;
-		}
-		
-		var item = data.item;
-		var syncModTime = data.syncModTime;
-		var syncHash = data.syncHash;
-		
-		// TODO: Test file hash
-		
-		if (data.compressed) {
-			var newFile = yield _processZipDownload(item);
-		}
-		else {
-			var newFile = yield _processDownload(item);
-		}
-		
-		// If |newFile| is set, the file was renamed, so set item filename to that
-		// and mark for updated
-		var file = item.getFile();
-		if (newFile && file.leafName != newFile.leafName) {
-			// Bypass library access check
-			_updatesInProgress = true;
-			
-			// If library isn't editable but filename was changed, update
-			// database without updating the item's mod time, which would result
-			// in a library access error
-			if (!Zotero.Items.isEditable(item)) {
-				Zotero.debug("File renamed without library access -- "
-					+ "updating itemAttachments path", 3);
-				item.relinkAttachmentFile(newFile, true);
-				var useCurrentModTime = false;
-			}
-			else {
-				item.relinkAttachmentFile(newFile);
-				
-				// TODO: use an integer counter instead of mod time for change detection
-				var useCurrentModTime = true;
-			}
-			
-			file = item.getFile();
-			_updatesInProgress = false;
-		}
-		else {
-			var useCurrentModTime = false;
-		}
-		
-		if (!file) {
-			// This can happen if an HTML snapshot filename was changed and synced
-			// elsewhere but the renamed file wasn't synced, so the ZIP doesn't
-			// contain a file with the known name
-			var missingFile = item.getFile(null, true);
-			Components.utils.reportError("File '" + missingFile.leafName
-				+ "' not found after processing download "
-				+ item.libraryID + "/" + item.key + " in " + funcName);
-			return false;
-		}
-		
-		Zotero.DB.beginTransaction();
-		
-		//var syncState = Zotero.Sync.Storage.getSyncState(item.id);
-		//var updateItem = syncState != this.SYNC_STATE_TO_DOWNLOAD;
-		var updateItem = false;
-		
-		try {
-			if (useCurrentModTime) {
-				file.lastModifiedTime = new Date();
-				
-				// Reset hash and sync state
-				Zotero.Sync.Storage.setSyncedHash(item.id, null);
-				Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD);
-				this.queueItem(item);
-			}
-			else {
-				file.lastModifiedTime = syncModTime;
-				// If hash not provided (e.g., WebDAV), calculate it now
-				if (!syncHash) {
-					syncHash = item.attachmentHash;
-				}
-				Zotero.Sync.Storage.setSyncedHash(item.id, syncHash);
-				Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
-			}
-		}
-		catch (e) {
-			Zotero.File.checkFileAccessError(e, file, 'update');
-		}
-		
-		Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem);
-		Zotero.DB.commitTransaction();
-		
-		return true;
-	});
-	
-	
 	this.checkServerPromise = function (mode) {
 		return mode.checkServer()
 		.spread(function (uri, status) {
@@ -1277,570 +230,16 @@ Zotero.Sync.Storage = new function () {
 	}
 	
 	
-	this.getItemFromRequestName = function (name) {
-		var [libraryID, key] = name.split('/');
-		return Zotero.Items.getByLibraryAndKey(libraryID, key);
-	}
-	
-	
-	this.notify = function(event, type, ids, extraData) {
-		if (event == 'open' && type == 'file') {
-			let timestamp = new Date().getTime();
-			
-			for each(let id in ids) {
-				_uploadCheckFiles.push({
-					itemID: id,
-					timestamp: timestamp
-				});
-			}
-		}
-	}
-	
-	
-	//
-	// Private methods
-	//
-	function getModeFromLibrary(libraryID) {
-		if (libraryID === undefined) {
-			throw new Error("libraryID not provided");
-		}
-		
-		// Personal library
-		if (!libraryID) {
-			if (Zotero.Sync.Storage.ZFS.includeUserFiles) {
-				return Zotero.Sync.Storage.ZFS;
-			}
-			if (Zotero.Sync.Storage.WebDAV.includeUserFiles) {
-				return Zotero.Sync.Storage.WebDAV;
-			}
-			return false;
-		}
-		
-		// Group library
-		else {
-			if (Zotero.Sync.Storage.ZFS.includeGroupFiles) {
-				return Zotero.Sync.Storage.ZFS;
-			}
-			return false;
-		}
-	}
-	
-	
-	var _processDownload = Zotero.Promise.coroutine(function* (item) {
-		var funcName = "Zotero.Sync.Storage._processDownload()";
-		
-		var tempFile = Zotero.getTempDirectory();
-		tempFile.append(item.key + '.tmp');
-		
-		if (!tempFile.exists()) {
-			Zotero.debug(tempFile.path);
-			throw ("Downloaded file not found in " + funcName);
-		}
-		
-		var parentDir = Zotero.Attachments.getStorageDirectory(item);
-		if (!parentDir.exists()) {
-			yield Zotero.Attachments.createDirectoryForItem(item);
-		}
-		
-		_deleteExistingAttachmentFiles(item);
-		
-		var file = item.getFile(null, true);
-		if (!file) {
-			throw ("Empty path for item " + item.key + " in " + funcName);
-		}
-		// Don't save Windows aliases
-		if (file.leafName.endsWith('.lnk')) {
-			return false;
-		}
-		
-		var fileName = file.leafName;
-		var renamed = false;
-		
-		// Make sure the new filename is valid, in case an invalid character made it over
-		// (e.g., from before we checked for them)
-		var filteredName = Zotero.File.getValidFileName(fileName);
-		if (filteredName != fileName) {
-			Zotero.debug("Filtering filename '" + fileName + "' to '" + filteredName + "'");
-			fileName = filteredName;
-			file.leafName = fileName;
-			renamed = true;
-		}
-		
-		Zotero.debug("Moving download file " + tempFile.leafName + " into attachment directory as '" + fileName + "'");
-		try {
-			var destFile = parentDir.clone();
-			destFile.append(fileName);
-			Zotero.File.createShortened(destFile, Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644);
-		}
-		catch (e) {
-			Zotero.File.checkFileAccessError(e, destFile, 'create');
-		}
-		
-		if (destFile.leafName != fileName) {
-			Zotero.debug("Changed filename '" + fileName + "' to '" + destFile.leafName + "'");
-			
-			// Abort if Windows path limitation would cause filenames to be overly truncated
-			if (Zotero.isWin && destFile.leafName.length < 40) {
-				try {
-					destFile.remove(false);
-				}
-				catch (e) {}
-				// TODO: localize
-				var msg = "Due to a Windows path length limitation, your Zotero data directory "
-					+ "is too deep in the filesystem for syncing to work reliably. "
-					+ "Please relocate your Zotero data to a higher directory.";
-				Zotero.debug(msg, 1);
-				throw new Error(msg);
-			}
-			
-			renamed = true;
-		}
-		
-		try {
-			tempFile.moveTo(parentDir, destFile.leafName);
-		}
-		catch (e) {
-			try {
-				destFile.remove(false);
-			}
-			catch (e) {}
-			
-			Zotero.File.checkFileAccessError(e, destFile, 'create');
-		}
-		
-		var returnFile = null;
-		// processDownload() needs to know that we're renaming the file
-		if (renamed) {
-			returnFile = destFile.clone();
-		}
-		return returnFile;
-	});
-	
-	
-	var _processZipDownload = Zotero.Promise.coroutine(function* (item) {
-		var funcName = "Zotero.Sync.Storage._processDownloadedZip()";
-		
-		var zipFile = Zotero.getTempDirectory();
-		zipFile.append(item.key + '.zip.tmp');
-		
-		if (!zipFile.exists()) {
-			throw ("Downloaded ZIP file not found in " + funcName);
-		}
-		
-		var zipReader = Components.classes["@mozilla.org/libjar/zip-reader;1"].
-				createInstance(Components.interfaces.nsIZipReader);
-		try {
-			zipReader.open(zipFile);
-			zipReader.test(null);
-			
-			Zotero.debug("ZIP file is OK");
-		}
-		catch (e) {
-			Zotero.debug(zipFile.leafName + " is not a valid ZIP file", 2);
-			zipReader.close();
-			
-			try {
-				zipFile.remove(false);
-			}
-			catch (e) {
-				Zotero.File.checkFileAccessError(e, zipFile, 'delete');
-			}
-			
-			// TODO: Remove prop file to trigger reuploading, in case it was an upload error?
-			
-			return false;
-		}
-		
-		var parentDir = Zotero.Attachments.getStorageDirectory(item);
-		if (!parentDir.exists()) {
-			yield Zotero.Attachments.createDirectoryForItem(item);
-		}
-		
-		try {
-			_deleteExistingAttachmentFiles(item);
-		}
-		catch (e) {
-			zipReader.close();
-			throw (e);
-		}
-		
-		var returnFile = null;
-		var count = 0;
-		
-		var entries = zipReader.findEntries(null);
-		while (entries.hasMore()) {
-			count++;
-			var entryName = entries.getNext();
-			var b64re = /%ZB64$/;
-			if (entryName.match(b64re)) {
-				var fileName = Zotero.Utilities.Internal.Base64.decode(
-					entryName.replace(b64re, '')
-				);
-			}
-			else {
-				var fileName = entryName;
-			}
-			
-			if (fileName.startsWith('.zotero')) {
-				Zotero.debug("Skipping " + fileName);
-				continue;
-			}
-			
-			Zotero.debug("Extracting " + fileName);
-			
-			var primaryFile = false;
-			var filtered = false;
-			var renamed = false;
-			
-			// Get the old filename
-			var itemFileName = item.getFilename();
-			
-			// Make sure the new filename is valid, in case an invalid character
-			// somehow make it into the ZIP (e.g., from before we checked for them)
-			//
-			// Do this before trying to use the relative descriptor, since otherwise
-			// it might fail silently and select the parent directory
-			var filteredName = Zotero.File.getValidFileName(fileName);
-			if (filteredName != fileName) {
-				Zotero.debug("Filtering filename '" + fileName + "' to '" + filteredName + "'");
-				fileName = filteredName;
-				filtered = true;
-			}
-			
-			// Name in ZIP is a relative descriptor, so file has to be reconstructed
-			// using setRelativeDescriptor()
-			var destFile = parentDir.clone();
-			destFile.QueryInterface(Components.interfaces.nsILocalFile);
-			destFile.setRelativeDescriptor(parentDir, fileName);
-			
-			fileName = destFile.leafName;
-			
-			// If only one file in zip and it doesn't match the known filename,
-			// take our chances and use that name
-			if (count == 1 && !entries.hasMore() && itemFileName) {
-				// May not be necessary, but let's be safe
-				itemFileName = Zotero.File.getValidFileName(itemFileName);
-				if (itemFileName != fileName) {
-					Zotero.debug("Renaming single file '" + fileName + "' in ZIP to known filename '" + itemFileName + "'", 2);
-					Components.utils.reportError("Renaming single file '" + fileName + "' in ZIP to known filename '" + itemFileName + "'");
-					fileName = itemFileName;
-					destFile.leafName = fileName;
-					renamed = true;
-				}
-			}
-			
-			var primaryFile = itemFileName == fileName;
-			if (primaryFile && filtered) {
-				renamed = true;
-			}
-			
-			if (destFile.exists()) {
-				var msg = "ZIP entry '" + fileName + "' " + "already exists";
-				Zotero.debug(msg, 2);
-				Components.utils.reportError(msg + " in " + funcName);
-				continue;
-			}
-			
-			try {
-				Zotero.File.createShortened(destFile, Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644);
-			}
-			catch (e) {
-				Zotero.debug(e, 1);
-				Components.utils.reportError(e);
-				
-				zipReader.close();
-				
-				Zotero.File.checkFileAccessError(e, destFile, 'create');
-			}
-			
-			if (destFile.leafName != fileName) {
-				Zotero.debug("Changed filename '" + fileName + "' to '" + destFile.leafName + "'");
-				
-				// Abort if Windows path limitation would cause filenames to be overly truncated
-				if (Zotero.isWin && destFile.leafName.length < 40) {
-					try {
-						destFile.remove(false);
-					}
-					catch (e) {}
-					zipReader.close();
-					// TODO: localize
-					var msg = "Due to a Windows path length limitation, your Zotero data directory "
-						+ "is too deep in the filesystem for syncing to work reliably. "
-						+ "Please relocate your Zotero data to a higher directory.";
-					Zotero.debug(msg, 1);
-					throw new Error(msg);
-				}
-				
-				if (primaryFile) {
-					renamed = true;
-				}
-			}
-			
-			try {
-				zipReader.extract(entryName, destFile);
-			}
-			catch (e) {
-				try {
-					destFile.remove(false);
-				}
-				catch (e) {}
-				
-				// For advertising junk files, ignore a bug on Windows where
-				// destFile.create() works but zipReader.extract() doesn't
-				// when the path length is close to 255.
-				if (destFile.leafName.match(/[a-zA-Z0-9+=]{130,}/)) {
-					var msg = "Ignoring error extracting '" + destFile.path + "'";
-					Zotero.debug(msg, 2);
-					Zotero.debug(e, 2);
-					Components.utils.reportError(msg + " in " + funcName);
-					continue;
-				}
-				
-				zipReader.close();
-				
-				Zotero.File.checkFileAccessError(e, destFile, 'create');
-			}
-			
-			destFile.permissions = 0644;
-			
-			// If we're renaming the main file, processDownload() needs to know
-			if (renamed) {
-				returnFile = destFile;
-			}
-		}
-		zipReader.close();
-		zipFile.remove(false);
-		
-		return returnFile;
-	});
-	
-	
-	function _deleteExistingAttachmentFiles(item) {
-		var funcName = "Zotero.Sync.Storage._deleteExistingAttachmentFiles()";
-		
-		var parentDir = Zotero.Attachments.getStorageDirectory(item);
-		
-		// Delete existing files
-		var otherFiles = parentDir.directoryEntries;
-		otherFiles.QueryInterface(Components.interfaces.nsIDirectoryEnumerator);
-		var filesToDelete = [];
-		var file;
-		while (file = otherFiles.nextFile) {
-			if (file.leafName.startsWith('.zotero')) {
-				continue;
-			}
-			
-			// Check symlink awareness, just to be safe
-			if (!parentDir.contains(file, false)) {
-				var msg = "Storage directory doesn't contain '" + file.leafName + "'";
-				Zotero.debug(msg, 2);
-				Components.utils.reportError(msg + " in " + funcName);
-				continue;
-			}
-			
-			filesToDelete.push(file);
-		}
-		otherFiles.close();
-		
-		// Do deletes outside of the enumerator to avoid an access error on Windows
-		for each(var file in filesToDelete) {
-			try {
-				if (file.isFile()) {
-					Zotero.debug("Deleting existing file " + file.leafName);
-					file.remove(false);
-				}
-				else if (file.isDirectory()) {
-					Zotero.debug("Deleting existing directory " + file.leafName);
-					file.remove(true);
-				}
-			}
-			catch (e) {
-				Zotero.File.checkFileAccessError(e, file, 'delete');
-			}
-		}
-	}
-	
-	
-	/**
-	 * Create zip file of attachment directory
-	 *
-	 * @param	{Zotero.Sync.Storage.Request}		request
-	 * @param	{Function}							callback
-	 * @return	{Boolean}							TRUE if zip process started,
-	 *												FALSE if storage was empty
-	 */
-	this.createUploadFile = function (request, callback) {
-		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
-		Zotero.debug("Creating zip file for item " + item.libraryID + "/" + item.key);
-		
-		try {
-			switch (item.attachmentLinkMode) {
-				case Zotero.Attachments.LINK_MODE_LINKED_FILE:
-				case Zotero.Attachments.LINK_MODE_LINKED_URL:
-					throw (new Error(
-						"Upload file must be an imported snapshot or file in "
-							+ "Zotero.Sync.Storage.createUploadFile()"
-					));
-			}
-			
-			var dir = Zotero.Attachments.getStorageDirectory(item);
-			
-			var tmpFile = Zotero.getTempDirectory();
-			tmpFile.append(item.key + '.zip');
-			
-			var zw = Components.classes["@mozilla.org/zipwriter;1"]
-				.createInstance(Components.interfaces.nsIZipWriter);
-			zw.open(tmpFile, 0x04 | 0x08 | 0x20); // open rw, create, truncate
-			var fileList = _zipDirectory(dir, dir, zw);
-			if (fileList.length == 0) {
-				Zotero.debug('No files to add -- removing zip file');
-				zw.close();
-				tmpFile.remove(null);
-				return false;
-			}
-			
-			Zotero.debug('Creating ' + tmpFile.leafName + ' with ' + fileList.length + ' file(s)');
-			
-			var observer = new Zotero.Sync.Storage.ZipWriterObserver(
-				zw, callback, { request: request, files: fileList }
-			);
-			zw.processQueue(observer, null);
-			return true;
-		}
-		// DEBUG: Do we want to catch this?
-		catch (e) {
-			Zotero.debug(e, 1);
-			Components.utils.reportError(e);
-			return false;
-		}
-	}
-	
-	function _zipDirectory(rootDir, dir, zipWriter) {
-		var fileList = [];
-		dir = dir.directoryEntries;
-		while (dir.hasMoreElements()) {
-			var file = dir.getNext();
-			file.QueryInterface(Components.interfaces.nsILocalFile);
-			if (file.isDirectory()) {
-				//Zotero.debug("Recursing into directory " + file.leafName);
-				fileList.concat(_zipDirectory(rootDir, file, zipWriter));
-				continue;
-			}
-			var fileName = file.getRelativeDescriptor(rootDir);
-			if (fileName.startsWith('.zotero')) {
-				Zotero.debug('Skipping file ' + fileName);
-				continue;
-			}
-			
-			//Zotero.debug("Adding file " + fileName);
-			
-			zipWriter.addEntryFile(
-				fileName,
-				Components.interfaces.nsIZipWriter.COMPRESSION_DEFAULT,
-				file,
-				true
-			);
-			fileList.push(fileName);
-		}
-		return fileList;
-	}
 	
 	
 
-	/**
-	 * Get files marked as ready to download
-	 *
-	 * @inner
-	 * @return	{Number[]}	Array of attachment itemIDs
-	 */
-	function _getFilesToDownload(libraryID, forcedOnly) {
-		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
-					+ "WHERE libraryID=? AND syncState IN (?";
-		var params = [libraryID, Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD];
-		if (!forcedOnly) {
-			sql += ",?";
-			params.push(Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD);
-		}
-		sql += ") "
-			// Skip attachments with empty path, which can't be saved, and files with .zotero*
-			// paths, which have somehow ended up in some users' libraries
-			+ "AND path!='' AND path NOT LIKE 'storage:.zotero%'";
-		var itemIDs = Zotero.DB.columnQuery(sql, params);
-		if (!itemIDs) {
-			return [];
-		}
-		return itemIDs;
-	}
 	
 	
-	/**
-	 * Get files marked as ready to upload
-	 *
-	 * @inner
-	 * @return	{Number[]}	Array of attachment itemIDs
-	 */
-	function _getFilesToUpload(libraryID) {
-		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
-			+ "WHERE syncState IN (?,?) AND linkMode IN (?,?)";
-		var params = [
-			Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD,
-			Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD,
-			Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
-			Zotero.Attachments.LINK_MODE_IMPORTED_URL
-		];
-		if (typeof libraryID != 'undefined') {
-			sql += " AND libraryID=?";
-			params.push(libraryID);
-		}
-		else {
-			throw new Error("libraryID not specified");
-		}
-		var itemIDs = Zotero.DB.columnQuery(sql, params);
-		if (!itemIDs) {
-			return [];
-		}
-		return itemIDs;
-	}
 	
 	
-	/**
-	 * Get files to check for local modifications for uploading
-	 *
-	 * This includes files previously modified and files opened externally
-	 * via Zotero within _maxCheckAgeInSeconds.
-	 */
-	function _getFilesToCheck(libraryID) {
-		var minTime = new Date().getTime() - (_maxCheckAgeInSeconds * 1000);
-		
-		// Get files by modification time
-		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
-			+ "WHERE libraryID=? AND linkMode IN (?,?) AND syncState IN (?) AND "
-			+ "storageModTime>=?";
-		var params = [
-			libraryID,
-			Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
-			Zotero.Attachments.LINK_MODE_IMPORTED_URL,
-			Zotero.Sync.Storage.SYNC_STATE_IN_SYNC,
-			minTime
-		];
-		var itemIDs = Zotero.DB.columnQuery(sql, params) || [];
-		
-		// Get files by open time
-		_uploadCheckFiles.filter(function (x) x.timestamp >= minTime);
-		itemIDs = itemIDs.concat([x.itemID for each(x in _uploadCheckFiles)])
-		
-		return Zotero.Utilities.arrayUnique(itemIDs);
-	}
 	
 	
-	/**
-	 * @inner
-	 * @return	{String[]|FALSE}			Array of keys, or FALSE if none
-	 */
-	this.getDeletedFiles = function () {
-		var sql = "SELECT key FROM storageDeleteLog";
-		return Zotero.DB.columnQuery(sql);
-	}
+	
 	
 	
 	function error(e) {
@@ -1892,56 +291,4 @@ Zotero.Sync.Storage = new function () {
 }
 
 
-/**
- * Request observer for zip writing
- *
- * Implements nsIRequestObserver
- *
- * @param	{nsIZipWriter}	zipWriter
- * @param	{Function}		callback
- * @param	{Object}			data
- */
-Zotero.Sync.Storage.ZipWriterObserver = function (zipWriter, callback, data) {
-	this._zipWriter = zipWriter;
-	this._callback = callback;
-	this._data = data;
-}
 
-Zotero.Sync.Storage.ZipWriterObserver.prototype = {
-	onStartRequest: function () {},
-	
-	onStopRequest: function(req, context, status) {
-		var zipFileName = this._zipWriter.file.leafName;
-		
-		var originalSize = 0;
-		for each(var fileName in this._data.files) {
-			var entry = this._zipWriter.getEntry(fileName);
-			if (!entry) {
-				var msg = "ZIP entry '" + fileName + "' not found for request '" + this._data.request.name + "'";
-				Components.utils.reportError(msg);
-				Zotero.debug(msg, 1);
-				this._zipWriter.close();
-				this._callback(false);
-				return;
-			}
-			originalSize += entry.realSize;
-		}
-		delete this._data.files;
-		
-		this._zipWriter.close();
-		
-		Zotero.debug("Zip of " + zipFileName + " finished with status " + status
-			+ " (original " + Math.round(originalSize / 1024) + "KB, "
-			+ "compressed " + Math.round(this._zipWriter.file.fileSize / 1024) + "KB, "
-			+ Math.round(
-				((originalSize - this._zipWriter.file.fileSize) / originalSize) * 100
-			  ) + "% reduction)");
-		
-		Zotero.Sync.Storage.compressionTracker.compressed += this._zipWriter.file.fileSize;
-		Zotero.Sync.Storage.compressionTracker.uncompressed += originalSize;
-		Zotero.debug("Average compression so far: "
-			+ Math.round(Zotero.Sync.Storage.compressionTracker.ratio * 100) + "%");
-		
-		this._callback(this._data);
-	}
-}
diff --git a/chrome/content/zotero/xpcom/storage/mode.js b/chrome/content/zotero/xpcom/storage/mode.js
deleted file mode 100644
index 472b6a101..000000000
--- a/chrome/content/zotero/xpcom/storage/mode.js
+++ /dev/null
@@ -1,87 +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.Mode = function () {};
-
-Zotero.Sync.Storage.Mode.prototype.__defineGetter__('verified', function () {
-	return this._verified;
-});
-
-Zotero.Sync.Storage.Mode.prototype.__defineGetter__('username', function () {
-	return this._username;
-});
-
-Zotero.Sync.Storage.Mode.prototype.__defineGetter__('password', function () {
-	return this._password;
-});
-
-Zotero.Sync.Storage.Mode.prototype.__defineSetter__('password', function (val) {
-	this._password = val;
-});
-
-Zotero.Sync.Storage.Mode.prototype.init = function () {
-	return this._init();
-}
-
-Zotero.Sync.Storage.Mode.prototype.sync = function (observer) {
-	return Zotero.Sync.Storage.sync(this.name, observer);
-}
-
-Zotero.Sync.Storage.Mode.prototype.downloadFile = function (request) {
-	return this._downloadFile(request);
-}
-
-Zotero.Sync.Storage.Mode.prototype.uploadFile = function (request) {
-	return this._uploadFile(request);
-}
-
-Zotero.Sync.Storage.Mode.prototype.getLastSyncTime = function (libraryID) {
-	return this._getLastSyncTime(libraryID);
-}
-
-Zotero.Sync.Storage.Mode.prototype.setLastSyncTime = function (callback, useLastSyncTime) {
-	return this._setLastSyncTime(callback, useLastSyncTime);
-}
-
-Zotero.Sync.Storage.Mode.prototype.checkServer = function (callback) {
-	return this._checkServer(callback);
-}
-
-Zotero.Sync.Storage.Mode.prototype.checkServerCallback = function (uri, status, window, skipSuccessMessage) {
-	return this._checkServerCallback(uri, status, window, skipSuccessMessage);
-}
-
-Zotero.Sync.Storage.Mode.prototype.cacheCredentials = function () {
-	return this._cacheCredentials();
-}
-
-Zotero.Sync.Storage.Mode.prototype.purgeDeletedStorageFiles = function (callback) {
-	return this._purgeDeletedStorageFiles(callback);
-}
-
-Zotero.Sync.Storage.Mode.prototype.purgeOrphanedStorageFiles = function (callback) {
-	return this._purgeOrphanedStorageFiles(callback);
-}
diff --git a/chrome/content/zotero/xpcom/storage/queue.js b/chrome/content/zotero/xpcom/storage/queue.js
deleted file mode 100644
index 25a399d72..000000000
--- a/chrome/content/zotero/xpcom/storage/queue.js
+++ /dev/null
@@ -1,427 +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 *****
-*/
-
-/**
- * Queue for storage sync transfer requests
- *
- * @param	{String}		type		Queue type (e.g., 'download' or 'upload')
- */
-Zotero.Sync.Storage.Queue = function (type, libraryID) {
-	Zotero.debug("Initializing " + type + " queue for library " + libraryID);
-	
-	// Public properties
-	this.type = type;
-	this.libraryID = libraryID;
-	this.maxConcurrentRequests = 1;
-	this.activeRequests = 0;
-	this.totalRequests = 0;
-	
-	// Private properties
-	this._requests = {};
-	this._highPriority = [];
-	this._running = false;
-	this._stopping = false;
-	this._finished = false;
-	this._error = false;
-	this._finishedReqs = 0;
-	this._localChanges = false;
-	this._remoteChanges = false;
-	this._conflicts = [];
-	this._cachedPercentage;
-	this._cachedPercentageTime;
-}
-
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('name', function () {
-	return this.type + "/" + this.libraryID;
-});
-
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('Type', function () {
-	return this.type[0].toUpperCase() + this.type.substr(1);
-});
-
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('running', function () this._running);
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('stopping', function () this._stopping);
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('finished', function () this._finished);
-
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('unfinishedRequests', function () {
-	return this.totalRequests - this.finishedRequests;
-});
-
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('finishedRequests', function () {
-	return this._finishedReqs;
-});
-
-Zotero.Sync.Storage.Queue.prototype.__defineSetter__('finishedRequests', function (val) {
-	Zotero.debug("Finished requests: " + val);
-	Zotero.debug("Total requests: " + this.totalRequests);
-	
-	this._finishedReqs = val;
-	
-	if (val == 0) {
-		return;
-	}
-	
-	// Last request
-	if (val == this.totalRequests) {
-		Zotero.debug(this.Type + " queue is done for library " + this.libraryID);
-		
-		// DEBUG info
-		Zotero.debug("Active requests: " + this.activeRequests);
-		
-		if (this.activeRequests) {
-			throw new Error(this.Type + " queue for library " + this.libraryID
-				+ " can't be done if there are active requests");
-		}
-		
-		this._running = false;
-		this._stopping = false;
-		this._finished = true;
-		this._requests = {};
-		this._highPriority = [];
-		
-		var localChanges = this._localChanges;
-		var remoteChanges = this._remoteChanges;
-		var conflicts = this._conflicts.concat();
-		var deferred = this._deferred;
-		this._localChanges = false;
-		this._remoteChanges = false;
-		this._conflicts = [];
-		this._deferred = null;
-		
-		if (!this._error) {
-			Zotero.debug("Resolving promise for queue " + this.name);
-			Zotero.debug(this._localChanges);
-			Zotero.debug(this._remoteChanges);
-			Zotero.debug(this._conflicts);
-			
-			deferred.resolve({
-				libraryID: this.libraryID,
-				type: this.type,
-				localChanges: localChanges,
-				remoteChanges: remoteChanges,
-				conflicts: conflicts
-			});
-		}
-		else {
-			Zotero.debug("Rejecting promise for queue " + this.name);
-			var e = this._error;
-			this._error = false;
-			e.libraryID = this.libraryID;
-			e.type = this.type;
-			deferred.reject(e);
-		}
-		
-		return;
-	}
-	
-	if (this._stopping) {
-		return;
-	}
-	this.advance();
-});
-
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('queuedRequests', function () {
-	return this.unfinishedRequests - this.activeRequests;
-});
-
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('remaining', function () {
-	var remaining = 0;
-	for each(var request in this._requests) {
-		remaining += request.remaining;
-	}
-	return remaining;
-});
-
-Zotero.Sync.Storage.Queue.prototype.__defineGetter__('percentage', function () {
-	if (this.totalRequests == 0) {
-		return 0;
-	}
-	if (this._finished) {
-		return 100;
-	}
-	
-	// Cache percentage for a second
-	if (this._cachedPercentage && (new Date() - this._cachedPercentageTime) < 1000) {
-		return this._cachedPercentage;
-	}
-	
-	var completedRequests = 0;
-	for each(var request in this._requests) {
-		completedRequests += request.percentage / 100;
-	}
-	this._cachedPercentage = Math.round((completedRequests / this.totalRequests) * 100);
-	this._cachedPercentageTime = new Date();
-	return this._cachedPercentage;
-});
-
-
-Zotero.Sync.Storage.Queue.prototype.isRunning = function () {
-	return this._running;
-}
-
-Zotero.Sync.Storage.Queue.prototype.isStopping = function () {
-	return this._stopping;
-}
-
-
-/**
- * Add a request to this queue
- *
- * @param {Zotero.Sync.Storage.Request} request
- * @param {Boolean} highPriority  Add or move request to high priority queue
- */
-Zotero.Sync.Storage.Queue.prototype.addRequest = function (request, highPriority) {
-	if (this._finished) {
-		this.reset();
-	}
-	
-	request.queue = this;
-	var name = request.name;
-	Zotero.debug("Queuing " + this.type + " request '" + name + "' for library " + this.libraryID);
-	
-	if (this._requests[name]) {
-		if (highPriority) {
-			Zotero.debug("Moving " + name + " to high-priority queue");
-			this._requests[name].importCallbacks(request);
-			this._highPriority.push(name);
-			return;
-		}
-		
-		Zotero.debug("Request '" + name + "' already exists");
-		return;
-	}
-	
-	this._requests[name] = request;
-	this.totalRequests++;
-	
-	if (highPriority) {
-		this._highPriority.push(name);
-	}
-}
-
-
-Zotero.Sync.Storage.Queue.prototype.start = function () {
-	if (!this._deferred || this._deferred.promise.isFulfilled()) {
-		Zotero.debug("Creating deferred for queue " + this.name);
-		this._deferred = Zotero.Promise.defer();
-	}
-	// The queue manager needs to know what queues were running in the
-	// current session
-	Zotero.Sync.Storage.QueueManager.addCurrentQueue(this);
-	
-	var self = this;
-	setTimeout(function () {
-		self.advance();
-	}, 0);
-	
-	return this._deferred.promise;
-}
-
-
-
-/**
- * Start another request in this queue if there's an available slot
- */
-Zotero.Sync.Storage.Queue.prototype.advance = function () {
-	this._running = true;
-	this._finished = false;
-	
-	if (this._stopping) {
-		Zotero.debug(this.Type + " queue for library " + this.libraryID
-			+ "is being stopped in Zotero.Sync.Storage.Queue.advance()", 2);
-		return;
-	}
-	
-	if (!this.queuedRequests) {
-		Zotero.debug("No remaining requests in " + this.type
-			+ " queue for library " + this.libraryID + " ("
-			+ this.activeRequests + " active, "
-			+ this.finishedRequests + " finished)");
-		return;
-	}
-	
-	if (this.activeRequests >= this.maxConcurrentRequests) {
-		Zotero.debug(this.Type + " queue for library " + this.libraryID
-			+ " is busy (" + this.activeRequests + "/"
-			+ this.maxConcurrentRequests + ")");
-		return;
-	}
-	
-	
-	
-	// Start the first unprocessed request
-	
-	// Try the high-priority queue first
-	var self = this;
-	var request, name;
-	while (name = this._highPriority.shift()) {
-		request = this._requests[name];
-		if (request.isRunning() || request.isFinished()) {
-			continue;
-		}
-		
-		let requestName = name;
-		
-		Zotero.Promise.try(function () {
-			var promise = request.start();
-			self.advance();
-			return promise;
-		})
-		.then(function (result) {
-			if (result.localChanges) {
-				self._localChanges = true;
-			}
-			if (result.remoteChanges) {
-				self._remoteChanges = true;
-			}
-			if (result.conflict) {
-				self.addConflict(
-					requestName,
-					result.conflict.local,
-					result.conflict.remote
-				);
-			}
-		})
-		.catch(function (e) {
-			self.error(e);
-		});
-		
-		return;
-	}
-	
-	// And then others
-	for each(var request in this._requests) {
-		if (request.isRunning() || request.isFinished()) {
-			continue;
-		}
-		
-		let requestName = request.name;
-		
-		// This isn't in a Zotero.Promise.try() because the request needs to get marked
-		// as running immediately so that it doesn't get run again by a
-		// subsequent advance() call.
-		try {
-			var promise = request.start();
-			self.advance();
-		}
-		catch (e) {
-			self.error(e);
-		}
-		
-		promise.then(function (result) {
-			if (result.localChanges) {
-				self._localChanges = true;
-			}
-			if (result.remoteChanges) {
-				self._remoteChanges = true;
-			}
-			if (result.conflict) {
-				self.addConflict(
-					requestName,
-					result.conflict.local,
-					result.conflict.remote
-				);
-			}
-		})
-		.catch(function (e) {
-			self.error(e);
-		});
-		
-		return;
-	}
-}
-
-
-Zotero.Sync.Storage.Queue.prototype.updateProgress = function () {
-	Zotero.Sync.Storage.QueueManager.updateProgress();
-}
-
-
-Zotero.Sync.Storage.Queue.prototype.addConflict = function (requestName, localData, remoteData) {
-	Zotero.debug('===========');
-	Zotero.debug(localData);
-	Zotero.debug(remoteData);
-	
-	this._conflicts.push({
-		name: requestName,
-		localData: localData,
-		remoteData: remoteData
-	});
-}
-
-
-Zotero.Sync.Storage.Queue.prototype.error = function (e) {
-	if (!this._error) {
-		if (this.isRunning()) {
-			this._error = e;
-		}
-		else {
-			Zotero.debug("Queue " + this.name + " was no longer running -- not assigning error", 2);
-		}
-	}
-	Zotero.debug(e, 1);
-	this.stop();
-}
-
-
-/**
- * Stops all requests in this queue
- */
-Zotero.Sync.Storage.Queue.prototype.stop = function () {
-	if (!this._running) {
-		Zotero.debug(this.Type + " queue for library " + this.libraryID
-			+ " is not running");
-		return;
-	}
-	if (this._stopping) {
-		Zotero.debug("Already stopping " + this.type + " queue for library "
-			+ this.libraryID);
-		return;
-	}
-	
-	Zotero.debug("Stopping " + this.type + " queue for library " + this.libraryID);
-	
-	// If no requests, finish manually
-	/*if (this.activeRequests == 0) {
-		this._finishedRequests = this._finishedRequests;
-		return;
-	}*/
-	
-	this._stopping = true;
-	for each(var request in this._requests) {
-		if (!request.isFinished()) {
-			request.stop(true);
-		}
-	}
-	
-	Zotero.debug("Queue is stopped");
-}
-
-
-Zotero.Sync.Storage.Queue.prototype.reset = function () {
-	this._finished = false;
-	this._finishedReqs = 0;
-	this.totalRequests = 0;
-}
diff --git a/chrome/content/zotero/xpcom/storage/queueManager.js b/chrome/content/zotero/xpcom/storage/queueManager.js
deleted file mode 100644
index e338458d6..000000000
--- a/chrome/content/zotero/xpcom/storage/queueManager.js
+++ /dev/null
@@ -1,370 +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.QueueManager = new function () {
-	var _queues = {};
-	var _currentQueues = [];
-	
-	this.start = Zotero.Promise.coroutine(function* (libraryID) {
-		if (libraryID) {
-			var queues = this.getAll(libraryID);
-			var suffix = " for library " + libraryID;
-		}
-		else {
-			var queues = this.getAll();
-			var suffix = "";
-		}
-		
-		Zotero.debug("Starting file sync queues" + suffix);
-		
-		var promises = [];
-		for each(var queue in queues) {
-			if (!queue.unfinishedRequests) {
-				continue;
-			}
-			Zotero.debug("Starting queue " + queue.name);
-			promises.push(queue.start());
-		}
-		
-		if (!promises.length) {
-			Zotero.debug("No files to sync" + suffix);
-		}
-		
-		var results = yield Zotero.Promise.allSettled(promises);
-		Zotero.debug("All storage queues are finished" + suffix);
-		
-		for (let i = 0; i < results.length; i++) {
-			let result = results[i];
-			// Check for conflicts to resolve
-			if (result.state == "fulfilled") {
-				result = result.value;
-				if (result.conflicts.length) {
-					Zotero.debug("Reconciling conflicts for library " + result.libraryID);
-					Zotero.debug(result.conflicts);
-					var data = yield _reconcileConflicts(result.conflicts);
-					if (data) {
-						_processMergeData(data);
-					}
-				}
-			}
-		}
-		
-		return promises;
-	});
-	
-	this.stop = function (libraryID) {
-		if (libraryID) {
-			var queues = this.getAll(libraryID);
-		}
-		else {
-			var queues = this.getAll();
-		}
-		for (var queue in queues) {
-			queue.stop();
-		}
-	};
-	
-	
-	/**
-	 * Retrieving a queue, creating a new one if necessary
-	 *
-	 * @param	{String}		queueName
-	 */
-	this.get = function (queueName, libraryID, noInit) {
-		if (typeof libraryID == 'undefined') {
-			throw new Error("libraryID not specified");
-		}
-		
-		var hash = queueName + "/" + libraryID;
-		
-		// Initialize the queue if it doesn't exist yet
-		if (!_queues[hash]) {
-			if (noInit) {
-				return false;
-			}
-			var queue = new Zotero.Sync.Storage.Queue(queueName, libraryID);
-			switch (queueName) {
-				case 'download':
-					queue.maxConcurrentRequests =
-						Zotero.Prefs.get('sync.storage.maxDownloads')
-					break;
-				
-				case 'upload':
-					queue.maxConcurrentRequests =
-						Zotero.Prefs.get('sync.storage.maxUploads')
-					break;
-				
-				default:
-					throw ("Invalid queue '" + queueName + "' in Zotero.Sync.Storage.QueueManager.get()");
-			}
-			_queues[hash] = queue;
-		}
-		
-		return _queues[hash];
-	};
-	
-	
-	this.getAll = function (libraryID) {
-		if (typeof libraryID == 'string') {
-			throw new Error("libraryID must be a number or undefined");
-		}
-		
-		var queues = [];
-		for each(var queue in _queues) {
-			if (typeof libraryID == 'undefined' || queue.libraryID === libraryID) {
-				queues.push(queue);
-			}
-		}
-		return queues;
-	};
-	
-	
-	this.addCurrentQueue = function (queue) {
-		if (!this.hasCurrentQueue(queue)) {
-			_currentQueues.push(queue.name);
-		}
-	}
-	
-	
-	this.hasCurrentQueue = function (queue) {
-		return _currentQueues.indexOf(queue.name) != -1;
-	}
-	
-	
-	/**
-	 * Stop all queues
-	 *
-	 * @param	{Boolean}	[skipStorageFinish=false]	Don't call Zotero.Sync.Storage.finish()
-	 *													when done (used when we stopped because of
-	 *													an error)
-	 */
-	this.cancel = function (skipStorageFinish) {
-		Zotero.debug("Stopping all storage queues");
-		for each(var queue in _queues) {
-			if (queue.isRunning() && !queue.isStopping()) {
-				queue.stop();
-			}
-		}
-	}
-	
-	
-	this.finish = function () {
-		Zotero.debug("All storage queues are finished");
-		_currentQueues = [];
-	}
-	
-	
-	/**
-	 * Calculate the current progress values and trigger a display update
-	 *
-	 * Also detects when all queues have finished and ends sync progress
-	 */
-	this.updateProgress = function () {
-		var activeRequests = 0;
-		var allFinished = true;
-		for each(var queue in _queues) {
-			// Finished or never started
-			if (!queue.isRunning() && !queue.isStopping()) {
-				continue;
-			}
-			allFinished = false;
-			activeRequests += queue.activeRequests;
-		}
-		if (activeRequests == 0) {
-			_updateProgressMeters(0);
-			if (allFinished) {
-				this.finish();
-			}
-			return;
-		}
-		
-		var status = {};
-		for each(var queue in _queues) {
-			if (!this.hasCurrentQueue(queue)) {
-				continue;
-			}
-			
-			if (!status[queue.libraryID]) {
-				status[queue.libraryID] = {};
-			}
-			if (!status[queue.libraryID][queue.type]) {
-				status[queue.libraryID][queue.type] = {};
-			}
-			status[queue.libraryID][queue.type].statusString = _getQueueStatus(queue);
-			status[queue.libraryID][queue.type].percentage = queue.percentage;
-			status[queue.libraryID][queue.type].totalRequests = queue.totalRequests;
-			status[queue.libraryID][queue.type].finished = queue.finished;
-		}
-		
-		_updateProgressMeters(activeRequests, status);
-	}
-	
-	
-	/**
-	 * Get a status string for a queue
-	 *
-	 * @param	{Zotero.Sync.Storage.Queue}		queue
-	 * @return	{String}
-	 */
-	function _getQueueStatus(queue) {
-		var remaining = queue.remaining;
-		var unfinishedRequests = queue.unfinishedRequests;
-		
-		if (!unfinishedRequests) {
-			return Zotero.getString('sync.storage.none');
-		}
-		
-		if (remaining > 1000) {
-			var bytesRemaining = Zotero.getString(
-				'sync.storage.mbRemaining',
-				Zotero.Utilities.numberFormat(remaining / 1000 / 1000, 1)
-			);
-		}
-		else {
-			var bytesRemaining = Zotero.getString(
-				'sync.storage.kbRemaining',
-				Zotero.Utilities.numberFormat(remaining / 1000, 0)
-			);
-		}
-		var totalRequests = queue.totalRequests;
-		var filesRemaining = Zotero.getString(
-			'sync.storage.filesRemaining',
-			[totalRequests - unfinishedRequests, totalRequests]
-		);
-		return bytesRemaining + ' (' + filesRemaining + ')';
-	}
-	
-	/**
-	 * Cycle through windows, updating progress meters with new values
-	 */
-	function _updateProgressMeters(activeRequests, status) {
-		// Get overall percentage across queues
-		var sum = 0, num = 0, percentage, total;
-		for each(var libraryStatus in status) {
-			for each(var queueStatus in libraryStatus) {
-				percentage = queueStatus.percentage;
-				total = queueStatus.totalRequests;
-				sum += total * percentage;
-				num += total;
-			}
-		}
-		var percentage = Math.round(sum / num);
-		
-		var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
-					.getService(Components.interfaces.nsIWindowMediator);
-		var enumerator = wm.getEnumerator("navigator:browser");
-		while (enumerator.hasMoreElements()) {
-			var win = enumerator.getNext();
-			if (!win.ZoteroPane) continue;
-			var doc = win.ZoteroPane.document;
-			
-			var box = doc.getElementById("zotero-tb-sync-progress-box");
-			var meter = doc.getElementById("zotero-tb-sync-progress");
-			
-			if (activeRequests == 0) {
-				box.hidden = true;
-				continue;
-			}
-			
-			meter.setAttribute("value", percentage);
-			box.hidden = false;
-			
-			var percentageLabel = doc.getElementById('zotero-tb-sync-progress-tooltip-progress');
-			percentageLabel.lastChild.setAttribute('value', percentage + "%");
-			
-			var statusBox = doc.getElementById('zotero-tb-sync-progress-status');
-			statusBox.data = status;
-		}
-	}
-	
-	
-	var _reconcileConflicts = Zotero.Promise.coroutine(function* (conflicts) {
-		var objectPairs = [];
-		for each(var conflict in conflicts) {
-			var item = Zotero.Sync.Storage.getItemFromRequestName(conflict.name);
-			var item1 = yield item.clone(false, false, true);
-			item1.setField('dateModified',
-				Zotero.Date.dateToSQL(new Date(conflict.localData.modTime), true));
-			var item2 = yield item.clone(false, false, true);
-			item2.setField('dateModified',
-				Zotero.Date.dateToSQL(new Date(conflict.remoteData.modTime), true));
-			objectPairs.push([item1, item2]);
-		}
-		
-		var io = {
-			dataIn: {
-				type: 'storagefile',
-				captions: [
-					Zotero.getString('sync.storage.localFile'),
-					Zotero.getString('sync.storage.remoteFile'),
-					Zotero.getString('sync.storage.savedFile')
-				],
-				objects: objectPairs
-			}
-		};
-		
-		var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
-				   .getService(Components.interfaces.nsIWindowMediator);
-		var lastWin = wm.getMostRecentWindow("navigator:browser");
-		lastWin.openDialog('chrome://zotero/content/merge.xul', '', 'chrome,modal,centerscreen', io);
-		
-		if (!io.dataOut) {
-			return false;
-		}
-		
-		// Since we're only putting cloned items into the merge window,
-		// we have to manually set the ids
-		for (var i=0; i<conflicts.length; i++) {
-			io.dataOut[i].id = Zotero.Sync.Storage.getItemFromRequestName(conflicts[i].name).id;
-		}
-		
-		return io.dataOut;
-	});
-	
-	
-	function _processMergeData(data) {
-		if (!data.length) {
-			return false;
-		}
-		
-		for each(var mergeItem in data) {
-			var itemID = mergeItem.id;
-			var dateModified = mergeItem.ref.getField('dateModified');
-			// Local
-			if (dateModified == mergeItem.left.getField('dateModified')) {
-				Zotero.Sync.Storage.setSyncState(
-					itemID, Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD
-				);
-			}
-			// Remote
-			else {
-				Zotero.Sync.Storage.setSyncState(
-					itemID, Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD
-				);
-			}
-		}
-	}
-}
diff --git a/chrome/content/zotero/xpcom/storage/storageEngine.js b/chrome/content/zotero/xpcom/storage/storageEngine.js
new file mode 100644
index 000000000..70c5dc217
--- /dev/null
+++ b/chrome/content/zotero/xpcom/storage/storageEngine.js
@@ -0,0 +1,307 @@
+/*
+    ***** BEGIN LICENSE BLOCK *****
+    
+    Copyright © 2015 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 *****
+*/
+
+
+if (!Zotero.Sync.Storage) {
+	Zotero.Sync.Storage = {};
+}
+
+/**
+ * An Engine manages file sync processes for a given library
+ *
+ * @param {Object} options
+ * @param {Zotero.Sync.APIClient} options.apiClient
+ * @param {Integer} options.libraryID
+ * @param {Function} [onError] - Function to run on error
+ * @param {Boolean} [stopOnError]
+ */
+Zotero.Sync.Storage.Engine = function (options) {
+	if (options.apiClient == undefined) {
+		throw new Error("options.apiClient not set");
+	}
+	if (options.libraryID == undefined) {
+		throw new Error("options.libraryID not set");
+	}
+	
+	this.apiClient = options.apiClient;
+	this.background = options.background;
+	this.firstInSession = options.firstInSession;
+	this.lastFullFileCheck = options.lastFullFileCheck;
+	this.libraryID = options.libraryID;
+	this.library = Zotero.Libraries.get(options.libraryID);
+	
+	this.local = Zotero.Sync.Storage.Local;
+	this.utils = Zotero.Sync.Storage.Utilities;
+	this.mode = this.local.getModeForLibrary(this.libraryID);
+	var modeClass = this.utils.getClassForMode(this.mode);
+	this.controller = new modeClass(options);
+	this.setStatus = options.setStatus || function () {};
+	this.onError = options.onError || function (e) {};
+	this.stopOnError = options.stopOnError || false;
+	
+	this.queues = [];
+	['download', 'upload'].forEach(function (type) {
+		this.queues[type] = new ConcurrentCaller({
+			id: `${this.libraryID}/${type}`,
+			numConcurrent: Zotero.Prefs.get(
+				'sync.storage.max' + Zotero.Utilities.capitalize(type) + 's'
+			),
+			onError: this.onError,
+			stopOnError: this.stopOnError,
+			logger: Zotero.debug
+		});
+	}.bind(this))
+	
+	this.maxCheckAge = 10800; // maximum age in seconds for upload modification check (3 hours)
+}
+
+Zotero.Sync.Storage.Engine.prototype.start = Zotero.Promise.coroutine(function* () {
+	var libraryID = this.libraryID;
+	if (!Zotero.Prefs.get("sync.storage.enabled")) {
+		Zotero.debug("File sync is not enabled for " + this.library.name);
+		return false;
+	}
+	
+	Zotero.debug("Starting file sync for " + this.library.name);
+	
+	if (!this.controller.verified) {
+		Zotero.debug(`${this.mode} file sync is not active`);
+		
+		throw new Error("Storage mode verification not implemented");
+		
+		// TODO: Check server
+	}
+	if (this.controller.cacheCredentials) {
+		yield this.controller.cacheCredentials();
+	}
+	
+	// Get library last-sync time for download-on-sync libraries.
+	var lastSyncTime = null;
+	var downloadAll = this.local.downloadOnSync(libraryID);
+	if (downloadAll) {
+		lastSyncTime = yield this.controller.getLastSyncTime(libraryID);
+	}
+	
+	// Check for updated files to upload
+	if (!Zotero.Libraries.isFilesEditable(libraryID)) {
+		Zotero.debug("No file editing access -- skipping file modification check for "
+			+ this.library.name);
+	}
+	// If this is a background sync, it's not the first sync of the session, the library has had
+	// at least one full check this session, and it's been less than maxCheckAge since the last
+	// full check of this library, check only files that were previously modified or opened
+	// recently
+	else if (this.background
+			&& !this.firstInSession
+			&& this.local.lastFullFileCheck[libraryID]
+			&& (this.local.lastFullFileCheck[libraryID]
+				+ (this.maxCheckAge * 1000)) > new Date().getTime()) {
+		let itemIDs = this.local.getFilesToCheck(libraryID, this.maxCheckAge);
+		yield this.local.checkForUpdatedFiles(libraryID, itemIDs);
+	}
+	// Otherwise check all files in library
+	else {
+		this.local.lastFullFileCheck[libraryID] = new Date().getTime();
+		yield this.local.checkForUpdatedFiles(libraryID);
+	}
+	
+	yield this.local.resolveConflicts(libraryID);
+	
+	var downloadForced = yield this.local.checkForForcedDownloads(libraryID);
+	
+	// If we don't have any forced downloads, we can skip downloads if the last sync time hasn't
+	// changed or doesn't exist on the server (meaning there are no files)
+	if (downloadAll && !downloadForced) {
+		if (lastSyncTime) {
+			if (this.library.lastStorageSync == lastSyncTime) {
+				Zotero.debug("Last " + this.mode.toUpperCase() + " sync id hasn't changed for "
+					+ this.library.name + " -- skipping file downloads");
+				downloadAll = false;
+			}
+		}
+		else {
+			Zotero.debug(`No last ${this.mode} sync time for ${this.library.name}`
+				+ " -- skipping file downloads");
+			downloadAll = false;
+		}
+	}
+	
+	// Get files to download
+	if (downloadAll || downloadForced) {
+		let itemIDs = yield this.local.getFilesToDownload(libraryID, !downloadAll);
+		if (itemIDs.length) {
+			Zotero.debug(itemIDs.length + " file" + (itemIDs.length == 1 ? '' : 's') + " to "
+				+ "download for " + this.library.name);
+			for (let itemID of itemIDs) {
+				let item = yield Zotero.Items.getAsync(itemID);
+				yield this.queueItem(item);
+			}
+		}
+		else {
+			Zotero.debug("No files to download for " + this.library.name);
+		}
+	}
+	
+	// Get files to upload
+	if (Zotero.Libraries.isFilesEditable(libraryID)) {
+		let itemIDs = yield this.local.getFilesToUpload(libraryID);
+		if (itemIDs.length) {
+			Zotero.debug(itemIDs.length + " file" + (itemIDs.length == 1 ? '' : 's') + " to "
+				+ "upload for " + this.library.name);
+			for (let itemID of itemIDs) {
+				let item = yield Zotero.Items.getAsync(itemID, { noCache: true });
+				yield this.queueItem(item);
+			}
+		}
+		else {
+			Zotero.debug("No files to upload for " + this.library.name);
+		}
+	}
+	else {
+		Zotero.debug("No file editing access -- skipping file uploads for " + this.library.name);
+	}
+	
+	var promises = {
+		download: this.queues.download.runAll(),
+		upload: this.queues.upload.runAll()
+	}
+	
+	// Process the results
+	var changes = new Zotero.Sync.Storage.Result;
+	for (let type of ['download', 'upload']) {
+		let results = yield promises[type];
+		
+		if (this.stopOnError) {
+			for (let p of results) {
+				if (p.isRejected()) {
+					let e = p.reason();
+					Zotero.debug(`File ${type} sync failed for ${this.library.name}`);
+					throw e;
+				}
+			}
+		}
+		
+		Zotero.debug(`File ${type} sync finished for ${this.library.name}`);
+		
+		changes.updateFromResults(results.filter(p => p.isFulfilled()).map(p => p.value()));
+	}
+	
+	// If files were uploaded, update the remote last-sync time
+	if (changes.remoteChanges) {
+		lastSyncTime = yield this.controller.setLastSyncTime(libraryID);
+		if (!lastSyncTime) {
+			throw new Error("Last sync time not set after sync");
+		}
+	}
+	
+	// If there's a remote last-sync time from either the check before downloads or when it
+	// was changed after uploads, store that locally so we know we can skip download checks
+	// next time
+	if (lastSyncTime) {
+		this.library.lastStorageSync = lastSyncTime;
+		yield this.library.saveTx();
+	}
+	
+	// If WebDAV sync, purge deleted and orphaned files
+	if (this.mode == 'webdav') {
+		try {
+			yield this.controller.purgeDeletedStorageFiles(libraryID);
+			yield this.controller.purgeOrphanedStorageFiles(libraryID);
+		}
+		catch (e) {
+			Zotero.logError(e);
+		}
+	}
+	
+	if (!changes.localChanges) {
+		Zotero.debug("No local changes made during file sync");
+	}
+	
+	Zotero.debug("Done with file sync for " + this.library.name);
+	
+	return changes;
+})
+
+
+Zotero.Sync.Storage.Engine.prototype.stop = function () {
+	for (let type in this.queues) {
+		this.queues[type].stop();
+	}
+}
+
+Zotero.Sync.Storage.Engine.prototype.queueItem = Zotero.Promise.coroutine(function* (item) {
+	switch (yield this.local.getSyncState(item.id)) {
+		case Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD:
+		case Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD:
+			var type = 'download';
+			var onStart = Zotero.Promise.method(function (request) {
+				return this.controller.downloadFile(request);
+			}.bind(this));
+			break;
+		
+		case Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD:
+		case Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD:
+			var type = 'upload';
+			var onStart = Zotero.Promise.method(function (request) {
+				return this.controller.uploadFile(request);
+			}.bind(this));
+			break;
+		
+		case false:
+			Zotero.debug("Sync state for item " + item.id + " not found", 2);
+			return;
+		
+		default:
+			throw new Error("Invalid sync state " + (yield this.local.getSyncState(item.id)));
+	}
+	
+	var request = new Zotero.Sync.Storage.Request({
+		type,
+		libraryID: this.libraryID,
+		name: item.libraryKey,
+		onStart,
+		onProgress: this.onProgress
+	});
+	if (type == 'upload') {
+		try {
+			request.setMaxSize(yield Zotero.Attachments.getTotalFileSize(item));
+		}
+		// If this fails, ignore it, though we might fail later
+		catch (e) {
+			// But if the file doesn't exist yet, don't try to upload it
+			//
+			// This isn't a perfect test, because the file could still be in the process of being
+			// downloaded (e.g., from the web). It'd be better to download files to a temp
+			// directory and move them into place.
+			if (!(yield item.getFilePathAsync())) {
+				Zotero.debug("File " + item.libraryKey + " not yet available to upload -- skipping");
+				return;
+			}
+			
+			Zotero.logError(e);
+		}
+	}
+	this.queues[type].add(request.start.bind(request));
+})
diff --git a/chrome/content/zotero/xpcom/storage/storageLocal.js b/chrome/content/zotero/xpcom/storage/storageLocal.js
new file mode 100644
index 000000000..ea8a13c0e
--- /dev/null
+++ b/chrome/content/zotero/xpcom/storage/storageLocal.js
@@ -0,0 +1,1088 @@
+Zotero.Sync.Storage.Local = {
+	lastFullFileCheck: {},
+	uploadCheckFiles: [],
+	
+	getClassForLibrary: function (libraryID) {
+		return Zotero.Sync.Storage.Utilities.getClassForMode(this.getModeForLibrary(libraryID));
+	},
+	
+	getModeForLibrary: function (libraryID) {
+		var libraryType = Zotero.Libraries.getType(libraryID);
+		switch (libraryType) {
+		case 'user':
+		case 'publications':
+			return Zotero.Prefs.get("sync.storage.protocol") == 'webdav' ? 'webdav' : 'zfs';
+		
+		case 'group':
+			return 'zfs';
+		
+		default:
+			throw new Error(`Unexpected library type '${libraryType}'`);
+		}
+	},
+	
+	setModeForLibrary: function (libraryID, mode) {
+		var libraryType = Zotero.Libraries.getType(libraryID);
+		
+		if (libraryType != 'user') {
+			throw new Error(`Cannot set storage mode for ${libraryType} library`);
+		}
+		
+		switch (mode) {
+		case 'webdav':
+		case 'zfs':
+			Zotero.Prefs.set("sync.storage.protocol", mode);
+			break;
+		
+		default:
+			throw new Error(`Unexpected storage mode '${mode}'`);
+		}
+	},
+	
+	/**
+	 * Check or enable download-as-needed mode
+	 *
+	 * @param {Integer} [libraryID]
+	 * @param {Boolean} [enable] - If true, enable download-as-needed mode for the given library
+	 * @return {Boolean|undefined} - If 'enable' isn't set to true, return true if
+	 *     download-as-needed mode enabled and false if not
+	 */
+	downloadAsNeeded: function (libraryID, enable) {
+		var pref = this._getDownloadPrefFromLibrary(libraryID);
+		var val = 'on-demand';
+		if (enable) {
+			Zotero.Prefs.set(pref, val);
+			return;
+		}
+		return Zotero.Prefs.get(pref) == val;
+	},
+	
+	/**
+	 * Check or enable download-on-sync mode
+	 *
+	 * @param {Integer} [libraryID]
+	 * @param {Boolean} [enable] - If true, enable download-on-demand mode for the given library
+	 * @return {Boolean|undefined} - If 'enable' isn't set to true, return true if
+	 *     download-as-needed mode enabled and false if not
+	 */
+	downloadOnSync: function (libraryID, enable) {
+		var pref = this._getDownloadPrefFromLibrary(libraryID);
+		var val = 'on-demand';
+		if (enable) {
+			Zotero.Prefs.set(pref, val);
+			return;
+		}
+		return Zotero.Prefs.get(pref) == val;
+	},
+	
+	_getDownloadPrefFromLibrary: function (libraryID) {
+		if (libraryID == Zotero.Libraries.userLibraryID) {
+			return 'sync.storage.downloadMode.personal';
+		}
+		// TODO: Library-specific settings
+		
+		// Group library
+		return 'sync.storage.downloadMode.groups';
+	},
+	
+	/**
+	 * Get files to check for local modifications for uploading
+	 *
+	 * This includes files previously modified or opened externally via Zotero within maxCheckAge
+	 */
+	getFilesToCheck: Zotero.Promise.coroutine(function* (libraryID, maxCheckAge) {
+		var minTime = new Date().getTime() - (maxCheckAge * 1000);
+		
+		// Get files modified and synced since maxCheckAge
+		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
+			+ "WHERE libraryID=? AND linkMode IN (?,?) AND syncState IN (?) AND "
+			+ "storageModTime>=?";
+		var params = [
+			libraryID,
+			Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
+			Zotero.Attachments.LINK_MODE_IMPORTED_URL,
+			Zotero.Sync.Storage.SYNC_STATE_IN_SYNC,
+			minTime
+		];
+		var itemIDs = yield Zotero.DB.columnQueryAsync(sql, params);
+		
+		// Get files opened since maxCheckAge
+		itemIDs = itemIDs.concat(
+			this.uploadCheckFiles.filter(x => x.timestamp >= minTime).map(x => x.itemID)
+		);
+		
+		return Zotero.Utilities.arrayUnique(itemIDs);
+	}),
+	
+	
+	/**
+	 * Scans local files and marks any that have changed for uploading
+	 * and any that are missing for downloading
+	 *
+	 * @param {Integer} libraryID
+	 * @param {Integer[]} [itemIDs]
+	 * @param {Object} [itemModTimes]  Item mod times indexed by item ids;
+	 *                                 items with stored mod times
+	 *                                 that differ from the provided
+	 *                                 time but file mod times
+	 *                                 matching the stored time will
+	 *                                 be marked for download
+	 * @return {Promise} Promise resolving to TRUE if any items changed state,
+	 *                   FALSE otherwise
+	 */
+	checkForUpdatedFiles: Zotero.Promise.coroutine(function* (libraryID, itemIDs, itemModTimes) {
+		var libraryName = Zotero.Libraries.getName(libraryID);
+		var msg = "Checking for locally changed attachment files in " + libraryName;
+		
+		var memmgr = Components.classes["@mozilla.org/memory-reporter-manager;1"]
+			.getService(Components.interfaces.nsIMemoryReporterManager);
+		memmgr.init();
+		//Zotero.debug("Memory usage: " + memmgr.resident);
+		
+		if (itemIDs) {
+			if (!itemIDs.length) {
+				Zotero.debug("No files to check for local changes");
+				return false;
+			}
+		}
+		if (itemModTimes) {
+			if (!Object.keys(itemModTimes).length) {
+				return false;
+			}
+			msg += " in download-marking mode";
+		}
+		
+		Zotero.debug(msg);
+		
+		var changed = false;
+		
+		if (!itemIDs) {
+			itemIDs = Object.keys(itemModTimes ? itemModTimes : {});
+		}
+		
+		// Can only handle a certain number of bound parameters at a time
+		var numIDs = itemIDs.length;
+		var maxIDs = Zotero.DB.MAX_BOUND_PARAMETERS - 10;
+		var done = 0;
+		var rows = [];
+		
+		do {
+			let chunk = itemIDs.splice(0, maxIDs);
+			let sql = "SELECT itemID, linkMode, path, storageModTime, storageHash, syncState "
+						+ "FROM itemAttachments JOIN items USING (itemID) "
+						+ "WHERE linkMode IN (?,?) AND syncState IN (?,?)";
+			let params = [
+				Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
+				Zotero.Attachments.LINK_MODE_IMPORTED_URL,
+				Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD,
+				Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+			];
+			if (libraryID !== false) {
+				sql += " AND libraryID=?";
+				params.push(libraryID);
+			}
+			if (chunk.length) {
+				sql += " AND itemID IN (" + chunk.map(() => '?').join() + ")";
+				params = params.concat(chunk);
+			}
+			let chunkRows = yield Zotero.DB.queryAsync(sql, params);
+			if (chunkRows) {
+				rows = rows.concat(chunkRows);
+			}
+			done += chunk.length;
+		}
+		while (done < numIDs);
+		
+		// If no files, or everything is already marked for download,
+		// we don't need to do anything
+		if (!rows.length) {
+			Zotero.debug("No in-sync or to-upload files found in " + libraryName);
+			return false;
+		}
+		
+		// Index attachment data by item id
+		itemIDs = [];
+		var attachmentData = {};
+		for (let row of rows) {
+			var id = row.itemID;
+			itemIDs.push(id);
+			attachmentData[id] = {
+				linkMode: row.linkMode,
+				path: row.path,
+				mtime: row.storageModTime,
+				hash: row.storageHash,
+				state: row.syncState
+			};
+		}
+		rows = null;
+		
+		var t = new Date();
+		var items = yield Zotero.Items.getAsync(itemIDs, { noCache: true });
+		var numItems = items.length;
+		var updatedStates = {};
+		
+		//Zotero.debug("Memory usage: " + memmgr.resident);
+		
+		var changed = false;
+		for (let i = 0; i < items.length; i++) {
+			let item = items[i];
+			// TODO: Catch error?
+			let state = yield this._checkForUpdatedFile(item, attachmentData[item.id]);
+			if (state !== false) {
+				yield Zotero.Sync.Storage.Local.setSyncState(item.id, state);
+				changed = true;
+			}
+		}
+		
+		if (!items.length) {
+			Zotero.debug("No synced files have changed locally");
+		}
+		
+		Zotero.debug(`Checked ${numItems} files in ${libraryName} in ` + (new Date() - t) + " ms");
+		
+		return changed;
+	}),
+	
+	
+	_checkForUpdatedFile: Zotero.Promise.coroutine(function* (item, attachmentData, remoteModTime) {
+		var lk = item.libraryKey;
+		Zotero.debug("Checking attachment file for item " + lk, 4);
+		
+		var path = item.getFilePath();
+		if (!path) {
+			Zotero.debug("Marking pathless attachment " + lk + " as in-sync");
+			return Zotero.Sync.Storage.SYNC_STATE_IN_SYNC;
+		}
+		var fileName = OS.Path.basename(path);
+		var file;
+		
+		try {
+			file = yield OS.File.open(path);
+			let info = yield file.stat();
+			//Zotero.debug("Memory usage: " + memmgr.resident);
+			
+			let fmtime = info.lastModificationDate.getTime();
+			//Zotero.debug("File modification time for item " + lk + " is " + fmtime);
+			
+			if (fmtime < 0) {
+				Zotero.debug("File mod time " + fmtime + " is less than 0 -- interpreting as 0", 2);
+				fmtime = 0;
+			}
+			
+			// If file is already marked for upload, skip check. Even if the file was changed
+			// both locally and remotely, conflicts are checked at upload time, so we don't need
+			// to worry about it here.
+			if ((yield this.getSyncState(item.id)) == Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD) {
+				Zotero.debug("File is already marked for upload");
+				return false;
+			}
+			
+			//Zotero.debug("Stored mtime is " + attachmentData.mtime);
+			//Zotero.debug("File mtime is " + fmtime);
+			
+			//BAIL AFTER DOWNLOAD MARKING MODE, OR CHECK LOCAL?
+			let mtime = attachmentData ? attachmentData.mtime : false;
+			
+			// Download-marking mode
+			if (remoteModTime) {
+				Zotero.debug(`Remote mod time for item ${lk} is ${remoteModTime}`);
+				
+				// Ignore attachments whose stored mod times haven't changed
+				mtime = mtime !== false ? mtime : (yield this.getSyncedModificationTime(item.id));
+				if (mtime == remoteModTime) {
+					Zotero.debug(`Synced mod time (${mtime}) hasn't changed for item ${lk}`);
+					return false;
+				}
+				
+				Zotero.debug(`Marking attachment ${lk} for download (stored mtime: ${mtime})`);
+				// DEBUG: Always set here, or allow further steps?
+				return Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD;
+			}
+			
+			var same = !this.checkFileModTime(item, fmtime, mtime);
+			if (same) {
+				Zotero.debug("File has not changed");
+				return false;
+			}
+			
+			// If file hash matches stored hash, only the mod time changed, so skip
+			let fileHash = yield Zotero.Utilities.Internal.md5Async(file);
+			
+			var hash = attachmentData ? attachmentData.hash : (yield this.getSyncedHash(item.id));
+			if (hash && hash == fileHash) {
+				// We have to close the file before modifying it from the main
+				// thread (at least on Windows, where assigning lastModifiedTime
+				// throws an NS_ERROR_FILE_IS_LOCKED otherwise)
+				yield file.close();
+				
+				Zotero.debug("Mod time didn't match (" + fmtime + " != " + mtime + ") "
+					+ "but hash did for " + fileName + " for item " + lk
+					+ " -- updating file mod time");
+				try {
+					yield OS.File.setDates(path, null, mtime);
+				}
+				catch (e) {
+					Zotero.File.checkPathAccessError(e, path, 'update');
+				}
+				return false;
+			}
+			
+			// Mark file for upload
+			Zotero.debug("Marking attachment " + lk + " as changed "
+				+ "(" + mtime + " != " + fmtime + ")");
+			return Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD;
+		}
+		catch (e) {
+			if (e instanceof OS.File.Error &&
+					(e.becauseNoSuchFile
+					// This can happen if a path is too long on Windows,
+					// e.g. a file is being accessed on a VM through a share
+					// (and probably in other cases).
+					|| (e.winLastError && e.winLastError == 3)
+					// Handle long filenames on OS X/Linux
+					|| (e.unixErrno && e.unixErrno == 63))) {
+				Zotero.debug("Marking attachment " + lk + " as missing");
+				return Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD;
+			}
+			
+			if (e instanceof OS.File.Error) {
+				if (e.becauseClosed) {
+					Zotero.debug("File was closed", 2);
+				}
+				Zotero.debug(e);
+				Zotero.debug(e.toString());
+				throw new Error(`Error for operation '${e.operation}' for ${path}`);
+			}
+			
+			throw e;
+		}
+		finally {
+			if (file) {
+				//Zotero.debug("Closing file for item " + lk);
+				file.close();
+			}
+		}
+	}),
+	
+	/**
+	 *
+	 * @param {Zotero.Item} item
+	 * @param {Integer} fmtime - File modification time in milliseconds
+	 * @param {Integer} mtime - Remote modification time in milliseconds
+	 * @return {Boolean} - True if file modification time differs from remote mod time,
+	 *                     false otherwise
+	 */
+	checkFileModTime(item, fmtime, mtime) {
+		var libraryKey = item.libraryKey;
+		
+		if (fmtime == mtime) {
+			Zotero.debug(`Mod time for ${libraryKey} matches remote file -- skipping`);
+		}
+		// Compare floored timestamps for filesystems that don't support millisecond
+		// precision (e.g., HFS+)
+		else if (Math.floor(mtime / 1000) * 1000 == fmtime
+				|| Math.floor(fmtime / 1000) * 1000 == mtime) {
+			Zotero.debug(`File mod times for ${libraryKey} are within one-second precision `
+				+ "(" + fmtime + " ≅ " + mtime + ") -- skipping");
+		}
+		// Allow timestamp to be exactly one hour off to get around time zone issues
+		// -- there may be a proper way to fix this
+		else if (Math.abs(fmtime - mtime) == 3600000
+				// And check with one-second precision as well
+				|| Math.abs(fmtime - Math.floor(mtime / 1000) * 1000) == 3600000
+				|| Math.abs(Math.floor(fmtime / 1000) * 1000 - mtime) == 3600000) {
+			Zotero.debug(`File mod time (${fmtime}) for {$libraryKey} is exactly one hour off `
+				+ `remote file (${mtime}) -- assuming time zone issue and skipping`);
+		}
+		else {
+			return true;
+		}
+		
+		return false;
+	},
+	
+	checkForForcedDownloads: Zotero.Promise.coroutine(function* (libraryID) {
+		// Forced downloads happen even in on-demand mode
+		var sql = "SELECT COUNT(*) FROM items JOIN itemAttachments USING (itemID) "
+			+ "WHERE libraryID=? AND syncState=?";
+		return !!(yield Zotero.DB.valueQueryAsync(
+			sql, [libraryID, Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD]
+		));
+	}),
+	
+	
+	/**
+	 * Get files marked as ready to download
+	 *
+	 * @param {Integer} libraryID
+	 * @return {Promise<Number[]>} - Promise for an array of attachment itemIDs
+	 */
+	getFilesToDownload: function (libraryID, forcedOnly) {
+		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
+					+ "WHERE libraryID=? AND syncState IN (?";
+		var params = [libraryID, Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD];
+		if (!forcedOnly) {
+			sql += ",?";
+			params.push(Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD);
+		}
+		sql += ") "
+			// Skip attachments with empty path, which can't be saved, and files with .zotero*
+			// paths, which have somehow ended up in some users' libraries
+			+ "AND path!='' AND path NOT LIKE 'storage:.zotero%'";
+		return Zotero.DB.columnQueryAsync(sql, params);
+	},
+	
+	
+	/**
+	 * Get files marked as ready to upload
+	 *
+	 * @param {Integer} libraryID
+	 * @return {Promise<Number[]>} - Promise for an array of attachment itemIDs
+	 */
+	getFilesToUpload: function (libraryID) {
+		var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) "
+			+ "WHERE libraryID=? AND syncState IN (?,?) AND linkMode IN (?,?)";
+		var params = [
+			libraryID,
+			Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD,
+			Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD,
+			Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
+			Zotero.Attachments.LINK_MODE_IMPORTED_URL
+		];
+		return Zotero.DB.columnQueryAsync(sql, params);
+	},
+	
+	
+	/**
+	 * @param {Integer} libraryID
+	 * @return {Promise<String[]>} - Promise for an array of item keys
+	 */
+	getDeletedFiles: function (libraryID) {
+		var sql = "SELECT key FROM storageDeleteLog WHERE libraryID=?";
+		return Zotero.DB.columnQueryAsync(sql, libraryID);
+	},
+	
+	
+	/**
+	 * @param	{Integer}		itemID
+	 */
+	getSyncState: function (itemID) {
+		var sql = "SELECT syncState FROM itemAttachments WHERE itemID=?";
+		return Zotero.DB.valueQueryAsync(sql, itemID);
+	},
+	
+	
+	/**
+	 * @param	{Integer}		itemID
+	 * @param	{Integer}		syncState		Constant from Zotero.Sync.Storage
+	 */
+	setSyncState: Zotero.Promise.method(function (itemID, syncState) {
+		switch (syncState) {
+			case Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD:
+			case Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD:
+			case Zotero.Sync.Storage.SYNC_STATE_IN_SYNC:
+			case Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD:
+			case Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD:
+			case Zotero.Sync.Storage.SYNC_STATE_IN_CONFLICT:
+				break;
+			
+			default:
+				throw new Error("Invalid sync state " + syncState);
+		}
+		
+		var sql = "UPDATE itemAttachments SET syncState=? WHERE itemID=?";
+		return Zotero.DB.valueQueryAsync(sql, [syncState, itemID]);
+	}),
+	
+	
+	/**
+	 * @param	{Integer}			itemID
+	 * @return	{Integer|NULL}					Mod time as timestamp in ms,
+	 *												or NULL if never synced
+	 */
+	getSyncedModificationTime: Zotero.Promise.coroutine(function* (itemID) {
+		var sql = "SELECT storageModTime FROM itemAttachments WHERE itemID=?";
+		var mtime = yield Zotero.DB.valueQueryAsync(sql, itemID);
+		if (mtime === false) {
+			throw new Error("Item " + itemID + " not found")
+		}
+		return mtime;
+	}),
+	
+	
+	/**
+	 * @param {Integer} itemID
+	 * @param {Integer} mtime - File modification time as timestamp in ms
+	 * @param {Boolean} [updateItem=FALSE] - Update clientDateModified field of attachment item
+	 */
+	setSyncedModificationTime: Zotero.Promise.coroutine(function* (itemID, mtime, updateItem) {
+		if (mtime < 0) {
+			Components.utils.reportError("Invalid file mod time " + mtime
+				+ " in Zotero.Storage.setSyncedModificationTime()");
+			mtime = 0;
+		}
+		
+		Zotero.DB.requireTransaction();
+		
+		var sql = "UPDATE itemAttachments SET storageModTime=? WHERE itemID=?";
+		yield Zotero.DB.queryAsync(sql, [mtime, itemID]);
+		
+		if (updateItem) {
+			// Update item date modified so the new mod time will be synced
+			let sql = "UPDATE items SET clientDateModified=? WHERE itemID=?";
+			yield Zotero.DB.queryAsync(sql, [Zotero.DB.transactionDateTime, itemID]);
+		}
+	}),
+	
+	
+	/**
+	 * @param {Integer} itemID
+	 * @return {Promise<String|null|false>} - File hash, null if never synced, if false if
+	 *     file doesn't exist
+	 */
+	getSyncedHash: Zotero.Promise.coroutine(function* (itemID) {
+		var sql = "SELECT storageHash FROM itemAttachments WHERE itemID=?";
+		var hash = yield Zotero.DB.valueQueryAsync(sql, itemID);
+		if (hash === false) {
+			throw new Error("Item " + itemID + " not found");
+		}
+		return hash;
+	}),
+	
+	
+	/**
+	 * @param	{Integer}	itemID
+	 * @param	{String}	hash				File hash
+	 * @param	{Boolean}	[updateItem=FALSE]	Update dateModified field of
+	 *												attachment item
+	 */
+	setSyncedHash: Zotero.Promise.coroutine(function* (itemID, hash, updateItem) {
+		if (hash !== null && hash.length != 32) {
+			throw new Error("Invalid file hash '" + hash + "'");
+		}
+		
+		Zotero.DB.requireTransaction();
+		
+		var sql = "UPDATE itemAttachments SET storageHash=? WHERE itemID=?";
+		yield Zotero.DB.queryAsync(sql, [hash, itemID]);
+		
+		if (updateItem) {
+			// Update item date modified so the new mod time will be synced
+			var sql = "UPDATE items SET clientDateModified=? WHERE itemID=?";
+			yield Zotero.DB.queryAsync(sql, [Zotero.DB.transactionDateTime, itemID]);
+		}
+	}),
+	
+	
+	/**
+	 * Extract a downloaded file and update the database metadata
+	 *
+	 * @param {Zotero.Item} data.item
+	 * @param {Integer}     data.mtime
+	 * @param {String}      data.md5
+	 * @param {Boolean}     data.compressed
+	 * @return {Promise}
+	 */
+	processDownload: Zotero.Promise.coroutine(function* (data) {
+		if (!data) {
+			throw new Error("'data' not set");
+		}
+		if (!data.item) {
+			throw new Error("'data.item' not set");
+		}
+		if (!data.mtime) {
+			throw new Error("'data.mtime' not set");
+		}
+		if (data.mtime != parseInt(data.mtime)) {
+			throw new Error("Invalid mod time '" + data.mtime + "'");
+		}
+		if (!data.compressed && !data.md5) {
+			throw new Error("'data.md5' is required if 'data.compressed'");
+		}
+		
+		var item = data.item;
+		var mtime = parseInt(data.mtime);
+		var md5 = data.md5;
+		
+		// TODO: Test file hash
+		
+		if (data.compressed) {
+			var newPath = yield this._processZipDownload(item);
+		}
+		else {
+			var newPath = yield this._processSingleFileDownload(item);
+		}
+		
+		// If newPath is set, the file was renamed, so set item filename to that
+		// and mark for updated
+		var path = yield item.getFilePathAsync();
+		if (newPath && path != newPath) {
+			// If library isn't editable but filename was changed, update
+			// database without updating the item's mod time, which would result
+			// in a library access error
+			if (!Zotero.Items.isEditable(item)) {
+				Zotero.debug("File renamed without library access -- "
+					+ "updating itemAttachments path", 3);
+				yield item.relinkAttachmentFile(newPath, true);
+			}
+			else {
+				yield item.relinkAttachmentFile(newPath);
+			}
+			
+			path = newPath;
+		}
+		
+		if (!path) {
+			// This can happen if an HTML snapshot filename was changed and synced
+			// elsewhere but the renamed file wasn't synced, so the ZIP doesn't
+			// contain a file with the known name
+			Components.utils.reportError("File '" + item.attachmentFilename
+				+ "' not found after processing download " + item.libraryKey);
+			return new Zotero.Sync.Storage.Result({
+				localChanges: false
+			});
+		}
+		
+		try {
+			// If hash not provided (e.g., WebDAV), calculate it now
+			if (!md5) {
+				md5 = yield item.attachmentHash;
+			}
+		}
+		catch (e) {
+			Zotero.File.checkFileAccessError(e, path, 'update');
+		}
+		
+		// Set the file mtime to the time from the server
+		yield OS.File.setDates(path, null, new Date(parseInt(mtime)));
+		
+		yield Zotero.DB.executeTransaction(function* () {
+			yield this.setSyncedHash(item.id, md5);
+			yield this.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
+			yield this.setSyncedModificationTime(item.id, mtime);
+		}.bind(this));
+		
+		return new Zotero.Sync.Storage.Result({
+			localChanges: true
+		});
+	}),
+	
+	
+	_processSingleFileDownload: Zotero.Promise.coroutine(function* (item) {
+		var tempFilePath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.tmp');
+		
+		if (!(yield OS.File.exists(tempFilePath))) {
+			Zotero.debug(tempFilePath, 1);
+			throw new Error("Downloaded file not found");
+		}
+		
+		var parentDirPath = Zotero.Attachments.getStorageDirectory(item).path;
+		if (!(yield OS.File.exists(parentDirPath))) {
+			yield Zotero.Attachments.createDirectoryForItem(item);
+		}
+		
+		yield this._deleteExistingAttachmentFiles(item);
+		
+		var path = item.getFilePath();
+		if (!path) {
+			throw new Error("Empty path for item " + item.key);
+		}
+		// Don't save Windows aliases
+		if (path.endsWith('.lnk')) {
+			return false;
+		}
+		
+		var fileName = OS.Path.basename(path);
+		var renamed = false;
+		
+		// Make sure the new filename is valid, in case an invalid character made it over
+		// (e.g., from before we checked for them)
+		var filteredName = Zotero.File.getValidFileName(fileName);
+		if (filteredName != fileName) {
+			Zotero.debug("Filtering filename '" + fileName + "' to '" + filteredName + "'");
+			fileName = filteredName;
+			path = OS.Path.dirname(path, fileName);
+			renamed = true;
+		}
+		
+		Zotero.debug("Moving download file " + OS.Path.basename(tempFilePath)
+			+ " into attachment directory as '" + fileName + "'");
+		try {
+			var finalFileName = Zotero.File.createShortened(
+				path, Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644
+			);
+		}
+		catch (e) {
+			Zotero.File.checkFileAccessError(e, path, 'create');
+		}
+		
+		if (finalFileName != fileName) {
+			Zotero.debug("Changed filename '" + fileName + "' to '" + finalFileName + "'");
+			
+			fileName = finalFileName;
+			path = OS.Path.dirname(path, fileName);
+			
+			// Abort if Windows path limitation would cause filenames to be overly truncated
+			if (Zotero.isWin && fileName.length < 40) {
+				try {
+					yield OS.File.remove(path);
+				}
+				catch (e) {}
+				// TODO: localize
+				var msg = "Due to a Windows path length limitation, your Zotero data directory "
+					+ "is too deep in the filesystem for syncing to work reliably. "
+					+ "Please relocate your Zotero data to a higher directory.";
+				Zotero.debug(msg, 1);
+				throw new Error(msg);
+			}
+			
+			renamed = true;
+		}
+		
+		try {
+			yield OS.File.move(tempFilePath, path);
+		}
+		catch (e) {
+			try {
+				yield OS.File.remove(tempFilePath);
+			}
+			catch (e) {}
+			
+			Zotero.File.checkFileAccessError(e, path, 'create');
+		}
+		
+		// processDownload() needs to know that we're renaming the file
+		return renamed ? path : null;
+	}),
+	
+	
+	_processZipDownload: Zotero.Promise.coroutine(function* (item) {
+		var zipFile = Zotero.getTempDirectory();
+		zipFile.append(item.key + '.tmp');
+		
+		if (!zipFile.exists()) {
+			Zotero.debug(zipFile.path);
+			throw new Error(`Downloaded ZIP file not found for item ${item.libraryKey}`);
+		}
+		
+		var zipReader = Components.classes["@mozilla.org/libjar/zip-reader;1"].
+				createInstance(Components.interfaces.nsIZipReader);
+		try {
+			zipReader.open(zipFile);
+			zipReader.test(null);
+			
+			Zotero.debug("ZIP file is OK");
+		}
+		catch (e) {
+			Zotero.debug(zipFile.leafName + " is not a valid ZIP file", 2);
+			zipReader.close();
+			
+			try {
+				zipFile.remove(false);
+			}
+			catch (e) {
+				Zotero.File.checkFileAccessError(e, zipFile, 'delete');
+			}
+			
+			// TODO: Remove prop file to trigger reuploading, in case it was an upload error?
+			
+			return false;
+		}
+		
+		var parentDir = Zotero.Attachments.getStorageDirectory(item);
+		if (!parentDir.exists()) {
+			yield Zotero.Attachments.createDirectoryForItem(item);
+		}
+		
+		try {
+			yield this._deleteExistingAttachmentFiles(item);
+		}
+		catch (e) {
+			zipReader.close();
+			throw (e);
+		}
+		
+		var returnFile = null;
+		var count = 0;
+		
+		var itemFileName = item.attachmentFilename;
+		
+		var entries = zipReader.findEntries(null);
+		while (entries.hasMore()) {
+			count++;
+			var entryName = entries.getNext();
+			var b64re = /%ZB64$/;
+			if (entryName.match(b64re)) {
+				var fileName = Zotero.Utilities.Internal.Base64.decode(
+					entryName.replace(b64re, '')
+				);
+			}
+			else {
+				var fileName = entryName;
+			}
+			
+			if (fileName.startsWith('.zotero')) {
+				Zotero.debug("Skipping " + fileName);
+				continue;
+			}
+			
+			Zotero.debug("Extracting " + fileName);
+			
+			var primaryFile = false;
+			var filtered = false;
+			var renamed = false;
+			
+			// Make sure the new filename is valid, in case an invalid character
+			// somehow make it into the ZIP (e.g., from before we checked for them)
+			//
+			// Do this before trying to use the relative descriptor, since otherwise
+			// it might fail silently and select the parent directory
+			var filteredName = Zotero.File.getValidFileName(fileName);
+			if (filteredName != fileName) {
+				Zotero.debug("Filtering filename '" + fileName + "' to '" + filteredName + "'");
+				fileName = filteredName;
+				filtered = true;
+			}
+			
+			// Name in ZIP is a relative descriptor, so file has to be reconstructed
+			// using setRelativeDescriptor()
+			var destFile = parentDir.clone();
+			destFile.QueryInterface(Components.interfaces.nsILocalFile);
+			destFile.setRelativeDescriptor(parentDir, fileName);
+			
+			fileName = destFile.leafName;
+			
+			// If only one file in zip and it doesn't match the known filename,
+			// take our chances and use that name
+			if (count == 1 && !entries.hasMore() && itemFileName) {
+				// May not be necessary, but let's be safe
+				itemFileName = Zotero.File.getValidFileName(itemFileName);
+				if (itemFileName != fileName) {
+					Zotero.debug("Renaming single file '" + fileName + "' in ZIP to known filename '" + itemFileName + "'", 2);
+					Components.utils.reportError("Renaming single file '" + fileName + "' in ZIP to known filename '" + itemFileName + "'");
+					fileName = itemFileName;
+					destFile.leafName = fileName;
+					renamed = true;
+				}
+			}
+			
+			var primaryFile = itemFileName == fileName;
+			if (primaryFile && filtered) {
+				renamed = true;
+			}
+			
+			if (destFile.exists()) {
+				var msg = "ZIP entry '" + fileName + "' " + "already exists";
+				Zotero.debug(msg, 2);
+				Components.utils.reportError(msg + " in " + funcName);
+				Zotero.debug(destFile.path);
+				continue;
+			}
+			
+			try {
+				Zotero.File.createShortened(destFile, Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644);
+			}
+			catch (e) {
+				Zotero.debug(e, 1);
+				Components.utils.reportError(e);
+				
+				zipReader.close();
+				
+				Zotero.File.checkFileAccessError(e, destFile, 'create');
+			}
+			
+			if (destFile.leafName != fileName) {
+				Zotero.debug("Changed filename '" + fileName + "' to '" + destFile.leafName + "'");
+				
+				// Abort if Windows path limitation would cause filenames to be overly truncated
+				if (Zotero.isWin && destFile.leafName.length < 40) {
+					try {
+						destFile.remove(false);
+					}
+					catch (e) {}
+					zipReader.close();
+					// TODO: localize
+					var msg = "Due to a Windows path length limitation, your Zotero data directory "
+						+ "is too deep in the filesystem for syncing to work reliably. "
+						+ "Please relocate your Zotero data to a higher directory.";
+					Zotero.debug(msg, 1);
+					throw new Error(msg);
+				}
+				
+				if (primaryFile) {
+					renamed = true;
+				}
+			}
+			
+			try {
+				zipReader.extract(entryName, destFile);
+			}
+			catch (e) {
+				try {
+					destFile.remove(false);
+				}
+				catch (e) {}
+				
+				// For advertising junk files, ignore a bug on Windows where
+				// destFile.create() works but zipReader.extract() doesn't
+				// when the path length is close to 255.
+				if (destFile.leafName.match(/[a-zA-Z0-9+=]{130,}/)) {
+					var msg = "Ignoring error extracting '" + destFile.path + "'";
+					Zotero.debug(msg, 2);
+					Zotero.debug(e, 2);
+					Components.utils.reportError(msg + " in " + funcName);
+					continue;
+				}
+				
+				zipReader.close();
+				
+				Zotero.File.checkFileAccessError(e, destFile, 'create');
+			}
+			
+			destFile.permissions = 0644;
+			
+			// If we're renaming the main file, processDownload() needs to know
+			if (renamed) {
+				returnFile = destFile.path;
+			}
+		}
+		zipReader.close();
+		zipFile.remove(false);
+		
+		return returnFile;
+	}),
+	
+	
+	_deleteExistingAttachmentFiles: Zotero.Promise.coroutine(function* (item) {
+		var parentDir = Zotero.Attachments.getStorageDirectory(item).path;
+		return this._deleteExistingFilesInDirectory(parentDir);
+	}),
+	
+	
+	_deleteExistingFilesInDirectory: Zotero.Promise.coroutine(function* (dir) {
+		var dirsToDelete = [];
+		var iterator = new OS.File.DirectoryIterator(dir);
+		try {
+			yield iterator.forEach(function (entry) {
+				return Zotero.Promise.coroutine(function* () {
+					if (entry.isDir) {
+						dirsToDelete.push(entry.path);
+					}
+					else {
+						try {
+							yield OS.File.remove(entry.path);
+						}
+						catch (e) {
+							Zotero.File.checkFileAccessError(e, entry.path, 'delete');
+						}
+					}
+				})();
+			});
+		}
+		finally {
+			iterator.close();
+		}
+		for (let path of dirsToDelete) {
+			yield this._deleteExistingFilesInDirectory(path);
+		}
+	}),
+	
+	
+	/**
+	 * @return {Promise<Object[]>} - A promise for an array of conflict objects
+	 */
+	getConflicts: Zotero.Promise.coroutine(function* (libraryID) {
+		var sql = "SELECT itemID, version FROM items JOIN itemAttachments USING (itemID) "
+			+ "WHERE libraryID=? AND syncState=?";
+		var rows = yield Zotero.DB.queryAsync(
+			sql,
+			[
+				{ int: libraryID },
+				Zotero.Sync.Storage.SYNC_STATE_IN_CONFLICT
+			]
+		);
+		var keyVersionPairs = rows.map(function (row) {
+			var { libraryID, key } = Zotero.Items.getLibraryAndKeyFromID(row.itemID);
+			return [key, row.version];
+		});
+		var cacheObjects = yield Zotero.Sync.Data.Local.getCacheObjects(
+			'item', libraryID, keyVersionPairs
+		);
+		if (!cacheObjects.length) return [];
+		
+		var cacheObjectsByKey = {};
+		cacheObjects.forEach(obj => cacheObjectsByKey[obj.key] = obj);
+		
+		var items = [];
+		var localItems = yield Zotero.Items.getAsync(rows.map(row => row.itemID));
+		for (let localItem of localItems) {
+			// Use the mtime for the dateModified field, since that's all that's shown in the
+			// CR window at the moment
+			let localItemJSON = yield localItem.toJSON();
+			localItemJSON.dateModified = Zotero.Date.dateToISO(
+				new Date(yield localItem.attachmentModificationTime)
+			);
+			
+			let remoteItemJSON = cacheObjectsByKey[localItem.key];
+			if (!remoteItemJSON) {
+				Zotero.logError("Cached object not found for item " + localItem.libraryKey);
+				continue;
+			}
+			remoteItemJSON = remoteItemJSON.data;
+			remoteItemJSON.dateModified = Zotero.Date.dateToISO(new Date(remoteItemJSON.mtime));
+			items.push({
+				left: localItemJSON,
+				right: remoteItemJSON,
+				changes: [],
+				conflicts: []
+			})
+		}
+		return items;
+	}),
+	
+	
+	resolveConflicts: Zotero.Promise.coroutine(function* (libraryID) {
+		var conflicts = yield this.getConflicts(libraryID);
+		if (!conflicts.length) return false;
+		
+		Zotero.debug("Reconciling conflicts for " + Zotero.Libraries.get(libraryID).name);
+		
+		var io = {
+			dataIn: {
+				type: 'file',
+				captions: [
+					Zotero.getString('sync.storage.localFile'),
+					Zotero.getString('sync.storage.remoteFile'),
+					Zotero.getString('sync.storage.savedFile')
+				],
+				conflicts
+			}
+		};
+		
+		var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
+				   .getService(Components.interfaces.nsIWindowMediator);
+		var lastWin = wm.getMostRecentWindow("navigator:browser");
+		lastWin.openDialog('chrome://zotero/content/merge.xul', '', 'chrome,modal,centerscreen', io);
+		
+		if (!io.dataOut) {
+			return false;
+		}
+		yield Zotero.DB.executeTransaction(function* () {
+			for (let i = 0; i < conflicts.length; i++) {
+				let conflict = conflicts[i];
+				let mtime = io.dataOut[i].dateModified;
+				// Local
+				if (mtime == conflict.left.dateModified) {
+					syncState = Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD;
+				}
+				// Remote
+				else {
+					syncState = Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD;
+				}
+				let itemID = Zotero.Items.getIDFromLibraryAndKey(libraryID, conflict.left.key);
+				yield Zotero.Sync.Storage.Local.setSyncState(itemID, syncState);
+			}
+		});
+		return true;
+	})
+}
diff --git a/chrome/content/zotero/xpcom/storage/request.js b/chrome/content/zotero/xpcom/storage/storageRequest.js
similarity index 60%
rename from chrome/content/zotero/xpcom/storage/request.js
rename to chrome/content/zotero/xpcom/storage/storageRequest.js
index bf0d5baa0..ca6b496e3 100644
--- a/chrome/content/zotero/xpcom/storage/request.js
+++ b/chrome/content/zotero/xpcom/storage/storageRequest.js
@@ -27,15 +27,25 @@
 /**
  * Transfer request for storage sync
  *
- * @param  {String}    name     Identifier for request (e.g., "[libraryID]/[key]")
- * @param  {Function}  onStart  Callback to run when request starts
+ * @param {Object} options
+ * @param {String} options.type
+ * @param {Integer} options.libraryID
+ * @param {String} options.name - Identifier for request (e.g., "[libraryID]/[key]")
+ * @param {Function|Function[]} [options.onStart]
+ * @param {Function|Function[]} [options.onProgress]
+ * @param {Function|Function[]} [options.onStop]
  */
-Zotero.Sync.Storage.Request = function (name, callbacks) {
-	Zotero.debug("Initializing request '" + name + "'");
+Zotero.Sync.Storage.Request = function (options) {
+	if (!options.type) throw new Error("type must be provided");
+	if (!options.libraryID) throw new Error("libraryID must be provided");
+	if (!options.name) throw new Error("name must be provided");
+	['type', 'libraryID', 'name'].forEach(x => this[x] = options[x]);
 	
-	this.callbacks = ['onStart', 'onProgress'];
+	Zotero.debug(`Initializing ${this.type} request ${this.name}`);
 	
-	this.name = name;
+	this.callbacks = ['onStart', 'onProgress', 'onStop'];
+	
+	this.Type = Zotero.Utilities.capitalize(this.type);
 	this.channel = null;
 	this.queue = null;
 	this.progress = 0;
@@ -48,17 +58,10 @@ Zotero.Sync.Storage.Request = function (name, callbacks) {
 	this._remaining = null;
 	this._maxSize = null;
 	this._finished = false;
-	this._forceFinish = false;
-	this._changesMade = false;
 	
-	for (var func in callbacks) {
-		if (this.callbacks.indexOf(func) !== -1) {
-			// Stuff all single functions into arrays
-			this['_' + func] = typeof callbacks[func] === 'function' ? [callbacks[func]] : callbacks[func];
-		}
-		else {
-			throw new Error("Invalid handler '" + func + "'");
-		}
+	for (let name of this.callbacks) {
+		if (!options[name]) continue;
+		this['_' + name] = Array.isArray(options[name]) ? options[name] : [options[name]];
 	}
 }
 
@@ -99,11 +102,6 @@ Zotero.Sync.Storage.Request.prototype.importCallbacks = function (request) {
 }
 
 
-Zotero.Sync.Storage.Request.prototype.__defineGetter__('promise', function () {
-	return this._deferred.promise;
-});
-
-
 Zotero.Sync.Storage.Request.prototype.__defineGetter__('percentage', function () {
 	if (this._finished) {
 		return 100;
@@ -142,7 +140,7 @@ Zotero.Sync.Storage.Request.prototype.__defineGetter__('remaining', function ()
 	}
 	
 	if (!this.progressMax) {
-		if (this.queue.type == 'upload' && this._maxSize) {
+		if (this.type == 'upload' && this._maxSize) {
 			return Math.round(Zotero.Sync.Storage.compressionTracker.ratio * this._maxSize);
 		}
 		
@@ -175,72 +173,47 @@ Zotero.Sync.Storage.Request.prototype.setChannel = function (channel) {
 }
 
 
-Zotero.Sync.Storage.Request.prototype.start = function () {
-	if (!this.queue) {
-		throw ("Request " + this.name + " must be added to a queue before starting");
-	}
-	
-	Zotero.debug("Starting " + this.queue.name + " request " + this.name);
+Zotero.Sync.Storage.Request.prototype.start = Zotero.Promise.coroutine(function* () {
+	Zotero.debug("Starting " + this.type + " request " + this.name);
 	
 	if (this._running) {
-		throw new Error("Request " + this.name + " already running");
+		throw new Error(this.type + " request " + this.name + " already running");
+	}
+	
+	if (!this._onStart) {
+		throw new Error("onStart not provided -- nothing to do!");
 	}
 	
 	this._running = true;
-	this.queue.activeRequests++;
 	
-	if (this.queue.type == 'download') {
-		Zotero.Sync.Storage.setItemDownloadPercentage(this.name, 0);
-	}
-	
-	var self = this;
-	
-	// this._onStart is an array of promises returning changesMade.
+	// this._onStart is an array of promises for objects of result flags, which are combined
+	// into a single object here
 	//
 	// The main sync logic is triggered here.
-	
-	Zotero.Promise.all([f(this) for each(f in this._onStart)])
-	.then(function (results) {
-		return {
-			localChanges: results.some(function (val) val && val.localChanges == true),
-			remoteChanges: results.some(function (val) val && val.remoteChanges == true),
-			conflict: results.reduce(function (prev, cur) {
-				return prev.conflict ? prev : cur;
-			}).conflict
-		};
-	})
-	.then(function (results) {
-		Zotero.debug(results);
+	try {
+		var results = yield Zotero.Promise.all(this._onStart.map(f => f(this)));
 		
-		if (results.localChanges) {
-			Zotero.debug("Changes were made by " + self.queue.name
-				+ " request " + self.name);
-		}
-		else {
-			Zotero.debug("No changes were made by " + self.queue.name
-				+ " request " + self.name);
-		}
+		var result = new Zotero.Sync.Storage.Result;
+		result.updateFromResults(results);
 		
-		// This promise updates localChanges/remoteChanges on the queue
-		self._deferred.resolve(results);
-	})
-	.catch(function (e) {
-		if (self._stopping) {
-			Zotero.debug("Skipping error for stopping request " + self.name);
-			return;
+		Zotero.debug(this.Type + " request " + this.name + " finished");
+		Zotero.debug(result + "");
+		
+		return result;
+	}
+	catch (e) {
+		Zotero.logError(this.Type + " request " + this.name + " failed");
+		throw e;
+	}
+	finally {
+		this._finished = true;
+		this._running = false;
+		
+		if (this._onStop) {
+			this._onStop.forEach(x => x());
 		}
-		Zotero.debug(self.queue.Type + " request " + self.name + " failed");
-		self._deferred.reject(e);
-	})
-	// Finish the request (and in turn the queue, if this is the last request)
-	.finally(function () {
-		if (!self._finished) {
-			self._finish();
-		}
-	});
-	
-	return this._deferred.promise;
-}
+	}
+});
 
 
 Zotero.Sync.Storage.Request.prototype.isRunning = function () {
@@ -263,7 +236,7 @@ Zotero.Sync.Storage.Request.prototype.isFinished = function () {
  * @param	{Integer}		progressMax		Max progress value for this request
  *												(usually total bytes)
  */
-Zotero.Sync.Storage.Request.prototype.onProgress = function (channel, progress, progressMax) {
+Zotero.Sync.Storage.Request.prototype.onProgress = function (progress, progressMax) {
 	//Zotero.debug(progress + "/" + progressMax + " for request " + this.name);
 	
 	if (!this._running) {
@@ -273,10 +246,6 @@ Zotero.Sync.Storage.Request.prototype.onProgress = function (channel, progress,
 		return;
 	}
 	
-	if (!this.channel) {
-		this.channel = channel;
-	}
-	
 	// Workaround for invalid progress values (possibly related to
 	// https://bugzilla.mozilla.org/show_bug.cgi?id=451991 and fixed in 3.1)
 	if (progress < this.progress) {
@@ -292,9 +261,8 @@ Zotero.Sync.Storage.Request.prototype.onProgress = function (channel, progress,
 	
 	this.progress = progress;
 	this.progressMax = progressMax;
-	this.queue.updateProgress();
 	
-	if (this.queue.type == 'download') {
+	if (this.type == 'download') {
 		Zotero.Sync.Storage.setItemDownloadPercentage(this.name, this.percentage);
 	}
 	
@@ -310,59 +278,15 @@ Zotero.Sync.Storage.Request.prototype.onProgress = function (channel, progress,
  * Stop the request's underlying network request, if there is one
  */
 Zotero.Sync.Storage.Request.prototype.stop = function (force) {
-	if (force) {
-		this._forceFinish = true;
-	}
-	
 	if (this.channel && this.channel.isPending()) {
 		this._stopping = true;
 		
 		try {
-			Zotero.debug("Stopping request '" + this.name + "'");
+			Zotero.debug(`Stopping ${this.type} request '${this.name} '`);
 			this.channel.cancel(0x804b0002); // NS_BINDING_ABORTED
 		}
 		catch (e) {
-			Zotero.debug(e);
+			Zotero.debug(e, 1);
 		}
 	}
-	else {
-		this._finish();
-	}
-}
-
-
-/**
- * Mark request as finished and notify queue that it's done
- */
-Zotero.Sync.Storage.Request.prototype._finish = function () {
-	// If an error occurred, we wait to finish the request, since doing
-	// so might end the queue before the error flag has been set on the queue.
-	// When the queue's error handler stops the queue, it stops the request
-	// with stop(true) to force the finish to occur, allowing the queue's
-	// promise to be rejected with the error.
-	if (!this._forceFinish && this._deferred.promise.isRejected()) {
-		return;
-	}
-	
-	Zotero.debug("Finishing " + this.queue.name + " request '" + this.name + "'");
-	this._finished = true;
-	var active = this._running;
-	this._running = false;
-	
-	Zotero.Sync.Storage.setItemDownloadPercentage(this.name, false);
-	
-	if (active) {
-		this.queue.activeRequests--;
-	}
-	// TEMP: mechanism for failures?
-	try {
-		this.queue.finishedRequests++;
-		this.queue.updateProgress();
-	}
-	catch (e) {
-		Zotero.debug(e, 1);
-		Components.utils.reportError(e);
-		this._deferred.reject(e);
-		throw e;
-	}
 }
diff --git a/chrome/content/zotero/xpcom/storage/storageResult.js b/chrome/content/zotero/xpcom/storage/storageResult.js
new file mode 100644
index 000000000..eaa1f38c1
--- /dev/null
+++ b/chrome/content/zotero/xpcom/storage/storageResult.js
@@ -0,0 +1,47 @@
+"use strict";
+
+/**
+ * @property {Boolean} localChanges - Changes were made locally. For logging purposes only.
+ * @property {Boolean} remoteChanges - Changes were made on the server. This causes the
+ *     last-sync time to be updated on the server (WebDAV) or retrieved (ZFS) and stored locally
+ *     to skip additional file syncs until further server changes are made.
+ * @property {Boolean} syncRequired - A data sync is required to upload local changes
+ * @propretty {Boolean} fileSyncRequired - Another file sync is required to handle files left in
+ *     conflict
+ */
+Zotero.Sync.Storage.Result = function (options = {}) {
+	this._props = ['localChanges', 'remoteChanges', 'syncRequired', 'fileSyncRequired'];
+	for (let prop of this._props) {
+		this[prop] = options[prop] || false;
+	}
+}
+
+/**
+ * Update the properties on this object from multiple Result objects
+ *
+ * @param {Zotero.Sync.Storage.Result[]} results
+ */
+Zotero.Sync.Storage.Result.prototype.updateFromResults = function (results) {
+	for (let prop of this._props) {
+		if (!this[prop]) {
+			for (let result of results) {
+				if (!(result instanceof Zotero.Sync.Storage.Result)) {
+					Zotero.debug(result, 1);
+					throw new Error("'result' is not a storage result");
+				}
+				if (result[prop]) {
+					this[prop] = true;
+				}
+			}
+		}
+	}
+}
+
+
+Zotero.Sync.Storage.Result.prototype.toString = function () {
+	var obj = {};
+	for (let prop of this._props) {
+		obj[prop] = this[prop] || false;
+	}
+	return JSON.stringify(obj, null, "    ");
+}
diff --git a/chrome/content/zotero/xpcom/storage/storageUtilities.js b/chrome/content/zotero/xpcom/storage/storageUtilities.js
new file mode 100644
index 000000000..7df99f1a6
--- /dev/null
+++ b/chrome/content/zotero/xpcom/storage/storageUtilities.js
@@ -0,0 +1,67 @@
+Zotero.Sync.Storage.Utilities = {
+	getClassForMode: function (mode) {
+		switch (mode) {
+		case 'zfs':
+			return Zotero.Sync.Storage.ZFS_Module;
+		
+		case 'webdav':
+			return Zotero.Sync.Storage.WebDAV_Module;
+		
+		default:
+			throw new Error("Invalid storage mode '" + mode + "'");
+		}
+	},
+	
+	getItemFromRequest: function (request) {
+		var [libraryID, key] = request.name.split('/');
+		return Zotero.Items.getByLibraryAndKey(libraryID, key);
+	},
+	
+	
+	/**
+	 * Create zip file of attachment directory in the temp directory
+	 *
+	 * @param	{Zotero.Sync.Storage.Request}		request
+	 * @return {Promise<Boolean>} - True if the zip file was created, false otherwise
+	 */
+	createUploadFile: Zotero.Promise.coroutine(function* (request) {
+		var item = this.getItemFromRequest(request);
+		Zotero.debug("Creating ZIP file for item " + item.libraryKey);
+		
+		switch (item.attachmentLinkMode) {
+			case Zotero.Attachments.LINK_MODE_LINKED_FILE:
+			case Zotero.Attachments.LINK_MODE_LINKED_URL:
+				throw new Error("Upload file must be an imported snapshot or file");
+		}
+		
+		var zipFile = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.zip');
+		
+		return Zotero.File.zipDirectory(
+			Zotero.Attachments.getStorageDirectory(item).path,
+			zipFile,
+			{
+				onStopRequest: function (req, context, status) {
+					var zipFileName = OS.Path.basename(zipFile);
+					
+					var originalSize = 0;
+					for (let entry of context.entries) {
+						let zipEntry = context.zipWriter.getEntry(entry.name);
+						if (!zipEntry) {
+							Zotero.logError("ZIP entry '" + entry.name + "' not found for "
+								+ "request '" + request.name + "'")
+							continue;
+						}
+						originalSize += zipEntry.realSize;
+					}
+					
+					Zotero.debug("Zip of " + zipFileName + " finished with status " + status
+						+ " (original " + Math.round(originalSize / 1024) + "KB, "
+						+ "compressed " + Math.round(context.zipWriter.file.fileSize / 1024) + "KB, "
+						+ Math.round(
+							((originalSize - context.zipWriter.file.fileSize) / originalSize) * 100
+						) + "% reduction)");
+				}
+			}
+		);
+	})
+}
diff --git a/chrome/content/zotero/xpcom/storage/streamListener.js b/chrome/content/zotero/xpcom/storage/streamListener.js
index 8a8e28295..136a8f895 100644
--- a/chrome/content/zotero/xpcom/storage/streamListener.js
+++ b/chrome/content/zotero/xpcom/storage/streamListener.js
@@ -30,10 +30,9 @@
  * Possible properties of data object:
  *   - onStart: f(request)
  *   - onProgress:  f(request, progress, progressMax)
- *   - onStop:  f(request, status, response, data)
- *   - onCancel:  f(request, status, data)
+ *   - onStop:  f(request, status, response)
+ *   - onCancel:  f(request, status)
  *   - streams: array of streams to close on completion
- *   - Other values to pass to onStop()
  */
 Zotero.Sync.Storage.StreamListener = function (data) {
 	this._data = data;
@@ -110,17 +109,15 @@ Zotero.Sync.Storage.StreamListener.prototype = {
 	},
 	
 	onStateChange: function (wp, request, stateFlags, status) {
-		Zotero.debug("onStateChange");
-		Zotero.debug(stateFlags);
-		Zotero.debug(status);
+		Zotero.debug("onStateChange with " + stateFlags);
 		
-		if ((stateFlags & Components.interfaces.nsIWebProgressListener.STATE_START)
-				&& (stateFlags & Components.interfaces.nsIWebProgressListener.STATE_IS_NETWORK)) {
-			this._onStart(request);
-		}
-		else if ((stateFlags & Components.interfaces.nsIWebProgressListener.STATE_STOP)
-				&& (stateFlags & Components.interfaces.nsIWebProgressListener.STATE_IS_NETWORK)) {
-			this._onStop(request, status);
+		if (stateFlags & Components.interfaces.nsIWebProgressListener.STATE_IS_REQUEST) {
+			if (stateFlags & Components.interfaces.nsIWebProgressListener.STATE_START) {
+				this._onStart(request);
+			}
+			else if (stateFlags & Components.interfaces.nsIWebProgressListener.STATE_STOP) {
+				this._onStop(request, status);
+			}
 		}
 	},
 	
@@ -148,18 +145,38 @@ Zotero.Sync.Storage.StreamListener.prototype = {
 	},
 	
 	// nsIChannelEventSink
-	onChannelRedirect: function (oldChannel, newChannel, flags) {
+	//
+	// If this._data.onChannelRedirect exists, it should return a promise resolving to true to
+	// follow the redirect or false to cancel it
+	onChannelRedirect: Zotero.Promise.coroutine(function* (oldChannel, newChannel, flags) {
 		Zotero.debug('onChannelRedirect');
 		
+		if (this._data && this._data.onChannelRedirect) {
+			let result = yield this._data.onChannelRedirect(oldChannel, newChannel, flags);
+			if (!result) {
+				oldChannel.cancel(Components.results.NS_BINDING_ABORTED);
+				newChannel.cancel(Components.results.NS_BINDING_ABORTED);
+				return false;
+			}
+		}
+		
 		// if redirecting, store the new channel
 		this._channel = newChannel;
-	},
+	}),
 	
 	asyncOnChannelRedirect: function (oldChan, newChan, flags, redirectCallback) {
 		Zotero.debug('asyncOnRedirect');
 		
-		this.onChannelRedirect(oldChan, newChan, flags);
-		redirectCallback.onRedirectVerifyCallback(0);
+		this.onChannelRedirect(oldChan, newChan, flags)
+		.then(function (result) {
+			redirectCallback.onRedirectVerifyCallback(
+				result ? Components.results.NS_SUCCEEDED : Components.results.NS_FAILED
+			);
+		})
+		.catch(function (e) {
+			Zotero.logError(e);
+			redirectCallback.onRedirectVerifyCallback(Components.results.NS_FAILED);
+		});
 	},
 	
 	// nsIHttpEventSink
@@ -177,8 +194,7 @@ Zotero.Sync.Storage.StreamListener.prototype = {
 	_onStart: function (request) {
 		Zotero.debug('Starting request');
 		if (this._data && this._data.onStart) {
-			var data = this._getPassData();
-			this._data.onStart(request, data);
+			this._data.onStart(request);
 		}
 	},
 	
@@ -189,7 +205,6 @@ Zotero.Sync.Storage.StreamListener.prototype = {
 	},
 	
 	_onStop: function (request, status) {
-		Zotero.debug('Request ended with status ' + status);
 		var cancelled = status == 0x804b0002; // NS_BINDING_ABORTED
 		
 		if (!cancelled && status == 0 && request instanceof Components.interfaces.nsIHttpChannel) {
@@ -201,9 +216,11 @@ Zotero.Sync.Storage.StreamListener.prototype = {
 				Zotero.debug("Request responseStatus not available", 1);
 				status = 0;
 			}
+			Zotero.debug('Request ended with status code ' + status);
 			request.QueryInterface(Components.interfaces.nsIRequest);
 		}
 		else {
+			Zotero.debug('Request ended with status ' + status);
 			status = 0;
 		}
 		
@@ -213,38 +230,20 @@ Zotero.Sync.Storage.StreamListener.prototype = {
 			}
 		}
 		
-		var data = this._getPassData();
-		
 		if (cancelled) {
 			if (this._data.onCancel) {
-				this._data.onCancel(request, status, data);
+				this._data.onCancel(request, status);
 			}
 		}
 		else {
 			if (this._data.onStop) {
-				this._data.onStop(request, status, this._response, data);
+				this._data.onStop(request, status, this._response);
 			}
 		}
 		
 		this._channel = null;
 	},
 	
-	_getPassData: function () {
-		// Make copy of data without callbacks to pass along
-		var passData = {};
-		for (var i in this._data) {
-			switch (i) {
-				case "onStart":
-				case "onProgress":
-				case "onStop":
-				case "onCancel":
-					continue;
-			}
-			passData[i] = this._data[i];
-		}
-		return passData;
-	},
-	
 	// nsIInterfaceRequestor
 	getInterface: function (iid) {
 		try {
diff --git a/chrome/content/zotero/xpcom/storage/webdav.js b/chrome/content/zotero/xpcom/storage/webdav.js
index 9ce0e9e6d..2b6c8c4a3 100644
--- a/chrome/content/zotero/xpcom/storage/webdav.js
+++ b/chrome/content/zotero/xpcom/storage/webdav.js
@@ -24,740 +24,105 @@
 */
 
 
-Zotero.Sync.Storage.WebDAV = (function () {
-	var _initialized = false;
-	var _parentURI;
-	var _rootURI;
-	var _cachedCredentials = false;
+Zotero.Sync.Storage.WebDAV_Module = {};
+Zotero.Sync.Storage.WebDAV_Module.prototype = {
+	name: "WebDAV",
+	get verified() {
+		return Zotero.Prefs.get("sync.storage.verified");
+	},
 	
-	var _loginManagerHost = 'chrome://zotero';
-	var _loginManagerURL = 'Zotero Storage Server';
+	_initialized: false,
+	_parentURI: null,
+	_rootURI: null,	
+	_cachedCredentials: false,
 	
-	var _lastSyncIDLength = 30;
+	_loginManagerHost: 'chrome://zotero',
+	_loginManagerURL: 'Zotero Storage Server',
 	
-	//
-	// Private methods
-	//
-	/**
-	 * Get mod time of file on storage server
-	 *
-	 * @param	{Zotero.Item}	item
-	 * @param	{Function}		callback		Callback f(item, mdate)
-	 */
-	function getStorageModificationTime(item, request) {
-		var uri = getItemPropertyURI(item);
+	_lastSyncIDLength: 30,
+	
+	
+	get defaultError() {
+		return Zotero.getString('sync.storage.error.webdav.default');
+	},
+	
+	get defaultErrorRestart() {
+		return Zotero.getString('sync.storage.error.webdav.defaultRestart', Zotero.appName);
+	},
+	
+	get _username() {
+		return Zotero.Prefs.get('sync.storage.username');
+	},
+	
+	get _password() {
+		var username = this._username;
 		
-		return Zotero.HTTP.promise("GET", uri,
-			{
-				debug: true,
-				successCodes: [200, 300, 404],
-				requestObserver: function (xmlhttp) {
-					request.setChannel(xmlhttp.channel);
-				}
-			})
-			.then(function (req) {
-				checkResponse(req);
-				
-				// mod_speling can return 300s for 404s with base name matches
-				if (req.status == 404 || req.status == 300) {
-					return false;
-				}
-				
-				// No modification time set
-				if (!req.responseText) {
-					return false;
-				}
-				
-				var seconds = false;
-				var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
-					.createInstance(Components.interfaces.nsIDOMParser);
-				try {
-					var xml = parser.parseFromString(req.responseText, "text/xml");
-					var mtime = xml.getElementsByTagName('mtime')[0].textContent;
-				}
-				catch (e) {
-					Zotero.debug(e);
-					var mtime = false;
-				}
-				
-				// TEMP
-				if (!mtime) {
-					mtime = req.responseText;
-					seconds = true;
-				}
-				
-				var invalid = false;
-				
-				// Unix timestamps need to be converted to ms-based timestamps
-				if (seconds) {
-					if (mtime.match(/^[0-9]{1,10}$/)) {
-						Zotero.debug("Converting Unix timestamp '" + mtime + "' to milliseconds");
-						mtime = mtime * 1000;
-					}
-					else {
-						invalid = true;
-					}
-				}
-				else if (!mtime.match(/^[0-9]{1,13}$/)) {
-					invalid = true;
-				}
-				
-				// Delete invalid .prop files
-				if (invalid) {
-					var msg = "Invalid mod date '" + Zotero.Utilities.ellipsize(mtime, 20)
-						+ "' for item " + Zotero.Items.getLibraryKeyHash(item);
-					Zotero.debug(msg, 1);
-					Components.utils.reportError(msg);
-					return deleteStorageFiles([item.key + ".prop"])
-					.then(function (results) {
-						throw new Error(Zotero.Sync.Storage.WebDAV.defaultError);
-					});
-				}
-				
-				return new Date(parseInt(mtime));
-			})
-			.catch(function (e) {
-				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
-					throw new Error("HTTP " + e.status + " error from WebDAV "
-						+ "server for GET request");
-				}
-				throw e;
-			});
-	}
-	
-	
-	/**
-	 * Set mod time of file on storage server
-	 *
-	 * @param	{Zotero.Item}	item
-	 */
-	function setStorageModificationTime(item) {
-		var uri = getItemPropertyURI(item);
-		
-		var mtime = item.attachmentModificationTime;
-		var hash = item.attachmentHash;
-		
-		var prop = '<properties version="1">'
-			+ '<mtime>' + mtime + '</mtime>'
-			+ '<hash>' + hash + '</hash>'
-			+ '</properties>';
-		
-		return Zotero.HTTP.promise("PUT", uri,
-				{ body: prop, debug: true, successCodes: [200, 201, 204] })
-			.then(function (req) {
-				return { mtime: mtime, hash: hash };
-			})
-			.catch(function (e) {
-				throw new Error("HTTP " + e.xmlhttp.status
-					+ " from WebDAV server for HTTP PUT");
-			});
-	};
-	
-	
-	
-	/**
-	 * Upload the generated ZIP file to the server
-	 *
-	 * @param	{Object}		Object with 'request' property
-	 * @return	{void}
-	 */
-	function processUploadFile(data) {
-		/*
-		updateSizeMultiplier(
-			(100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100
-		);
-		*/
-		var request = data.request;
-		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
-		
-		return getStorageModificationTime(item, request)
-			.then(function (mdate) {
-				if (!request.isRunning()) {
-					Zotero.debug("Upload request '" + request.name
-						+ "' is no longer running after getting mod time");
-					return false;
-				}
-				
-				// Check for conflict
-				if (Zotero.Sync.Storage.getSyncState(item.id)
-						!= Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD) {
-					if (mdate) {
-						// Remote prop time
-						var mtime = mdate.getTime();
-						
-						// Local file time
-						var fmtime = item.attachmentModificationTime;
-						
-						var same = false;
-						if (fmtime == mtime) {
-							same = true;
-							Zotero.debug("File mod time matches remote file -- skipping upload");
-						}
-						// Allow floored timestamps for filesystems that don't support
-						// millisecond precision (e.g., HFS+)
-						else if (Math.floor(mtime / 1000) * 1000 == fmtime || Math.floor(fmtime / 1000) * 1000 == mtime) {
-							same = true;
-							Zotero.debug("File mod times are within one-second precision (" + fmtime + " ≅ " + mtime + ") "
-								+ "-- skipping upload");
-						}
-						// Allow timestamp to be exactly one hour off to get around
-						// time zone issues -- there may be a proper way to fix this
-						else if (Math.abs(fmtime - mtime) == 3600000
-								// And check with one-second precision as well
-								|| Math.abs(fmtime - Math.floor(mtime / 1000) * 1000) == 3600000
-								|| Math.abs(Math.floor(fmtime / 1000) * 1000 - mtime) == 3600000) {
-							same = true;
-							Zotero.debug("File mod time (" + fmtime + ") is exactly one hour off remote file (" + mtime + ") "
-								+ "-- assuming time zone issue and skipping upload");
-						}
-						
-						if (same) {
-							Zotero.DB.beginTransaction();
-							var syncState = Zotero.Sync.Storage.getSyncState(item.id);
-							Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime, true);
-							Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
-							Zotero.DB.commitTransaction();
-							return true;
-						}
-						
-						var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id);
-						if (smtime != mtime) {
-							Zotero.debug("Conflict -- last synced file mod time "
-								+ "does not match time on storage server"
-								+ " (" + smtime + " != " + mtime + ")");
-							return {
-								localChanges: false,
-								remoteChanges: false,
-								conflict: {
-									local: { modTime: fmtime },
-									remote: { modTime: mtime }
-								}
-							};
-						}
-					}
-					else {
-						Zotero.debug("Remote file not found for item " + item.id);
-					}
-				}
-				
-				var file = Zotero.getTempDirectory();
-				file.append(item.key + '.zip');
-				
-				var fis = Components.classes["@mozilla.org/network/file-input-stream;1"]
-							.createInstance(Components.interfaces.nsIFileInputStream);
-				fis.init(file, 0x01, 0, 0);
-				
-				var bis = Components.classes["@mozilla.org/network/buffered-input-stream;1"]
-							.createInstance(Components.interfaces.nsIBufferedInputStream)
-				bis.init(fis, 64 * 1024);
-				
-				var uri = getItemURI(item);
-				
-				var ios = Components.classes["@mozilla.org/network/io-service;1"].
-							getService(Components.interfaces.nsIIOService);
-				var channel = ios.newChannelFromURI(uri);
-				channel.QueryInterface(Components.interfaces.nsIUploadChannel);
-				channel.setUploadStream(bis, 'application/octet-stream', -1);
-				channel.QueryInterface(Components.interfaces.nsIHttpChannel);
-				channel.requestMethod = 'PUT';
-				channel.allowPipelining = false;
-				
-				channel.setRequestHeader('Keep-Alive', '', false);
-				channel.setRequestHeader('Connection', '', false);
-				
-				var deferred = Zotero.Promise.defer();
-				
-				var listener = new Zotero.Sync.Storage.StreamListener(
-					{
-						onProgress: function (a, b, c) {
-							request.onProgress(a, b, c);
-						},
-						onStop: function (httpRequest, status, response, data) {
-							data.request.setChannel(false);
-							
-							deferred.resolve(
-								Zotero.Promise.try(function () {
-									return onUploadComplete(httpRequest, status, response, data);
-								})
-							);
-						},
-						onCancel: function (httpRequest, status, data) {
-							onUploadCancel(httpRequest, status, data);
-							deferred.resolve(false);
-						},
-						request: request,
-						item: item,
-						streams: [fis, bis]
-					}
-				);
-				channel.notificationCallbacks = listener;
-				
-				var dispURI = uri.clone();
-				if (dispURI.password) {
-					dispURI.password = '********';
-				}
-				Zotero.debug("HTTP PUT of " + file.leafName + " to " + dispURI.spec);
-				
-				channel.asyncOpen(listener, null);
-				
-				return deferred.promise;
-			});
-	}
-	
-	
-	function onUploadComplete(httpRequest, status, response, data) {
-		var request = data.request;
-		var item = data.item;
-		var url = httpRequest.name;
-		
-		Zotero.debug("Upload of attachment " + item.key
-			+ " finished with status code " + status);
-		
-		switch (status) {
-			case 200:
-			case 201:
-			case 204:
-				break;
-			
-			case 403:
-			case 500:
-				Zotero.debug(response);
-				throw (Zotero.getString('sync.storage.error.fileUploadFailed') +
-					" " + Zotero.getString('sync.storage.error.checkFileSyncSettings'));
-			
-			case 507:
-				Zotero.debug(response);
-				throw Zotero.getString('sync.storage.error.webdav.insufficientSpace');
-			
-			default:
-				Zotero.debug(response);
-				throw (Zotero.getString('sync.storage.error.fileUploadFailed') +
-					" " + Zotero.getString('sync.storage.error.checkFileSyncSettings')
-					+ "\n\n" + "HTTP " + status);
+		if (!username) {
+			Zotero.debug('Username not set before getting Zotero.Sync.Storage.WebDAV.password');
+			return '';
 		}
 		
-		return setStorageModificationTime(item)
-			.then(function (props) {
-				if (!request.isRunning()) {
-					Zotero.debug("Upload request '" + request.name
-						+ "' is no longer running after getting mod time");
-					return false;
-				}
-				
-				Zotero.DB.beginTransaction();
-				
-				Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
-				Zotero.Sync.Storage.setSyncedModificationTime(item.id, props.mtime, true);
-				Zotero.Sync.Storage.setSyncedHash(item.id, props.hash);
-				
-				Zotero.DB.commitTransaction();
-				
-				try {
-					var file = Zotero.getTempDirectory();
-					file.append(item.key + '.zip');
-					file.remove(false);
-				}
-				catch (e) {
-					Components.utils.reportError(e);
-				}
-				
-				return {
-					localChanges: true,
-					remoteChanges: true
-				};
-			});
-	}
-	
-	
-	function onUploadCancel(httpRequest, status, data) {
-		var request = data.request;
-		var item = data.item;
+		Zotero.debug('Getting WebDAV password');
+		var loginManager = Components.classes["@mozilla.org/login-manager;1"]
+								.getService(Components.interfaces.nsILoginManager);
+		var logins = loginManager.findLogins({}, this._loginManagerHost, this._loginManagerURL, null);
 		
-		Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status);
-		
-		try {
-			var file = Zotero.getTempDirectory();
-			file.append(item.key + '.zip');
-			file.remove(false);
-		}
-		catch (e) {
-			Components.utils.reportError(e);
-		}
-	}
-	
-	
-	/**
-	 * Create a Zotero directory on the storage server
-	 */
-	function createServerDirectory(callback) {
-		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
-		Zotero.HTTP.WebDAV.doMkCol(uri, function (req) {
-			Zotero.debug(req.responseText);
-			Zotero.debug(req.status);
-			
-			switch (req.status) {
-				case 201:
-					return [uri, Zotero.Sync.Storage.SUCCESS];
-				
-				case 401:
-					return [uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED];
-				
-				case 403:
-					return [uri, Zotero.Sync.Storage.ERROR_FORBIDDEN];
-				
-				case 405:
-					return [uri, Zotero.Sync.Storage.ERROR_NOT_ALLOWED];
-				
-				case 500:
-					return [uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR];
-				
-				default:
-					return [uri, Zotero.Sync.Storage.ERROR_UNKNOWN];
+		// Find user from returned array of nsILoginInfo objects
+		for (var i = 0; i < logins.length; i++) {
+			if (logins[i].username == username) {
+				return logins[i].password;
 			}
-		});
-	}
-	
-	
-	/**
-	 * Get the storage URI for an item
-	 *
-	 * @inner
-	 * @param	{Zotero.Item}
-	 * @return	{nsIURI}					URI of file on storage server
-	 */
-	function getItemURI(item) {
-		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
-		uri.spec = uri.spec + item.key + '.zip';
-		return uri;
-	}
-	
-	
-	/**
-	 * Get the storage property file URI for an item
-	 *
-	 * @inner
-	 * @param	{Zotero.Item}
-	 * @return	{nsIURI}					URI of property file on storage server
-	 */
-	function getItemPropertyURI(item) {
-		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
-		uri.spec = uri.spec + item.key + '.prop';
-		return uri;
-	}
-		
-		
-	/**
-	 * Get the storage property file URI corresponding to a given item storage URI
-	 *
-	 * @param	{nsIURI}			Item storage URI
-	 * @return	{nsIURI|FALSE}	Property file URI, or FALSE if not an item storage URI
-	 */
-	function getPropertyURIFromItemURI(uri) {
-		if (!uri.spec.match(/\.zip$/)) {
-			return false;
-		}
-		var propURI = uri.clone();
-		propURI.QueryInterface(Components.interfaces.nsIURL);
-		propURI.fileName = uri.fileName.replace(/\.zip$/, '.prop');
-		propURI.QueryInterface(Components.interfaces.nsIURI);
-		return propURI;
-	}
-	
-	
-	/**
-	 * @inner
-	 * @param	{String[]}	files		Remote filenames to delete (e.g., ZIPs)
-	 * @param	{Function}	callback		Passed object containing three arrays:
-	 *										'deleted', 'missing', and 'error',
-	 *										each containing filenames
-	 */
-	function deleteStorageFiles(files) {
-		var results = {
-			deleted: [],
-			missing: [],
-			error: []
-		};
-		
-		if (files.length == 0) {
-			return Zotero.Promise.resolve(results);
 		}
 		
-		let deleteURI = _rootURI.clone();
-		// This should never happen, but let's be safe
-		if (!deleteURI.spec.match(/\/$/)) {
-			return Zotero.Promise.reject("Root URI does not end in slash in "
-				+ "Zotero.Sync.Storage.WebDAV.deleteStorageFiles()");
-		}
-		
-		var funcs = [];
-		for (let i=0; i<files.length; i++) {
-			let fileName = files[i];
-			let baseName = fileName.match(/^([^\.]+)/)[1];
-			funcs.push(function () {
-				let deleteURI = _rootURI.clone();
-				deleteURI.QueryInterface(Components.interfaces.nsIURL);
-				deleteURI.fileName = fileName;
-				deleteURI.QueryInterface(Components.interfaces.nsIURI);
-				return Zotero.HTTP.promise("DELETE", deleteURI, { successCodes: [200, 204, 404] })
-				.then(function (req) {
-					switch (req.status) {
-						case 204:
-						// IIS 5.1 and Sakai return 200
-						case 200:
-							var fileDeleted = true;
-							break;
-						
-						case 404:
-							var fileDeleted = true;
-							break;
-					}
-					
-					// If an item file URI, get the property URI
-					var deletePropURI = getPropertyURIFromItemURI(deleteURI);
-					
-					// If we already deleted the prop file, skip it
-					if (!deletePropURI || results.deleted.indexOf(deletePropURI.fileName) != -1) {
-						if (fileDeleted) {
-							results.deleted.push(baseName);
-						}
-						else {
-							results.missing.push(baseName);
-						}
-						return;
-					}
-					
-					let propFileName = deletePropURI.fileName;
-					
-					// Delete property file
-					return Zotero.HTTP.promise("DELETE", deletePropURI, { successCodes: [200, 204, 404] })
-					.then(function (req) {
-						switch (req.status) {
-							case 204:
-							// IIS 5.1 and Sakai return 200
-							case 200:
-								results.deleted.push(baseName);
-								break;
-							
-							case 404:
-								if (fileDeleted) {
-									results.deleted.push(baseName);
-								}
-								else {
-									results.missing.push(baseName);
-								}
-								break;
-						}
-					});
-				})
-				.catch(function (e) {
-					results.error.push(baseName);
-					throw e;
-				});
-			});
-		}
-		
-		Components.utils.import("resource://zotero/concurrent-caller.js");
-		var caller = new ConcurrentCaller(4);
-		caller.stopOnError = true;
-		caller.setLogger(function (msg) Zotero.debug(msg));
-		caller.onError(function (e) Components.utils.reportError(e));
-		return caller.fcall(funcs)
-		.then(function () {
-			return results;
-		});
-	}
+		return '';
+	},
 	
-	
-	/**
-	 * Checks for an invalid SSL certificate and throws a nice error
-	 */
-	function checkResponse(req) {
-		var channel = req.channel;
-		if (!channel instanceof Ci.nsIChannel) {
-			Zotero.Sync.Storage.EventManager.error('No HTTPS channel available');
-		}
-		
-		// Check if the error we encountered is really an SSL error
-		// Logic borrowed from https://developer.mozilla.org/en-US/docs/How_to_check_the_security_state_of_an_XMLHTTPRequest_over_SSL
-		//  http://mxr.mozilla.org/mozilla-central/source/security/nss/lib/ssl/sslerr.h
-		//  http://mxr.mozilla.org/mozilla-central/source/security/nss/lib/util/secerr.h
-		var secErrLimit = Ci.nsINSSErrorsService.NSS_SEC_ERROR_LIMIT - Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE;
-		var secErr = Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (channel.status & 0xffff);
-		var sslErrLimit = Ci.nsINSSErrorsService.NSS_SSL_ERROR_LIMIT - Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE;
-		var sslErr = Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (channel.status & 0xffff);
-		if( (secErr < 0 || secErr > secErrLimit) && (sslErr < 0 || sslErr > sslErrLimit) ) {
+	set _password(password) {
+		var username = this._username;
+		if (!username) {
+			Zotero.debug('Username not set before setting Zotero.Sync.Server.Mode.WebDAV.password');
 			return;
 		}
 		
-		var secInfo = channel.securityInfo;
-		if (secInfo instanceof Ci.nsITransportSecurityInfo) {
-			secInfo.QueryInterface(Ci.nsITransportSecurityInfo);
-			if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_INSECURE) == Ci.nsIWebProgressListener.STATE_IS_INSECURE) {
-				var host = 'host';
-				try {
-					host = channel.URI.host;
-				}
-				catch (e) {
-					Zotero.debug(e);
-				}
-				
-				var msg = Zotero.getString('sync.storage.error.webdav.sslCertificateError', host);
-				// In Standalone, provide cert_override.txt instructions and a
-				// button to open the Zotero profile directory
-				if (Zotero.isStandalone) {
-					msg += "\n\n" + Zotero.getString('sync.storage.error.webdav.seeCertOverrideDocumentation');
-					var buttonText = Zotero.getString('general.openDocumentation');
-					var func = function () {
-						var zp = Zotero.getActiveZoteroPane();
-						zp.loadURI("https://www.zotero.org/support/kb/cert_override", { shiftKey: true });
-					};
-				}
-				// In Firefox display a button to load the WebDAV URL
-				else {
-					msg += "\n\n" + Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo');
-					var buttonText = Zotero.getString('sync.storage.error.webdav.loadURL');
-					var func = function () {
-						var zp = Zotero.getActiveZoteroPane();
-						zp.loadURI(channel.URI.spec, { shiftKey: true });
-					};
-				}
-				
-				var e = new Zotero.Error(
-					msg,
-					0,
-					{
-						dialogText: msg,
-						dialogButtonText: buttonText,
-						dialogButtonCallback: func
-					}
-				);
-				throw e;
-			}
-			else if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_BROKEN) == Ci.nsIWebProgressListener.STATE_IS_BROKEN) {
-				var msg = Zotero.getString('sync.storage.error.webdav.sslConnectionError', host) +
-							Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo');
-				var e = new Zotero.Error(
-					msg,
-					0,
-					{
-						dialogText: msg,
-						dialogButtonText: Zotero.getString('sync.storage.error.webdav.loadURL'),
-						dialogButtonCallback: function () {
-							var zp = Zotero.getActiveZoteroPane();
-							zp.loadURI(channel.URI.spec, { shiftKey: true });
-						}
-					}
-				);
-				throw e;
-			}
-		}
-	}
-	
-	
-	//
-	// Public methods (called via Zotero.Sync.Storage.WebDAV)
-	//
-	var obj = new Zotero.Sync.Storage.Mode;
-	obj.name = "WebDAV";
-	
-	Object.defineProperty(obj, "defaultError", {
-		get: function () Zotero.getString('sync.storage.error.webdav.default')
-	});
-	
-	Object.defineProperty(obj, "defaultErrorRestart", {
-		get: function () Zotero.getString('sync.storage.error.webdav.defaultRestart', Zotero.appName)
-	});
-	
-	Object.defineProperty(obj, "includeUserFiles", {
-		get: function () {
-			return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'webdav';
-		}
-	});
-	obj.includeGroupItems = false;
-	
-	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.WebDAV.password');
-				return '';
-			}
-			
-			Zotero.debug('Getting WebDAV password');
-			var loginManager = Components.classes["@mozilla.org/login-manager;1"]
-									.getService(Components.interfaces.nsILoginManager);
-			var logins = loginManager.findLogins({}, _loginManagerHost, _loginManagerURL, null);
-			
-			// Find user from returned array of nsILoginInfo objects
-			for (var i = 0; i < logins.length; i++) {
-				if (logins[i].username == username) {
-					return logins[i].password;
-				}
-			}
-			
-			return '';
-		},
+		_cachedCredentials = false;
 		
-		set: function (password) {
-			var username = this._username;
-			if (!username) {
-				Zotero.debug('Username not set before setting Zotero.Sync.Server.Mode.WebDAV.password');
-				return;
-			}
-			
-			_cachedCredentials = false;
-			
-			var loginManager = Components.classes["@mozilla.org/login-manager;1"]
-									.getService(Components.interfaces.nsILoginManager);
-			var logins = loginManager.findLogins({}, _loginManagerHost, _loginManagerURL, null);
-			
-			for (var i = 0; i < logins.length; i++) {
-				Zotero.debug('Clearing WebDAV passwords');
-				loginManager.removeLogin(logins[i]);
-				break;
-			}
-			
-			if (password) {
-				Zotero.debug(_loginManagerURL);
-				var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
-					Components.interfaces.nsILoginInfo, "init");
-				var loginInfo = new nsLoginInfo(_loginManagerHost, _loginManagerURL,
-					null, username, password, "", "");
-				loginManager.addLogin(loginInfo);
-			}
+		var loginManager = Components.classes["@mozilla.org/login-manager;1"]
+								.getService(Components.interfaces.nsILoginManager);
+		var logins = loginManager.findLogins({}, this._loginManagerHost, this._loginManagerURL, null);
+		
+		for (var i = 0; i < logins.length; i++) {
+			Zotero.debug('Clearing WebDAV passwords');
+			loginManager.removeLogin(logins[i]);
+			break;
 		}
-	});
-	
-	Object.defineProperty(obj, "rootURI", {
-		get: function () {
-			if (!_rootURI) {
-				this._init();
-			}
-			return _rootURI.clone();
+		
+		if (password) {
+			Zotero.debug(this._loginManagerURL);
+			var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+				Components.interfaces.nsILoginInfo, "init");
+			var loginInfo = new nsLoginInfo(this._loginManagerHost, this._loginManagerURL,
+				null, username, password, "", "");
+			loginManager.addLogin(loginInfo);
 		}
-	});
+	},
 	
-	Object.defineProperty(obj, "parentURI", {
-		get: function () {
-			if (!_parentURI) {
-				this._init();
-			}
-			return _parentURI.clone();
+	get rootURI() {
+		if (!this._rootURI) {
+			this._init();
 		}
-	});
+		return this._rootURI.clone();
+	},
 	
-	obj._init = function () {
-		_rootURI = false;
-		_parentURI = false;
+	get parentURI() {
+		if (!this._parentURI) {
+			this._init();
+		}
+		return this._parentURI.clone();
+	},
+	
+	init: function () {
+		this._rootURI = false;
+		this._parentURI = false;
 		
 		var scheme = Zotero.Prefs.get('sync.storage.scheme');
 		switch (scheme) {
@@ -816,26 +181,52 @@ Zotero.Sync.Storage.WebDAV = (function () {
 		if (!uri.spec.match(/\/$/)) {
 			uri.spec += "/";
 		}
-		_parentURI = uri;
+		this._parentURI = uri;
 		
 		var uri = uri.clone();
 		uri.spec += "zotero/";
-		_rootURI = uri;
-	};
+		this._rootURI = uri;
+	},
 	
+	
+	cacheCredentials: Zotero.Promise.coroutine(function* () {
+		if (this._cachedCredentials) {
+			Zotero.debug("WebDAV credentials are already cached");
+			return;
+		}
 		
-	obj.clearCachedCredentials = function() {
-		_rootURI = _parentURI = undefined;
-		_cachedCredentials = false;
-	};
+		try {
+			var req = Zotero.HTTP.request("OPTIONS", this.rootURI);
+			checkResponse(req);
+			
+			Zotero.debug("Credentials are cached");
+			this._cachedCredentials = true;
+		}
+		catch (e) {
+			if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
+				let msg = "HTTP " + e.status + " error from WebDAV server "
+					+ "for OPTIONS request";
+				Zotero.debug(msg, 1);
+				Components.utils.reportError(msg);
+				throw new Error(Zotero.Sync.Storage.WebDAV.defaultErrorRestart);
+			}
+			throw e;
+		}
+	}),
+	
+	
+	clearCachedCredentials: function() {
+		this._rootURI = this._parentURI = undefined;
+		this._cachedCredentials = false;
+	},
 	
 	/**
 	 * Begin download process for individual file
 	 *
 	 * @param	{Zotero.Sync.Storage.Request}	[request]
 	 */
-	obj._downloadFile = function (request) {
-		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
+	downloadFile: function (request, requeueCallback) {
+		var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
 		if (!item) {
 			throw new Error("Item '" + request.name + "' not found");
 		}
@@ -868,7 +259,9 @@ Zotero.Sync.Storage.WebDAV = (function () {
 					Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
 					Zotero.DB.commitTransaction();
 					return {
-						localChanges: true
+						localChanges: true, // ?
+						remoteChanges: false,
+						syncRequired: false
 					};
 				}
 				
@@ -930,7 +323,11 @@ Zotero.Sync.Storage.WebDAV = (function () {
 							Zotero.debug("Finished download of " + destFile.path);
 							
 							try {
-								deferred.resolve(Zotero.Sync.Storage.processDownload(data));
+								deferred.resolve(
+									Zotero.Sync.Storage.processDownload(
+										data, requeueCallback
+									)
+								);
 							}
 							catch (e) {
 								deferred.reject(e);
@@ -963,10 +360,10 @@ Zotero.Sync.Storage.WebDAV = (function () {
 				
 				return deferred.promise;
 			});
-	};
+	},
 	
 	
-	obj._uploadFile = function (request) {
+	uploadFile: function (request) {
 		var deferred = Zotero.Promise.defer();
 		var created = Zotero.Sync.Storage.createUploadFile(
 			request,
@@ -986,10 +383,10 @@ Zotero.Sync.Storage.WebDAV = (function () {
 			return Zotero.Promise.resolve(false);
 		}
 		return deferred.promise;
-	};
+	},
 	
 	
-	obj._getLastSyncTime = function () {
+	getLastSyncTime: function () {
 		var lastSyncURI = this.rootURI;
 		lastSyncURI.spec += "lastsync.txt";
 		
@@ -1063,10 +460,10 @@ Zotero.Sync.Storage.WebDAV = (function () {
 				throw (e);
 			}
 		});
-	};
+	},
 	
 	
-	obj._setLastSyncTime = function (libraryID, localLastSyncID) {
+	setLastSyncTime: function (libraryID, localLastSyncID) {
 		if (libraryID != Zotero.Libraries.userLibraryID) {
 			throw new Error("libraryID must be user library");
 		}
@@ -1102,36 +499,11 @@ Zotero.Sync.Storage.WebDAV = (function () {
 			Components.utils.reportError(msg);
 			throw Zotero.Sync.Storage.WebDAV.defaultError;
 		});
-	};
+	},
 	
 	
-	obj._cacheCredentials = function () {
-		if (_cachedCredentials) {
-			Zotero.debug("WebDAV credentials are already cached");
-			return;
-		}
-		
-		return Zotero.HTTP.promise("OPTIONS", this.rootURI)
-		.then(function (req) {
-			checkResponse(req);
-			
-			Zotero.debug("Credentials are cached");
-			_cachedCredentials = true;
-		})
-		.catch(function (e) {
-			if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
-				var msg = "HTTP " + e.status + " error from WebDAV server "
-					+ "for OPTIONS request";
-				Zotero.debug(msg, 1);
-				Components.utils.reportError(msg);
-				throw new Error(Zotero.Sync.Storage.WebDAV.defaultErrorRestart);
-			}
-			throw e;
-		});
-	};
 	
-	
-	obj._checkServer = function () {
+	checkServer: function () {
 		var deferred = Zotero.Promise.defer();
 		
 		try {
@@ -1399,7 +771,7 @@ Zotero.Sync.Storage.WebDAV = (function () {
 		}, 0);
 		
 		return deferred.promise;
-	};
+	},
 	
 	
 	/**
@@ -1407,7 +779,7 @@ Zotero.Sync.Storage.WebDAV = (function () {
 	 *
 	 * @return bool True if the verification succeeded, false otherwise
 	 */
-	obj._checkServerCallback = function (uri, status, window, skipSuccessMessage) {
+	checkServerCallback: function (uri, status, window, skipSuccessMessage) {
 		var promptService =
 			Components.classes["@mozilla.org/embedcomp/prompt-service;1"].
 				createInstance(Components.interfaces.nsIPromptService);
@@ -1545,72 +917,58 @@ Zotero.Sync.Storage.WebDAV = (function () {
 			promptService.alert(window, errorTitle, errorMessage);
 		}
 		return false;
-	};
+	},
 	
 	
 	/**
 	 * Remove files on storage server that were deleted locally
 	 *
-	 * @param	{Function}	callback		Passed number of files deleted
+	 * @param {Integer} libraryID
 	 */
-	obj._purgeDeletedStorageFiles = function () {
-		return Zotero.Promise.try(function () {
-			if (!this.includeUserFiles) {
-				return false;
-			}
-			
-			Zotero.debug("Purging deleted storage files");
-			var files = Zotero.Sync.Storage.getDeletedFiles();
-			if (!files) {
-				Zotero.debug("No files to delete remotely");
-				return false;
-			}
-			
-			// Add .zip extension
-			var files = files.map(function (file) file + ".zip");
-			
-			return deleteStorageFiles(files)
-			.then(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;
+	purgeDeletedStorageFiles: Zotero.Promise.coroutine(function* (libraryID) {
+		Zotero.debug("Purging deleted storage files");
+		var files = yield Zotero.Sync.Storage.Local.getDeletedFiles(libraryID);
+		if (!files.length) {
+			Zotero.debug("No files to delete remotely");
+			return false;
+		}
+		
+		// Add .zip extension
+		var files = files.map(file => file + ".zip");
+		
+		var results = yield deleteStorageFiles(files)
+		
+		// Remove deleted and nonexistent files from storage delete log
+		var toPurge = results.deleted.concat(results.missing);
+		if (toPurge.length > 0) {
+			yield Zotero.DB.executeTransaction(function* () {
+				yield Zotero.Utilities.Internal.forEachChunkAsync(
+					toPurge,
+					Zotero.DB.MAX_BOUND_PARAMETERS,
+					function (chunk) {
+						var sql = "DELETE FROM storageDeleteLog WHERE libraryID=? AND key IN ("
+							+ chunk.map(() => '?').join() + ")";
+						return Zotero.DB.queryAsync(sql, [libraryID].concat(chunk));
 					}
-					while (done < numFiles);
-					
-					Zotero.DB.commitTransaction();
-				}
-				
-				Zotero.debug(results);
-				
-				return results.deleted.length;
+				);
 			});
-		}.bind(this));
-	};
+		}
+		
+		Zotero.debug(results);
+		
+		return results.deleted.length;
+	}),
 	
 	
 	/**
 	 * Delete orphaned storage files older than a day before last sync time
 	 */
-	obj._purgeOrphanedStorageFiles = function () {
+	purgeOrphanedStorageFiles: function (libraryID) {
+		// Note: libraryID not currently used
+		
 		return Zotero.Promise.try(function () {
 			const daysBeforeSyncTime = 1;
 			
-			if (!this.includeUserFiles) {
-				return false;
-			}
-			
 			// If recently purged, skip
 			var lastpurge = Zotero.Prefs.get('lastWebDAVOrphanPurge');
 			var days = 10;
@@ -1742,7 +1100,602 @@ Zotero.Sync.Storage.WebDAV = (function () {
 			
 			return deferred.promise;
 		}.bind(this));
-	};
+	},
 	
-	return obj;
-}());
+	
+	//
+	// Private methods
+	//
+	/**
+	 * Get mod time of file on storage server
+	 *
+	 * @param	{Zotero.Item}	item
+	 * @param	{Function}		callback		Callback f(item, mdate)
+	 */
+	_getStorageModificationTime: function (item, request) {
+		var uri = getItemPropertyURI(item);
+		
+		return Zotero.HTTP.promise("GET", uri,
+			{
+				debug: true,
+				successCodes: [200, 300, 404],
+				requestObserver: function (xmlhttp) {
+					request.setChannel(xmlhttp.channel);
+				}
+			})
+			.then(function (req) {
+				checkResponse(req);
+				
+				// mod_speling can return 300s for 404s with base name matches
+				if (req.status == 404 || req.status == 300) {
+					return false;
+				}
+				
+				// No modification time set
+				if (!req.responseText) {
+					return false;
+				}
+				
+				var seconds = false;
+				var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
+					.createInstance(Components.interfaces.nsIDOMParser);
+				try {
+					var xml = parser.parseFromString(req.responseText, "text/xml");
+					var mtime = xml.getElementsByTagName('mtime')[0].textContent;
+				}
+				catch (e) {
+					Zotero.debug(e);
+					var mtime = false;
+				}
+				
+				// TEMP
+				if (!mtime) {
+					mtime = req.responseText;
+					seconds = true;
+				}
+				
+				var invalid = false;
+				
+				// Unix timestamps need to be converted to ms-based timestamps
+				if (seconds) {
+					if (mtime.match(/^[0-9]{1,10}$/)) {
+						Zotero.debug("Converting Unix timestamp '" + mtime + "' to milliseconds");
+						mtime = mtime * 1000;
+					}
+					else {
+						invalid = true;
+					}
+				}
+				else if (!mtime.match(/^[0-9]{1,13}$/)) {
+					invalid = true;
+				}
+				
+				// Delete invalid .prop files
+				if (invalid) {
+					var msg = "Invalid mod date '" + Zotero.Utilities.ellipsize(mtime, 20)
+						+ "' for item " + Zotero.Items.getLibraryKeyHash(item);
+					Zotero.debug(msg, 1);
+					Components.utils.reportError(msg);
+					return deleteStorageFiles([item.key + ".prop"])
+					.then(function (results) {
+						throw new Error(Zotero.Sync.Storage.WebDAV.defaultError);
+					});
+				}
+				
+				return new Date(parseInt(mtime));
+			})
+			.catch(function (e) {
+				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
+					throw new Error("HTTP " + e.status + " error from WebDAV "
+						+ "server for GET request");
+				}
+				throw e;
+			});
+	},
+	
+	
+	/**
+	 * Set mod time of file on storage server
+	 *
+	 * @param	{Zotero.Item}	item
+	 */
+	_setStorageModificationTime: Zotero.Promise.coroutine(function* (item) {
+		var uri = getItemPropertyURI(item);
+		
+		var mtime = item.attachmentModificationTime;
+		var hash = yield item.attachmentHash;
+		
+		var prop = '<properties version="1">'
+			+ '<mtime>' + mtime + '</mtime>'
+			+ '<hash>' + hash + '</hash>'
+			+ '</properties>';
+		
+		return Zotero.HTTP.promise("PUT", uri,
+				{ body: prop, debug: true, successCodes: [200, 201, 204] })
+			.then(function (req) {
+				return { mtime: mtime, hash: hash };
+			})
+			.catch(function (e) {
+				throw new Error("HTTP " + e.xmlhttp.status
+					+ " from WebDAV server for HTTP PUT");
+			})
+	}),
+	
+	
+	
+	/**
+	 * Upload the generated ZIP file to the server
+	 *
+	 * @param	{Object}		Object with 'request' property
+	 * @return	{void}
+	 */
+	_processUploadFile: Zotero.Promise.coroutine(function* (data) {
+		/*
+		updateSizeMultiplier(
+			(100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100
+		);
+		*/
+		var request = data.request;
+		var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
+		
+		var mdate = getStorageModificationTime(item, request);
+		
+		if (!request.isRunning()) {
+			Zotero.debug("Upload request '" + request.name
+				+ "' is no longer running after getting mod time");
+			return false;
+		}
+		
+		// Check for conflict
+		if (Zotero.Sync.Storage.getSyncState(item.id)
+				!= Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD) {
+			if (mdate) {
+				// Local file time
+				var fmtime = yield item.attachmentModificationTime;
+				// Remote prop time
+				var mtime = mdate.getTime();
+				
+				var same = !(yield Zotero.Sync.Storage.checkFileModTime(item, fmtime, mtime));
+				if (same) {
+					Zotero.DB.beginTransaction();
+					var syncState = Zotero.Sync.Storage.getSyncState(item.id);
+					Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime, true);
+					Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
+					Zotero.DB.commitTransaction();
+					return true;
+				}
+				
+				let smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id);
+				if (smtime != mtime) {
+					Zotero.debug("Conflict -- last synced file mod time "
+						+ "does not match time on storage server"
+						+ " (" + smtime + " != " + mtime + ")");
+					return {
+						localChanges: false,
+						remoteChanges: false,
+						syncRequired: false,
+						conflict: {
+							local: { modTime: fmtime },
+							remote: { modTime: mtime }
+						}
+					};
+				}
+			}
+			else {
+				Zotero.debug("Remote file not found for item " + item.id);
+			}
+		}
+		
+		var file = Zotero.getTempDirectory();
+		file.append(item.key + '.zip');
+		
+		var fis = Components.classes["@mozilla.org/network/file-input-stream;1"]
+					.createInstance(Components.interfaces.nsIFileInputStream);
+		fis.init(file, 0x01, 0, 0);
+		
+		var bis = Components.classes["@mozilla.org/network/buffered-input-stream;1"]
+					.createInstance(Components.interfaces.nsIBufferedInputStream)
+		bis.init(fis, 64 * 1024);
+		
+		var uri = getItemURI(item);
+		
+		var ios = Components.classes["@mozilla.org/network/io-service;1"].
+					getService(Components.interfaces.nsIIOService);
+		var channel = ios.newChannelFromURI(uri);
+		channel.QueryInterface(Components.interfaces.nsIUploadChannel);
+		channel.setUploadStream(bis, 'application/octet-stream', -1);
+		channel.QueryInterface(Components.interfaces.nsIHttpChannel);
+		channel.requestMethod = 'PUT';
+		channel.allowPipelining = false;
+		
+		channel.setRequestHeader('Keep-Alive', '', false);
+		channel.setRequestHeader('Connection', '', false);
+		
+		var deferred = Zotero.Promise.defer();
+		
+		var listener = new Zotero.Sync.Storage.StreamListener(
+			{
+				onProgress: function (a, b, c) {
+					request.onProgress(a, b, c);
+				},
+				onStop: function (httpRequest, status, response, data) {
+					data.request.setChannel(false);
+					
+					deferred.resolve(
+						Zotero.Promise.try(function () {
+							return onUploadComplete(httpRequest, status, response, data);
+						})
+					);
+				},
+				onCancel: function (httpRequest, status, data) {
+					onUploadCancel(httpRequest, status, data);
+					deferred.resolve(false);
+				},
+				request: request,
+				item: item,
+				streams: [fis, bis]
+			}
+		);
+		channel.notificationCallbacks = listener;
+		
+		var dispURI = uri.clone();
+		if (dispURI.password) {
+			dispURI.password = '********';
+		}
+		Zotero.debug("HTTP PUT of " + file.leafName + " to " + dispURI.spec);
+		
+		channel.asyncOpen(listener, null);
+		
+		return deferred.promise;
+	}),
+	
+	
+	_onUploadComplete: function (httpRequest, status, response, data) {
+		var request = data.request;
+		var item = data.item;
+		var url = httpRequest.name;
+		
+		Zotero.debug("Upload of attachment " + item.key
+			+ " finished with status code " + status);
+		
+		switch (status) {
+			case 200:
+			case 201:
+			case 204:
+				break;
+			
+			case 403:
+			case 500:
+				Zotero.debug(response);
+				throw (Zotero.getString('sync.storage.error.fileUploadFailed') +
+					" " + Zotero.getString('sync.storage.error.checkFileSyncSettings'));
+			
+			case 507:
+				Zotero.debug(response);
+				throw Zotero.getString('sync.storage.error.webdav.insufficientSpace');
+			
+			default:
+				Zotero.debug(response);
+				throw (Zotero.getString('sync.storage.error.fileUploadFailed') +
+					" " + Zotero.getString('sync.storage.error.checkFileSyncSettings')
+					+ "\n\n" + "HTTP " + status);
+		}
+		
+		return setStorageModificationTime(item)
+			.then(function (props) {
+				if (!request.isRunning()) {
+					Zotero.debug("Upload request '" + request.name
+						+ "' is no longer running after getting mod time");
+					return false;
+				}
+				
+				Zotero.DB.beginTransaction();
+				
+				Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
+				Zotero.Sync.Storage.setSyncedModificationTime(item.id, props.mtime, true);
+				Zotero.Sync.Storage.setSyncedHash(item.id, props.hash);
+				
+				Zotero.DB.commitTransaction();
+				
+				try {
+					var file = Zotero.getTempDirectory();
+					file.append(item.key + '.zip');
+					file.remove(false);
+				}
+				catch (e) {
+					Components.utils.reportError(e);
+				}
+				
+				return {
+					localChanges: true,
+					remoteChanges: true,
+					syncRequired: true
+				};
+			});
+	},
+	
+	
+	_onUploadCancel: function (httpRequest, status, data) {
+		var request = data.request;
+		var item = data.item;
+		
+		Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status);
+		
+		try {
+			var file = Zotero.getTempDirectory();
+			file.append(item.key + '.zip');
+			file.remove(false);
+		}
+		catch (e) {
+			Components.utils.reportError(e);
+		}
+	},
+	
+	
+	/**
+	 * Create a Zotero directory on the storage server
+	 */
+	_createServerDirectory: function (callback) {
+		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
+		Zotero.HTTP.WebDAV.doMkCol(uri, function (req) {
+			Zotero.debug(req.responseText);
+			Zotero.debug(req.status);
+			
+			switch (req.status) {
+				case 201:
+					return [uri, Zotero.Sync.Storage.SUCCESS];
+				
+				case 401:
+					return [uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED];
+				
+				case 403:
+					return [uri, Zotero.Sync.Storage.ERROR_FORBIDDEN];
+				
+				case 405:
+					return [uri, Zotero.Sync.Storage.ERROR_NOT_ALLOWED];
+				
+				case 500:
+					return [uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR];
+				
+				default:
+					return [uri, Zotero.Sync.Storage.ERROR_UNKNOWN];
+			}
+		});
+	},
+	
+	
+	/**
+	 * Get the storage URI for an item
+	 *
+	 * @inner
+	 * @param	{Zotero.Item}
+	 * @return	{nsIURI}					URI of file on storage server
+	 */
+	_getItemURI: function (item) {
+		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
+		uri.spec = uri.spec + item.key + '.zip';
+		return uri;
+	},
+	
+	
+	/**
+	 * Get the storage property file URI for an item
+	 *
+	 * @inner
+	 * @param	{Zotero.Item}
+	 * @return	{nsIURI}					URI of property file on storage server
+	 */
+	_getItemPropertyURI: function (item) {
+		var uri = Zotero.Sync.Storage.WebDAV.rootURI;
+		uri.spec = uri.spec + item.key + '.prop';
+		return uri;
+	},
+	
+	
+	/**
+	 * Get the storage property file URI corresponding to a given item storage URI
+	 *
+	 * @param	{nsIURI}			Item storage URI
+	 * @return	{nsIURI|FALSE}	Property file URI, or FALSE if not an item storage URI
+	 */
+	_getPropertyURIFromItemURI: function (uri) {
+		if (!uri.spec.match(/\.zip$/)) {
+			return false;
+		}
+		var propURI = uri.clone();
+		propURI.QueryInterface(Components.interfaces.nsIURL);
+		propURI.fileName = uri.fileName.replace(/\.zip$/, '.prop');
+		propURI.QueryInterface(Components.interfaces.nsIURI);
+		return propURI;
+	},
+	
+	
+	/**
+	 * @inner
+	 * @param	{String[]}	files		Remote filenames to delete (e.g., ZIPs)
+	 * @param	{Function}	callback		Passed object containing three arrays:
+	 *										'deleted', 'missing', and 'error',
+	 *										each containing filenames
+	 */
+	_deleteStorageFiles: function (files) {
+		var results = {
+			deleted: [],
+			missing: [],
+			error: []
+		};
+		
+		if (files.length == 0) {
+			return Zotero.Promise.resolve(results);
+		}
+		
+		let deleteURI = _rootURI.clone();
+		// This should never happen, but let's be safe
+		if (!deleteURI.spec.match(/\/$/)) {
+			return Zotero.Promise.reject("Root URI does not end in slash in "
+				+ "Zotero.Sync.Storage.WebDAV.deleteStorageFiles()");
+		}
+		
+		var funcs = [];
+		for (let i=0; i<files.length; i++) {
+			let fileName = files[i];
+			let baseName = fileName.match(/^([^\.]+)/)[1];
+			funcs.push(function () {
+				let deleteURI = _rootURI.clone();
+				deleteURI.QueryInterface(Components.interfaces.nsIURL);
+				deleteURI.fileName = fileName;
+				deleteURI.QueryInterface(Components.interfaces.nsIURI);
+				return Zotero.HTTP.promise("DELETE", deleteURI, { successCodes: [200, 204, 404] })
+				.then(function (req) {
+					switch (req.status) {
+						case 204:
+						// IIS 5.1 and Sakai return 200
+						case 200:
+							var fileDeleted = true;
+							break;
+						
+						case 404:
+							var fileDeleted = true;
+							break;
+					}
+					
+					// If an item file URI, get the property URI
+					var deletePropURI = getPropertyURIFromItemURI(deleteURI);
+					
+					// If we already deleted the prop file, skip it
+					if (!deletePropURI || results.deleted.indexOf(deletePropURI.fileName) != -1) {
+						if (fileDeleted) {
+							results.deleted.push(baseName);
+						}
+						else {
+							results.missing.push(baseName);
+						}
+						return;
+					}
+					
+					let propFileName = deletePropURI.fileName;
+					
+					// Delete property file
+					return Zotero.HTTP.promise("DELETE", deletePropURI, { successCodes: [200, 204, 404] })
+					.then(function (req) {
+						switch (req.status) {
+							case 204:
+							// IIS 5.1 and Sakai return 200
+							case 200:
+								results.deleted.push(baseName);
+								break;
+							
+							case 404:
+								if (fileDeleted) {
+									results.deleted.push(baseName);
+								}
+								else {
+									results.missing.push(baseName);
+								}
+								break;
+						}
+					});
+				})
+				.catch(function (e) {
+					results.error.push(baseName);
+					throw e;
+				});
+			});
+		}
+		
+		Components.utils.import("resource://zotero/concurrentCaller.js");
+		var caller = new ConcurrentCaller(4);
+		caller.stopOnError = true;
+		caller.setLogger(function (msg) Zotero.debug(msg));
+		caller.onError(function (e) Components.utils.reportError(e));
+		return caller.fcall(funcs)
+		.then(function () {
+			return results;
+		});
+	},
+	
+	
+	/**
+	 * Checks for an invalid SSL certificate and throws a nice error
+	 */
+	_checkResponse: function (req) {
+		var channel = req.channel;
+		if (!channel instanceof Ci.nsIChannel) {
+			Zotero.Sync.Storage.EventManager.error('No HTTPS channel available');
+		}
+		
+		// Check if the error we encountered is really an SSL error
+		// Logic borrowed from https://developer.mozilla.org/en-US/docs/How_to_check_the_security_state_of_an_XMLHTTPRequest_over_SSL
+		//  http://mxr.mozilla.org/mozilla-central/source/security/nss/lib/ssl/sslerr.h
+		//  http://mxr.mozilla.org/mozilla-central/source/security/nss/lib/util/secerr.h
+		var secErrLimit = Ci.nsINSSErrorsService.NSS_SEC_ERROR_LIMIT - Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE;
+		var secErr = Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (channel.status & 0xffff);
+		var sslErrLimit = Ci.nsINSSErrorsService.NSS_SSL_ERROR_LIMIT - Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE;
+		var sslErr = Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (channel.status & 0xffff);
+		if( (secErr < 0 || secErr > secErrLimit) && (sslErr < 0 || sslErr > sslErrLimit) ) {
+			return;
+		}
+		
+		var secInfo = channel.securityInfo;
+		if (secInfo instanceof Ci.nsITransportSecurityInfo) {
+			secInfo.QueryInterface(Ci.nsITransportSecurityInfo);
+			if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_INSECURE) == Ci.nsIWebProgressListener.STATE_IS_INSECURE) {
+				var host = 'host';
+				try {
+					host = channel.URI.host;
+				}
+				catch (e) {
+					Zotero.debug(e);
+				}
+				
+				var msg = Zotero.getString('sync.storage.error.webdav.sslCertificateError', host);
+				// In Standalone, provide cert_override.txt instructions and a
+				// button to open the Zotero profile directory
+				if (Zotero.isStandalone) {
+					msg += "\n\n" + Zotero.getString('sync.storage.error.webdav.seeCertOverrideDocumentation');
+					var buttonText = Zotero.getString('general.openDocumentation');
+					var func = function () {
+						var zp = Zotero.getActiveZoteroPane();
+						zp.loadURI("https://www.zotero.org/support/kb/cert_override", { shiftKey: true });
+					};
+				}
+				// In Firefox display a button to load the WebDAV URL
+				else {
+					msg += "\n\n" + Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo');
+					var buttonText = Zotero.getString('sync.storage.error.webdav.loadURL');
+					var func = function () {
+						var zp = Zotero.getActiveZoteroPane();
+						zp.loadURI(channel.URI.spec, { shiftKey: true });
+					};
+				}
+				
+				var e = new Zotero.Error(
+					msg,
+					0,
+					{
+						dialogText: msg,
+						dialogButtonText: buttonText,
+						dialogButtonCallback: func
+					}
+				);
+				throw e;
+			}
+			else if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_BROKEN) == Ci.nsIWebProgressListener.STATE_IS_BROKEN) {
+				var msg = Zotero.getString('sync.storage.error.webdav.sslConnectionError', host) +
+							Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo');
+				var e = new Zotero.Error(
+					msg,
+					0,
+					{
+						dialogText: msg,
+						dialogButtonText: Zotero.getString('sync.storage.error.webdav.loadURL'),
+						dialogButtonCallback: function () {
+							var zp = Zotero.getActiveZoteroPane();
+							zp.loadURI(channel.URI.spec, { shiftKey: true });
+						}
+					}
+				);
+				throw e;
+			}
+		}
+	}
+}
diff --git a/chrome/content/zotero/xpcom/storage/zfs.js b/chrome/content/zotero/xpcom/storage/zfs.js
index df52f547a..5c6e0ecc9 100644
--- a/chrome/content/zotero/xpcom/storage/zfs.js
+++ b/chrome/content/zotero/xpcom/storage/zfs.js
@@ -24,605 +24,874 @@
 */
 
 
-Zotero.Sync.Storage.ZFS = (function () {
-	var _rootURI;
-	var _userURI;
-	var _headers = {
-		"Zotero-API-Version" : ZOTERO_CONFIG.API_VERSION
-	};
-	var _cachedCredentials = false;
-	var _s3Backoff = 1;
-	var _s3ConsecutiveFailures = 0;
-	var _maxS3Backoff = 60;
-	var _maxS3ConsecutiveFailures = 5;
+Zotero.Sync.Storage.ZFS_Module = function (options) {
+	this.options = options;
+	this.apiClient = options.apiClient;
+	
+	this._s3Backoff = 1;
+	this._s3ConsecutiveFailures = 0;
+	this._maxS3Backoff = 60;
+	this._maxS3ConsecutiveFailures = 5;
+};
+Zotero.Sync.Storage.ZFS_Module.prototype = {
+	name: "ZFS",
+	verified: true,
 	
 	/**
-	 * Get file metadata on storage server
-	 *
-	 * @param	{Zotero.Item}	item
-	 * @param	{Function}		callback		Callback f(item, etag)
+	 * @return {Promise} A promise for the last sync time
 	 */
-	function getStorageFileInfo(item, request) {
-		var funcName = "Zotero.Sync.Storage.ZFS.getStorageFileInfo()";
+	getLastSyncTime: Zotero.Promise.coroutine(function* (libraryID) {
+		var params = this._getRequestParams(libraryID, "laststoragesync");
+		var uri = this.apiClient.buildRequestURI(params);
 		
-		return Zotero.HTTP.promise("GET", getItemInfoURI(item),
-			{
-				successCodes: [200, 404],
-				headers: _headers,
-				requestObserver: function (xmlhttp) {
-					request.setChannel(xmlhttp.channel);
-				}
-			})
-			.then(function (req) {
-				if (req.status == 404) {
-					return false;
-				}
-				
-				var info = {};
-				info.hash = req.getResponseHeader('ETag');
-				if (!info.hash) {
-					var msg = "Hash not found in info response in " + funcName
-								+ " (" + Zotero.Items.getLibraryKeyHash(item) + ")";
-					Zotero.debug(msg, 1);
-					Zotero.debug(req.status);
-					Zotero.debug(req.responseText);
-					Components.utils.reportError(msg);
-					try {
-						Zotero.debug(req.getAllResponseHeaders());
-					}
-					catch (e) {
-						Zotero.debug("Response headers unavailable");
-					}
-					var msg = Zotero.getString('sync.storage.error.zfs.restart', Zotero.appName);
-					throw msg;
-				}
-				info.filename = req.getResponseHeader('X-Zotero-Filename');
-				var mtime = req.getResponseHeader('X-Zotero-Modification-Time');
-				info.mtime = parseInt(mtime);
-				info.compressed = req.getResponseHeader('X-Zotero-Compressed') == 'Yes';
-				Zotero.debug(info);
-				
-				return info;
-			})
-			.catch(function (e) {
-				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
-					if (e.xmlhttp.status == 0) {
-						var msg = "Request cancelled getting storage file info";
-					}
-					else {
-						var msg = "Unexpected status code " + e.xmlhttp.status
-							+ " getting storage file info for item " + item.libraryKey;
-					}
-					Zotero.debug(msg, 1);
-					Zotero.debug(e.xmlhttp.responseText);
-					Components.utils.reportError(msg);
-					throw new Error(Zotero.Sync.Storage.defaultError);
-				}
-				
-				throw e;
-			});
-	}
+		try {
+			let req = yield this.apiClient.makeRequest(
+				"GET", uri, { successCodes: [200, 404], debug: true }
+			);
+			
+			// Not yet synced
+			if (req.status == 404) {
+				Zotero.debug("No last sync time for library " + libraryID);
+				return null;
+			}
+			
+			let ts = req.responseText;
+			let date = new Date(ts * 1000);
+			Zotero.debug("Last successful ZFS sync for library " + libraryID + " was " + date);
+			return ts;
+		}
+		catch (e) {
+			Zotero.logError(e);
+			throw e;
+		}
+	}),
+	
+	
+	setLastSyncTime: Zotero.Promise.coroutine(function* (libraryID) {
+		var params = this._getRequestParams(libraryID, "laststoragesync");
+		var uri = this.apiClient.buildRequestURI(params);
+		
+		try {
+			var req = yield this.apiClient.makeRequest(
+				"POST", uri, { successCodes: [200, 404], debug: true }
+			);
+		}
+		catch (e) {
+			var msg = "Unexpected status code " + e.xmlhttp.status + " setting last file sync time";
+			Zotero.logError(e);
+			throw new Error(Zotero.Sync.Storage.defaultError);
+		}
+		
+		// Not yet synced
+		//
+		// TODO: Don't call this at all if no files uploaded
+		if (req.status == 404) {
+			return;
+		}
+		
+		var time = req.responseText;
+		if (parseInt(time) != time) {
+			Zotero.logError(`Unexpected response ${time} setting last file sync time`);
+			throw new Error(Zotero.Sync.Storage.defaultError);
+		}
+		return parseInt(time);
+	}),
 	
 	
 	/**
-	 * Upload the file to the server
+	 * Begin download process for individual file
 	 *
-	 * @param	{Object}		Object with 'request' property
-	 * @return	{void}
+	 * @param {Zotero.Sync.Storage.Request} request
+	 * @return {Promise<Boolean>} - True if file download, false if not
 	 */
-	function processUploadFile(data) {
-		/*
-		updateSizeMultiplier(
-			(100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100
-		);
-		*/
+	downloadFile: Zotero.Promise.coroutine(function* (request) {
+		var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
+		if (!item) {
+			throw new Error("Item '" + request.name + "' not found");
+		}
 		
-		var request = data.request;
-		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
-		return getStorageFileInfo(item, request)
-			.then(function (info) {
-				if (request.isFinished()) {
-					Zotero.debug("Upload request '" + request.name
-						+ "' is no longer running after getting file info");
-					return false;
-				}
-				
-				// Check for conflict
-				if (Zotero.Sync.Storage.getSyncState(item.id)
-						!= Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD) {
-					if (info) {
-						// Remote mod time
-						var mtime = info.mtime;
-						// Local file time
-						var fmtime = item.attachmentModificationTime;
-						
-						var same = false;
-						var useLocal = false;
-						if (fmtime == mtime) {
-							same = true;
-							Zotero.debug("File mod time matches remote file -- skipping upload");
-						}
-						// Allow floored timestamps for filesystems that don't support
-						// millisecond precision (e.g., HFS+)
-						else if (Math.floor(mtime / 1000) * 1000 == fmtime || Math.floor(fmtime / 1000) * 1000 == mtime) {
-							same = true;
-							Zotero.debug("File mod times are within one-second precision (" + fmtime + " ≅ " + mtime + ") "
-								+ "-- skipping upload");
-						}
-						// Allow timestamp to be exactly one hour off to get around
-						// time zone issues -- there may be a proper way to fix this
-						else if (Math.abs(fmtime - mtime) == 3600000
-								// And check with one-second precision as well
-								|| Math.abs(fmtime - Math.floor(mtime / 1000) * 1000) == 3600000
-								|| Math.abs(Math.floor(fmtime / 1000) * 1000 - mtime) == 3600000) {
-							same = true;
-							Zotero.debug("File mod time (" + fmtime + ") is exactly one hour off remote file (" + mtime + ") "
-								+ "-- assuming time zone issue and skipping upload");
-						}
-						// Ignore maxed-out 32-bit ints, from brief problem after switch to 32-bit servers
-						else if (mtime == 2147483647) {
-							Zotero.debug("Remote mod time is invalid -- uploading local file version");
-							useLocal = true;
-						}
-						
-						if (same) {
-							Zotero.debug(Zotero.Sync.Storage.getSyncedModificationTime(item.id));
-							
-							Zotero.DB.beginTransaction();
-							var syncState = Zotero.Sync.Storage.getSyncState(item.id);
-							//Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime, true);
-							Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime);
-							Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
-							Zotero.DB.commitTransaction();
-							return {
-								localChanges: true,
-								remoteChanges: false
-							};
-						}
-						
-						var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id);
-						if (!useLocal && smtime != mtime) {
-							Zotero.debug("Conflict -- last synced file mod time "
-								+ "does not match time on storage server"
-								+ " (" + smtime + " != " + mtime + ")");
-							return {
-								localChanges: false,
-								remoteChanges: false,
-								conflict: {
-									local: { modTime: fmtime },
-									remote: { modTime: mtime }
-								}
-							};
-						}
-					}
-					else {
-						Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key);
-					}
-				}
-				
-				return getFileUploadParameters(
-					item,
-					function (item, target, uploadKey, params) {
-						return postFile(request, item, target, uploadKey, params);
-					},
-					function () {
-						updateItemFileInfo(item);
-						return {
-							localChanges: true,
-							remoteChanges: false
-						};
-					}
-				);
+		var path = item.getFilePath();
+		if (!path) {
+			Zotero.debug(`Cannot download file for attachment ${item.libraryKey} with no path`);
+			return new Zotero.Sync.Storage.Result;
+		}
+		
+		var destPath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.tmp');
+		
+		// saveURI() below appears not to create empty files for Content-Length: 0,
+		// so we create one here just in case, which also lets us check file access
+		try {
+			let file = yield OS.File.open(destPath, {
+				truncate: true
 			});
-	}
-	
-	
-	/**
-	 * Get mod time of file on storage server
-	 *
-	 * @param	{Zotero.Item}					item
-	 * @param	{Function}		uploadCallback					Callback f(request, item, target, params)
-	 * @param	{Function}		existsCallback					Callback f() to call when file already exists
-	 *																on server and uploading isn't necessary
-	 */
-	function getFileUploadParameters(item, uploadCallback, existsCallback) {
-		var funcName = "Zotero.Sync.Storage.ZFS.getFileUploadParameters()";
-		
-		var uri = getItemURI(item);
-		
-		if (Zotero.Attachments.getNumFiles(item) > 1) {
-			var file = Zotero.getTempDirectory();
-			var filename = item.key + '.zip';
-			file.append(filename);
-			uri.spec = uri.spec;
-			var zip = true;
+			file.close();
 		}
-		else {
-			var file = item.getFile();
-			var filename = file.leafName;
-			var zip = false;
+		catch (e) {
+			Zotero.File.checkFileAccessError(e, destPath, 'create');
 		}
 		
-		var mtime = item.attachmentModificationTime;
-		var hash = Zotero.Utilities.Internal.md5(file);
-		
-		var body = "md5=" + hash + "&filename=" + encodeURIComponent(filename)
-					+ "&filesize=" + file.fileSize + "&mtime=" + mtime;
-		if (zip) {
-			body += "&zip=1";
-		}
-		
-		return Zotero.HTTP.promise("POST", uri, { body: body, headers: _headers, debug: true })
-			.then(function (req) {
-				if (!req.responseXML) {
-					throw new Error("Invalid response retrieving file upload parameters");
-				}
-				
-				var rootTag = req.responseXML.documentElement.tagName;
-				
-				if (rootTag != 'upload' && rootTag != 'exists') {
-					throw new Error("Invalid response retrieving file upload parameters");
-				}
-				
-				// File was already available, so uploading isn't required
-				if (rootTag == 'exists') {
-					return existsCallback();
-				}
-				
-				var url = req.responseXML.getElementsByTagName('url')[0].textContent;
-				var uploadKey = req.responseXML.getElementsByTagName('key')[0].textContent;
-				var params = {}, p = '';
-				var paramNodes = req.responseXML.getElementsByTagName('params')[0].childNodes;
-				for (var i = 0; i < paramNodes.length; i++) {
-					params[paramNodes[i].tagName] = paramNodes[i].textContent;
-				}
-				return uploadCallback(item, url, uploadKey, params);
-			})
-			.catch(function (e) {
-				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
-					if (e.status == 413) {
-						var retry = e.xmlhttp.getResponseHeader('Retry-After');
-						if (retry) {
-							var minutes = Math.round(retry / 60);
-							var e = new Zotero.Error(
-								Zotero.getString('sync.storage.error.zfs.tooManyQueuedUploads', minutes),
-								"ZFS_UPLOAD_QUEUE_LIMIT"
-							);
-							throw e;
-						}
-						
-						var text, buttonText = null, buttonCallback;
-						
-						// Group file
-						if (item.libraryID) {
-							var group = Zotero.Groups.getByLibraryID(item.libraryID);
-							text = Zotero.getString('sync.storage.error.zfs.groupQuotaReached1', group.name) + "\n\n"
-									+ Zotero.getString('sync.storage.error.zfs.groupQuotaReached2');
-						}
-						// Personal file
-						else {
-							text = Zotero.getString('sync.storage.error.zfs.personalQuotaReached1') + "\n\n"
-									+ Zotero.getString('sync.storage.error.zfs.personalQuotaReached2');
-							buttonText = Zotero.getString('sync.storage.openAccountSettings');
-							buttonCallback = function () {
-								var url = "https://www.zotero.org/settings/storage";
-								
-								var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
-											.getService(Components.interfaces.nsIWindowMediator);
-								var win = wm.getMostRecentWindow("navigator:browser");
-								win.ZoteroPane.loadURI(url);
-							}
-						}
-						
-						text += "\n\n" + filename + " (" + Math.round(file.fileSize / 1024) + "KB)";
-						
-						var e = new Zotero.Error(
-							Zotero.getString('sync.storage.error.zfs.fileWouldExceedQuota', filename),
-							"ZFS_OVER_QUOTA",
-							{
-								dialogText: text,
-								dialogButtonText: buttonText,
-								dialogButtonCallback: buttonCallback
-							}
-						);
-						e.errorType = 'warning';
-						Zotero.debug(e, 2);
-						Components.utils.reportError(e);
-						throw e;
-					}
-					else if (e.status == 403) {
-						var groupID = Zotero.Groups.getGroupIDFromLibraryID(item.libraryID);
-						var e = new Zotero.Error(
-							"File editing denied for group",
-							"ZFS_FILE_EDITING_DENIED",
-							{
-								groupID: groupID
-							}
-						);
-						throw e;
-					}
-					else if (e.status == 404) {
-						Components.utils.reportError("Unexpected status code 404 in " + funcName
-									 + " (" + Zotero.Items.getLibraryKeyHash(item) + ")");
-						if (Zotero.Prefs.get('sync.debugNoAutoResetClient')) {
-							Components.utils.reportError("Skipping automatic client reset due to debug pref");
-							return;
-						}
-						if (!Zotero.Sync.Server.canAutoResetClient) {
-							Components.utils.reportError("Client has already been auto-reset -- manual sync required");
-							return;
-						}
-						Zotero.Sync.Server.resetClient();
-						Zotero.Sync.Server.canAutoResetClient = false;
-						throw new Error(Zotero.Sync.Storage.defaultError);
-					}
-					
-					var msg = "Unexpected status code " + e.status + " in " + funcName
-								 + " (" + Zotero.Items.getLibraryKeyHash(item) + ")";
-					Zotero.debug(msg, 1);
-					Zotero.debug(e.xmlhttp.getAllResponseHeaders());
-					Components.utils.reportError(msg);
-					throw new Error(Zotero.Sync.Storage.defaultError);
-				}
-				
-				throw e;
-			});
-	}
-	
-	
-	function postFile(request, item, url, uploadKey, params) {
-		if (request.isFinished()) {
-			Zotero.debug("Upload request " + request.name + " is no longer running after getting upload parameters");
-			return false;
-		}
-		
-		var file = getUploadFile(item);
-		
-		// TODO: make sure this doesn't appear in file
-		var boundary = "---------------------------" + Math.random().toString().substr(2);
-		
-		var mis = Components.classes["@mozilla.org/io/multiplex-input-stream;1"]
-					.createInstance(Components.interfaces.nsIMultiplexInputStream);
-		
-		// Add parameters
-		for (var key in params) {
-			var storage = Components.classes["@mozilla.org/storagestream;1"]
-							.createInstance(Components.interfaces.nsIStorageStream);
-			storage.init(4096, 4294967295, null); // PR_UINT32_MAX
-			var out = storage.getOutputStream(0);
-			
-			var conv = Components.classes["@mozilla.org/intl/converter-output-stream;1"]
-							.createInstance(Components.interfaces.nsIConverterOutputStream);
-			conv.init(out, null, 4096, "?");
-			
-			var str = "--" + boundary + '\r\nContent-Disposition: form-data; name="' + key + '"'
-						+ '\r\n\r\n' + params[key] + '\r\n';
-			conv.writeString(str);
-			conv.close();
-			
-			var instr = storage.newInputStream(0);
-			mis.appendStream(instr);
-		}
-		
-		// Add file
-		var sis = Components.classes["@mozilla.org/io/string-input-stream;1"]
-					.createInstance(Components.interfaces.nsIStringInputStream);
-		var str = "--" + boundary + '\r\nContent-Disposition: form-data; name="file"\r\n\r\n';
-		sis.setData(str, -1);
-		mis.appendStream(sis);
-		
-		var fis = Components.classes["@mozilla.org/network/file-input-stream;1"]
-					.createInstance(Components.interfaces.nsIFileInputStream);
-		fis.init(file, 0x01, 0, Components.interfaces.nsIFileInputStream.CLOSE_ON_EOF
-			| Components.interfaces.nsIFileInputStream.REOPEN_ON_REWIND);
-		
-		var bis = Components.classes["@mozilla.org/network/buffered-input-stream;1"]
-					.createInstance(Components.interfaces.nsIBufferedInputStream)
-		bis.init(fis, 64 * 1024);
-		mis.appendStream(bis);
-		
-		// End request
-		var sis = Components.classes["@mozilla.org/io/string-input-stream;1"]
-					.createInstance(Components.interfaces.nsIStringInputStream);
-		var str = "\r\n--" + boundary + "--";
-		sis.setData(str, -1);
-		mis.appendStream(sis);
-		
-		
-	/*	var cstream = Components.classes["@mozilla.org/intl/converter-input-stream;1"].
-		createInstance(Components.interfaces.nsIConverterInputStream);
-		cstream.init(mis, "UTF-8", 0, 0); // you can use another encoding here if you wish
-		
-		let (str = {}) {
-			cstream.readString(-1, str); // read the whole file and put it in str.value
-			data = str.value;
-		}
-		cstream.close(); // this closes fstream
-		alert(data);
-	*/	
-		
-		var ios = Components.classes["@mozilla.org/network/io-service;1"].
-					getService(Components.interfaces.nsIIOService);
-		var uri = ios.newURI(url, null, null);
-		var channel = ios.newChannelFromURI(uri);
-		
-		channel.QueryInterface(Components.interfaces.nsIUploadChannel);
-		channel.setUploadStream(mis, "multipart/form-data", -1);
-		channel.QueryInterface(Components.interfaces.nsIHttpChannel);
-		channel.requestMethod = 'POST';
-		channel.allowPipelining = false;
-		channel.setRequestHeader('Keep-Alive', '', false);
-		channel.setRequestHeader('Connection', '', false);
-		channel.setRequestHeader("Content-Type", "multipart/form-data; boundary=" + boundary, false);
-		//channel.setRequestHeader('Date', date, false);
-		
-		request.setChannel(channel);
-		
 		var deferred = Zotero.Promise.defer();
+		var requestData = {item};
 		
 		var listener = new Zotero.Sync.Storage.StreamListener(
 			{
-				onProgress: function (a, b, c) {
-					request.onProgress(a, b, c);
+				onStart: function (req) {
+					if (request.isFinished()) {
+						Zotero.debug("Download request " + request.name
+							+ " stopped before download started -- closing channel");
+						req.cancel(Components.results.NS_BINDING_ABORTED);
+						deferred.resolve(false);
+					}
 				},
-				onStop: function (httpRequest, status, response, data) {
-					data.request.setChannel(false);
+				onChannelRedirect: Zotero.Promise.coroutine(function* (oldChannel, newChannel, flags) {
+					// These will be used in processDownload() if the download succeeds
+					oldChannel.QueryInterface(Components.interfaces.nsIHttpChannel);
 					
-					// For timeouts and failures from S3, which happen intermittently,
-					// wait a little and try again
-					let timeoutMessage = "Your socket connection to the server was not read from or "
-						+ "written to within the timeout period.";
-					if (status == 0 || (status == 400 && response.indexOf(timeoutMessage) != -1)) {
-						if (_s3ConsecutiveFailures >= _maxS3ConsecutiveFailures) {
-							Zotero.debug(_s3ConsecutiveFailures
-								+ " consecutive S3 failures -- aborting", 1);
-							_s3ConsecutiveFailures = 0;
+					Zotero.debug("CHANNEL HERE FOR " + item.libraryKey + " WITH " + oldChannel.status);
+					Zotero.debug(oldChannel.URI.spec);
+					Zotero.debug(newChannel.URI.spec);
+					
+					var header;
+					try {
+						header = "Zotero-File-Modification-Time";
+						requestData.mtime = oldChannel.getResponseHeader(header);
+						header = "Zotero-File-MD5";
+						requestData.md5 = oldChannel.getResponseHeader(header);
+						header = "Zotero-File-Compressed";
+						requestData.compressed = oldChannel.getResponseHeader(header) == 'Yes';
+					}
+					catch (e) {
+						deferred.reject(new Error(`${header} header not set in file request for ${item.libraryKey}`));
+						return false;
+					}
+					
+					if (!(yield OS.File.exists(path))) {
+						return true;
+					}
+					
+					var updateHash = false;
+					var fileModTime = yield item.attachmentModificationTime;
+					if (requestData.mtime == fileModTime) {
+						Zotero.debug("File mod time matches remote file -- skipping download of "
+							+ item.libraryKey);
+					}
+					// If not compressed, check hash, in case only timestamp changed
+					else if (!requestData.compressed && (yield item.attachmentHash) == requestData.md5) {
+						Zotero.debug("File hash matches remote file -- skipping download of "
+							+ item.libraryKey);
+						updateHash = true;
+					}
+					else {
+						return true;
+					}
+					
+					// Update local metadata and stop request, skipping file download
+					yield Zotero.DB.executeTransaction(function* () {
+						if (updateHash) {
+							yield Zotero.Sync.Storage.Local.setSyncedHash(item.id, requestData.md5);
 						}
-						else {
-							let libraryKey = Zotero.Items.getLibraryKeyHash(item);
-							let msg = "S3 returned " + status
-								+ " (" + libraryKey + ") -- retrying upload"
-							Components.utils.reportError(msg);
-							Zotero.debug(msg, 1);
-							Zotero.debug(response, 1);
-							if (_s3Backoff < _maxS3Backoff) {
-								_s3Backoff *= 2;
-							}
-							_s3ConsecutiveFailures++;
-							Zotero.debug("Delaying " + libraryKey + " upload for "
-								+ _s3Backoff + " seconds", 2);
-							Q.delay(_s3Backoff * 1000)
-							.then(function () {
-								deferred.resolve(postFile(request, item, url, uploadKey, params));
-							});
+						yield Zotero.Sync.Storage.Local.setSyncedModificationTime(
+							item.id, requestData.mtime
+						);
+						yield Zotero.Sync.Storage.Local.setSyncState(
+							item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+						);
+					});
+					return false;
+				}),
+				onProgress: function (a, b, c) {
+					request.onProgress(a, b, c)
+				},
+				onStop: function (req, status, res) {
+					request.setChannel(false);
+					
+					if (status != 200) {
+						if (status == 404) {
+							Zotero.debug("Remote file not found for item " + item.libraryKey);
+							deferred.resolve(new Zotero.Sync.Storage.Result);
 							return;
 						}
+						
+						// If S3 connection is interrupted, delay and retry, or bail if too many
+						// consecutive failures
+						if (status == 0) {
+							if (this._s3ConsecutiveFailures < this._maxS3ConsecutiveFailures) {
+								let libraryKey = item.libraryKey;
+								let msg = "S3 returned 0 for " + libraryKey + " -- retrying download"
+								Components.utils.reportError(msg);
+								Zotero.debug(msg, 1);
+								if (this._s3Backoff < this._maxS3Backoff) {
+									this._s3Backoff *= 2;
+								}
+								this._s3ConsecutiveFailures++;
+								Zotero.debug("Delaying " + libraryKey + " download for "
+									+ this._s3Backoff + " seconds", 2);
+								Zotero.Promise.delay(this._s3Backoff * 1000)
+								.then(function () {
+									deferred.resolve(this._downloadFile(request));
+								});
+								return;
+							}
+							
+							Zotero.debug(this._s3ConsecutiveFailures
+								+ " consecutive S3 failures -- aborting", 1);
+							this._s3ConsecutiveFailures = 0;
+						}
+						
+						var msg = "Unexpected status code " + status + " for GET " + uri;
+						Zotero.debug(msg, 1);
+						Components.utils.reportError(msg);
+						// Output saved content, in case an error was captured
+						try {
+							let sample = Zotero.File.getContents(destPath, null, 4096);
+							if (sample) {
+								Zotero.debug(sample, 1);
+							}
+						}
+						catch (e) {
+							Zotero.debug(e, 1);
+						}
+						deferred.reject(new Error(Zotero.Sync.Storage.defaultError));
+						return;
 					}
 					
-					deferred.resolve(onUploadComplete(httpRequest, status, response, data));
-				},
-				onCancel: function (httpRequest, status, data) {
-					onUploadCancel(httpRequest, status, data)
-					deferred.resolve(false);
-				},
-				request: request,
-				item: item,
-				uploadKey: uploadKey,
-				streams: [mis]
+					// Don't try to process if the request has been cancelled
+					if (request.isFinished()) {
+						Zotero.debug("Download request " + request.name
+							+ " is no longer running after file download", 2);
+						deferred.resolve(false);
+						return;
+					}
+					
+					Zotero.debug("Finished download of " + destPath);
+					
+					try {
+						deferred.resolve(
+							Zotero.Sync.Storage.Local.processDownload(requestData)
+						);
+					}
+					catch (e) {
+						Zotero.debug("REJECTING");
+						deferred.reject(e);
+					}
+				}.bind(this),
+				onCancel: function (req, status) {
+					Zotero.debug("Request cancelled");
+					if (deferred.promise.isPending()) {
+						deferred.resolve(false);
+					}
+				}
 			}
 		);
-		channel.notificationCallbacks = listener;
 		
-		var dispURI = uri.clone();
-		if (dispURI.password) {
-			dispURI.password = '********';
-		}
-		Zotero.debug("HTTP POST of " + file.leafName + " to " + dispURI.spec);
+		var params = this._getRequestParams(item.libraryID, `items/${item.key}/file`);
+		var uri = this.apiClient.buildRequestURI(params);
+		var headers = this.apiClient.getHeaders();
 		
-		channel.asyncOpen(listener, null);
+		Zotero.debug('Saving ' + uri);
+		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;
+		Zotero.Utilities.Internal.saveURI(wbp, uri, destPath, headers);
 		
 		return deferred.promise;
-	}
+	}),
 	
 	
-	function onUploadComplete(httpRequest, status, response, data) {
-		return Q.try(function () {
-			var request = data.request;
-			var item = data.item;
-			var uploadKey = data.uploadKey;
-			
-			Zotero.debug("Upload of attachment " + item.key
-				+ " finished with status code " + status);
-			
-			Zotero.debug(response);
-			
-			switch (status) {
-				case 201:
-					// Decrease backoff delay on successful upload
-					if (_s3Backoff > 1) {
-						_s3Backoff /= 2;
-					}
-					// And reset consecutive failures
-					_s3ConsecutiveFailures = 0;
-					break;
-				
-				case 500:
-					throw new Error("File upload failed. Please try again.");
-				
-				default:
-					var msg = "Unexpected file upload status " + status
-						+ " in Zotero.Sync.Storage.ZFS.onUploadComplete()"
-						+ " (" + Zotero.Items.getLibraryKeyHash(item) + ")";
-					Zotero.debug(msg, 1);
-					Components.utils.reportError(msg);
-					Components.utils.reportError(response);
-					throw new Error(Zotero.Sync.Storage.defaultError);
+	uploadFile: Zotero.Promise.coroutine(function* (request) {
+		var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
+		if (yield Zotero.Attachments.hasMultipleFiles(item)) {
+			let created = yield Zotero.Sync.Storage.Utilities.createUploadFile(request);
+			if (!created) {
+				return new Zotero.Sync.Storage.Result;
 			}
-			
-			var uri = getItemURI(item);
-			var body = "update=" + uploadKey + "&mtime=" + item.attachmentModificationTime;
-			
-			// Register upload on server
-			return Zotero.HTTP.promise("POST", uri, { body: body, headers: _headers, successCodes: [204] })
-				.then(function (req) {
-					updateItemFileInfo(item);
-					return {
-						localChanges: true,
-						remoteChanges: true
-					};
-				})
-				.catch(function (e) {
-					var msg = "Unexpected file registration status " + e.status
-						+ " (" + Zotero.Items.getLibraryKeyHash(item) + ")";
-					Zotero.debug(msg, 1);
-					Zotero.debug(e.xmlhttp.responseText);
-					Zotero.debug(e.xmlhttp.getAllResponseHeaders());
-					Components.utils.reportError(msg);
-					Components.utils.reportError(e.xmlhttp.responseText);
-					throw new Error(Zotero.Sync.Storage.defaultError);
-				});
-		});
-	}
+			return this._processUploadFile(request);
+		}
+		return this._processUploadFile(request);
+	}),
 	
 	
-	function updateItemFileInfo(item) {
-		// Mark as changed locally
-		Zotero.DB.beginTransaction();
-		Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
-		
-		// Store file mod time
-		var mtime = item.attachmentModificationTime;
-		Zotero.Sync.Storage.setSyncedModificationTime(item.id, mtime, true);
-		
-		// Store file hash of individual files
-		if (Zotero.Attachments.getNumFiles(item) == 1) {
-			var hash = item.attachmentHash;
-			Zotero.Sync.Storage.setSyncedHash(item.id, hash);
+	/**
+	 * Remove all synced files from the server
+	 */
+	purgeDeletedStorageFiles: Zotero.Promise.coroutine(function* () {
+		var sql = "SELECT value FROM settings WHERE setting=? AND key=?";
+		var values = yield Zotero.DB.columnQueryAsync(sql, ['storage', 'zfsPurge']);
+		if (!values) {
+			return false;
 		}
 		
-		Zotero.DB.commitTransaction();
+		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:
+					throw new Error("Invalid zfsPurge value '" + value + "'");
+			}
+		}
+		uri.spec = uri.spec.substr(0, uri.spec.length - 1);
+		
+		yield Zotero.HTTP.request("POST", uri, "");
+		
+		var sql = "DELETE FROM settings WHERE setting=? AND key=?";
+		yield Zotero.DB.queryAsync(sql, ['storage', 'zfsPurge']);
+	}),
+	
+	
+	//
+	// Private methods
+	//
+	_getRequestParams: function (libraryID, target) {
+		var library = Zotero.Libraries.get(libraryID);
+		return {
+			libraryType: library.libraryType,
+			libraryTypeID: library.libraryTypeID,
+			target
+		};
+	},
+	
+	
+	/**
+	 * Get authorization from API for uploading file
+	 *
+	 * @param {Zotero.Item} item
+	 * @return {Object|String} - Object with upload params or 'exists'
+	 */
+	_getFileUploadParameters: Zotero.Promise.coroutine(function* (item) {
+		var funcName = "Zotero.Sync.Storage.ZFS._getFileUploadParameters()";
+		
+		var path = item.getFilePath();
+		var filename = OS.Path.basename(path);
+		var zip = yield Zotero.Attachments.hasMultipleFiles(item);
+		if (zip) {
+			var uploadPath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.zip');
+		}
+		else {
+			var uploadPath = path;
+		}
+		
+		var params = this._getRequestParams(item.libraryID, `items/${item.key}/file`);
+		var uri = this.apiClient.buildRequestURI(params);
+		
+		// TODO: One-step uploads
+		/*var headers = {
+			"Content-Type": "application/json"
+		};
+		var storedHash = yield Zotero.Sync.Storage.Local.getSyncedHash(item.id);
+		//var storedModTime = yield Zotero.Sync.Storage.getSyncedModificationTime(item.id);
+		if (storedHash) {
+			headers["If-Match"] = storedHash;
+		}
+		else {
+			headers["If-None-Match"] = "*";
+		}
+		var mtime = yield item.attachmentModificationTime;
+		var hash = Zotero.Utilities.Internal.md5(file);
+		var json = {
+			md5: hash,
+			mtime,
+			filename,
+			size: file.fileSize
+		};
+		var charset = item.attachmentCharset;
+		var contentType = item.attachmentContentType;
+		if (charset) {
+			json.charset = charset;
+		}
+		if (contentType) {
+			json.contentType = contentType;
+		}
+		if (zip) {
+			json.zip = true;
+		}
 		
 		try {
-			if (Zotero.Attachments.getNumFiles(item) > 1) {
+			var req = yield this.apiClient.makeRequest(
+				"POST", uri, { body: JSON.stringify(json), headers, debug: true }
+			);
+		}*/
+		
+		var headers = {
+			"Content-Type": "application/x-www-form-urlencoded"
+		};
+		var storedHash = yield Zotero.Sync.Storage.Local.getSyncedHash(item.id);
+		//var storedModTime = yield Zotero.Sync.Storage.getSyncedModificationTime(item.id);
+		if (storedHash) {
+			headers["If-Match"] = storedHash;
+		}
+		else {
+			headers["If-None-Match"] = "*";
+		}
+		
+		// Build POST body
+		var mtime = yield item.attachmentModificationTime;
+		var params = {
+			md5: yield item.attachmentHash,
+			mtime,
+			filename,
+			filesize: (yield OS.File.stat(uploadPath)).size
+		};
+		var charset = item.attachmentCharset;
+		var contentType = item.attachmentContentType;
+		if (charset) {
+			params.charset = charset;
+		}
+		if (contentType) {
+			params.contentType = contentType;
+		}
+		if (zip) {
+			params.zipMD5 = yield Zotero.Utilities.Internal.md5Async(uploadPath);
+			params.zipFilename = OS.Path.basename(uploadPath);
+		}
+		var body = [];
+		for (let i in params) {
+			body.push(i + "=" + encodeURIComponent(params[i]));
+		}
+		body = body.join('&');
+		
+		try {
+			var req = yield this.apiClient.makeRequest(
+				"POST",
+				uri,
+				{
+					body,
+					headers,
+					// This should include all errors in _handleUploadAuthorizationFailure()
+					successCodes: [200, 201, 204, 403, 404, 412, 413],
+					debug: true
+				}
+			);
+		}
+		catch (e) {
+			if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
+				let msg = "Unexpected status code " + e.status + " in " + funcName
+					 + " (" + item.libraryKey + ")";
+				Zotero.logError(msg);
+				Zotero.debug(e.xmlhttp.getAllResponseHeaders());
+				throw new Error(Zotero.Sync.Storage.defaultError);
+			}
+			throw e;
+		}
+		
+		var result = yield this._handleUploadAuthorizationFailure(req, item);
+		if (result instanceof Zotero.Sync.Storage.Result) {
+			return result;
+		}
+		
+		try {
+			var json = JSON.parse(req.responseText);
+		}
+		catch (e) {
+			Zotero.logError(e);
+			Zotero.debug(req.responseText, 1);
+		}
+		if (!json) {
+			 throw new Error("Invalid response retrieving file upload parameters");
+		}
+		
+		if (!json.uploadKey && !json.exists) {
+			throw new Error("Invalid response retrieving file upload parameters");
+		}
+		
+		if (json.exists) {
+			let version = req.getResponseHeader('Last-Modified-Version');
+			if (!version) {
+				throw new Error("Last-Modified-Version not provided");
+			}
+			json.version = version;
+		}
+		
+		Zotero.debug('=-=-=--=');
+		Zotero.debug(json);
+		
+		// TEMP
+		//
+		// Passed through to _updateItemFileInfo()
+		json.mtime = mtime;
+		json.md5 = params.md5;
+		if (storedHash) {
+			json.storedHash = storedHash;
+		}
+		
+		return json;
+	}),
+	
+	
+	/**
+	 * Handle known errors from upload authorization request
+	 *
+	 * These must be included in successCodes in _getFileUploadParameters()
+	 */
+	_handleUploadAuthorizationFailure: Zotero.Promise.coroutine(function* (req, item) {
+		//
+		// These must be included in successCodes above.
+		// TODO: 429?
+		if (req.status == 403) {
+			let groupID = Zotero.Groups.getGroupIDFromLibraryID(item.libraryID);
+			let e = new Zotero.Error(
+				"File editing denied for group",
+				"ZFS_FILE_EDITING_DENIED",
+				{
+					groupID: groupID
+				}
+			);
+			throw e;
+		}
+		else if (req.status == 404) {
+			Components.utils.reportError("Unexpected status code 404 in upload authorization "
+				+ "request (" + item.libraryKey + ")");
+			if (Zotero.Prefs.get('sync.debugNoAutoResetClient')) {
+				Components.utils.reportError("Skipping automatic client reset due to debug pref");
+			}
+			if (!Zotero.Sync.Server.canAutoResetClient) {
+				Components.utils.reportError("Client has already been auto-reset -- manual sync required");
+			}
+			
+			// TODO: Make an API request to fix this
+			
+			throw new Error(Zotero.Sync.Storage.defaultError);
+		}
+		else if (req.status == 412) {
+			Zotero.debug("412 BUT WE'RE COOL");
+			let version = req.getResponseHeader('Last-Modified-Version');
+			if (!version) {
+				throw new Error("Last-Modified-Version header not provided");
+			}
+			if (version > item.version) {
+				return new Zotero.Sync.Storage.Result({
+					syncRequired: true
+				});
+			}
+			if (version < item.version) {
+				throw new Error("Last-Modified-Version is lower than item version "
+					+ `(${version} < ${item.version})`);
+			}
+			
+			// Get updated item metadata
+			let library = Zotero.Libraries.get(item.libraryID);
+			let json = yield this.apiClient.downloadObjects(
+				library.libraryType,
+				library.libraryTypeID,
+				'item',
+				[item.key]
+			)[0];
+			if (!Array.isArray(json)) {
+				Zotero.logError(json);
+				throw new Error(Zotero.Sync.Storage.defaultError);
+			}
+			if (json.length > 1) {
+				throw new Error("More than one result for item lookup");
+			}
+			
+			yield Zotero.Sync.Data.Local.saveCacheObjects('item', item.libraryID, json);
+			json = json[0];
+			
+			if (json.data.version > item.version) {
+				return new Zotero.Sync.Storage.Result({
+					syncRequired: true
+				});
+			}
+			
+			let fileHash = yield item.attachmentHash;
+			let fileModTime = yield item.attachmentModificationTime;
+			
+			Zotero.debug("MD5");
+			Zotero.debug(json.data.md5);
+			Zotero.debug(fileHash);
+			
+			if (json.data.md5 == fileHash) {
+				yield Zotero.DB.executeTransaction(function* () {
+					yield Zotero.Sync.Storage.Local.setSyncedModificationTime(
+						item.id, fileModTime
+					);
+					yield Zotero.Sync.Storage.Local.setSyncedHash(item.id, fileHash);
+					yield Zotero.Sync.Storage.Local.setSyncState(
+						item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+					);
+				});
+				return new Zotero.Sync.Storage.Result;
+			}
+			
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				item.id, Zotero.Sync.Storage.SYNC_STATE_IN_CONFLICT
+			);
+			return new Zotero.Sync.Storage.Result({
+				fileSyncRequired: true
+			});
+		}
+		else if (req.status == 413) {
+			let retry = req.getResponseHeader('Retry-After');
+			if (retry) {
+				let minutes = Math.round(retry / 60);
+				throw new Zotero.Error(
+					Zotero.getString('sync.storage.error.zfs.tooManyQueuedUploads', minutes),
+					"ZFS_UPLOAD_QUEUE_LIMIT"
+				);
+			}
+			
+			let text, buttonText = null, buttonCallback;
+			
+			// Group file
+			if (item.libraryID) {
+				var group = Zotero.Groups.getByLibraryID(item.libraryID);
+				text = Zotero.getString('sync.storage.error.zfs.groupQuotaReached1', group.name) + "\n\n"
+						+ Zotero.getString('sync.storage.error.zfs.groupQuotaReached2');
+			}
+			// Personal file
+			else {
+				text = Zotero.getString('sync.storage.error.zfs.personalQuotaReached1') + "\n\n"
+						+ Zotero.getString('sync.storage.error.zfs.personalQuotaReached2');
+				buttonText = Zotero.getString('sync.storage.openAccountSettings');
+				buttonCallback = function () {
+					var url = "https://www.zotero.org/settings/storage";
+					
+					var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
+								.getService(Components.interfaces.nsIWindowMediator);
+					var win = wm.getMostRecentWindow("navigator:browser");
+					win.ZoteroPane.loadURI(url);
+				}
+			}
+			
+			text += "\n\n" + filename + " (" + Math.round(file.fileSize / 1024) + "KB)";
+			
+			let e = new Zotero.Error(
+				Zotero.getString('sync.storage.error.zfs.fileWouldExceedQuota', filename),
+				"ZFS_OVER_QUOTA",
+				{
+					dialogText: text,
+					dialogButtonText: buttonText,
+					dialogButtonCallback: buttonCallback
+				}
+			);
+			e.errorType = 'warning';
+			Zotero.debug(e, 2);
+			Components.utils.reportError(e);
+			throw e;
+		}
+	}),
+	
+	
+	/**
+	 * Given parameters from authorization, upload file to S3
+	 */
+	_uploadFile: Zotero.Promise.coroutine(function* (request, item, params) {
+		if (request.isFinished()) {
+			Zotero.debug("Upload request " + request.name + " is no longer running after getting "
+				+ "upload parameters");
+			return new Zotero.Sync.Storage.Result;
+		}
+		
+		var file = yield this._getUploadFile(item);
+		
+		Components.utils.importGlobalProperties(["File"]);
+		file = File(file);
+		
+		var blob = new Blob([params.prefix, file, params.suffix]);
+		
+		try {
+			var req = yield Zotero.HTTP.request(
+				"POST",
+				params.url,
+				{
+					headers: {
+						"Content-Type": params.contentType
+					},
+					body: blob,
+					requestObserver: function (req) {
+						request.setChannel(req.channel);
+						req.upload.addEventListener("progress", function (event) {
+							if (event.lengthComputable) {
+								request.onProgress(event.loaded, event.total);
+							}
+						});
+					},
+					debug: true,
+					successCodes: [201]
+				}
+			);
+		}
+		catch (e) {
+			// For timeouts and failures from S3, which happen intermittently,
+			// wait a little and try again
+			let timeoutMessage = "Your socket connection to the server was not read from or "
+				+ "written to within the timeout period.";
+			if (e.status == 0
+					|| (e.status == 400 && e.xmlhttp.responseText.indexOf(timeoutMessage) != -1)) {
+				if (this._s3ConsecutiveFailures >= this._maxS3ConsecutiveFailures) {
+					Zotero.debug(this._s3ConsecutiveFailures
+						+ " consecutive S3 failures -- aborting", 1);
+					this._s3ConsecutiveFailures = 0;
+				}
+				else {
+					let msg = "S3 returned " + e.status + " (" + item.libraryKey + ") "
+						+ "-- retrying upload"
+					Zotero.logError(msg);
+					Zotero.debug(e.xmlhttp.responseText, 1);
+					if (this._s3Backoff < this._maxS3Backoff) {
+						this._s3Backoff *= 2;
+					}
+					this._s3ConsecutiveFailures++;
+					Zotero.debug("Delaying " + item.libraryKey + " upload for "
+						+ this._s3Backoff + " seconds", 2);
+					yield Zotero.Promise.delay(this._s3Backoff * 1000);
+					return this._uploadFile(request, item, params);
+				}
+			}
+			else if (e.status == 500) {
+				// TODO: localize
+				throw new Error("File upload failed. Please try again.");
+			}
+			else {
+				Zotero.logError(`Unexpected file upload status ${e.status} (${item.libraryKey})`);
+				Zotero.debug(e, 1);
+				Components.utils.reportError(e.xmlhttp.responseText);
+				throw new Error(Zotero.Sync.Storage.defaultError);
+			}
+			
+			// TODO: Detect cancel?
+			//onUploadCancel(httpRequest, status, data)
+			//deferred.resolve(false);
+		}
+		
+		request.setChannel(false);
+		return this._onUploadComplete(req, request, item, params);
+	}),
+	
+	
+	/**
+	 * Post-upload file registration with API
+	 */
+	_onUploadComplete: Zotero.Promise.coroutine(function* (req, request, item, params) {
+		var uploadKey = params.uploadKey;
+		
+		Zotero.debug("Upload of attachment " + item.key + " finished with status code " + req.status);
+		Zotero.debug(req.responseText);
+		
+		// Decrease backoff delay on successful upload
+		if (this._s3Backoff > 1) {
+			this._s3Backoff /= 2;
+		}
+		// And reset consecutive failures
+		this._s3ConsecutiveFailures = 0;
+		
+		var requestParams = this._getRequestParams(item.libraryID, `items/${item.key}/file`);
+		var uri = this.apiClient.buildRequestURI(requestParams);
+		var headers = {
+			"Content-Type": "application/x-www-form-urlencoded"
+		};
+		if (params.storedHash) {
+			headers["If-Match"] = params.storedHash;
+		}
+		else {
+			headers["If-None-Match"] = "*";
+		}
+		var body = "upload=" + uploadKey;
+		
+		// Register upload on server
+		try {
+			req = yield this.apiClient.makeRequest(
+				"POST",
+				uri,
+				{
+					body,
+					headers,
+					successCodes: [204],
+					requestObserver: function (xmlhttp) {
+						request.setChannel(xmlhttp.channel);
+					}
+				}
+			);
+		}
+		catch (e) {
+			let msg = `Unexpected file registration status ${e.status} (${item.libraryKey})`;
+			Zotero.logError(msg);
+			Zotero.logError(e.xmlhttp.responseText);
+			Zotero.debug(e.xmlhttp.getAllResponseHeaders());
+			throw new Error(Zotero.Sync.Storage.defaultError);
+		}
+		
+		var version = req.getResponseHeader('Last-Modified-Version');
+		if (!version) {
+			throw new Error("Last-Modified-Version not provided");
+		}
+		params.version = version;
+		
+		yield this._updateItemFileInfo(item, params);
+		
+		return new Zotero.Sync.Storage.Result({
+			localChanges: true,
+			remoteChanges: true
+		});
+	}),
+	
+	
+	/**
+	 * Update the local attachment item with the mtime and hash of the uploaded file and the
+	 * library version returned by the upload request, and save a modified version of the item
+	 * to the sync cache
+	 */
+	_updateItemFileInfo: Zotero.Promise.coroutine(function* (item, params) {
+		// Mark as in-sync
+		yield Zotero.DB.executeTransaction(function* () {
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+			);
+			
+			// Store file mod time and hash
+			yield Zotero.Sync.Storage.Local.setSyncedModificationTime(item.id, params.mtime);
+			yield Zotero.Sync.Storage.Local.setSyncedHash(item.id, params.md5);
+			// Update sync cache with new file metadata and version from server
+			var json = yield Zotero.Sync.Data.Local.getCacheObject(
+				'item', item.libraryID, item.key, item.version
+			);
+			if (json) {
+				json.version = params.version;
+				json.data.version = params.version;
+				json.data.mtime = params.mtime;
+				json.data.md5 = params.md5;
+				yield Zotero.Sync.Data.Local.saveCacheObject('item', item.libraryID, json);
+			}
+			// Update item with new version from server
+			yield Zotero.Items.updateVersion([item.id], params.version);
+			
+			// TODO: Can filename, contentType, and charset change the attachment item?
+		});
+		
+		try {
+			if (yield Zotero.Attachments.hasMultipleFiles(item)) {
 				var file = Zotero.getTempDirectory();
 				file.append(item.key + '.zip');
-				file.remove(false);
+				yield OS.File.remove(file.path);
 			}
 		}
 		catch (e) {
 			Components.utils.reportError(e);
 		}
-	}
+	}),
 	
 	
-	function onUploadCancel(httpRequest, status, data) {
+	_onUploadCancel: Zotero.Promise.coroutine(function* (httpRequest, status, data) {
 		var request = data.request;
 		var item = data.item;
 		
 		Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status);
 		
 		try {
-			if (Zotero.Attachments.getNumFiles(item) > 1) {
+			if (yield Zotero.Attachments.hasMultipleFiles(item)) {
 				var file = Zotero.getTempDirectory();
 				file.append(item.key + '.zip');
 				file.remove(false);
@@ -631,40 +900,11 @@ Zotero.Sync.Storage.ZFS = (function () {
 		catch (e) {
 			Components.utils.reportError(e);
 		}
-	}
+	}),
 	
 	
-	/**
-	 * Get the storage URI for an item
-	 *
-	 * @inner
-	 * @param	{Zotero.Item}
-	 * @return	{nsIURI}					URI of file on storage server
-	 */
-	function getItemURI(item) {
-		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;
-	}
-	
-	
-	/**
-	 * Get the storage info URI for an item
-	 *
-	 * @inner
-	 * @param	{Zotero.Item}
-	 * @return	{nsIURI}					URI of file on storage server with info flag
-	 */
-	function getItemInfoURI(item) {
-		var uri = Zotero.Sync.Storage.ZFS.rootURI;
-		uri.spec += Zotero.URI.getItemPath(item) + '/file?auth=1&iskey=1&version=1&info=1';
-		return uri;
-	}
-	
-	
-	function getUploadFile(item) {
-		if (Zotero.Attachments.getNumFiles(item) > 1) {
+	_getUploadFile: Zotero.Promise.coroutine(function* (item) {
+		if (yield Zotero.Attachments.hasMultipleFiles(item)) {
 			var file = Zotero.getTempDirectory();
 			var filename = item.key + '.zip';
 			file.append(filename);
@@ -673,500 +913,169 @@ Zotero.Sync.Storage.ZFS = (function () {
 			var file = item.getFile();
 		}
 		return file;
-	}
+	}),
 	
 	
-	//
-	// 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';
-		}
-	});
-	
-	Object.defineProperty(obj, "includeGroupFiles", {
-		get: function () {
-			return Zotero.Prefs.get("sync.storage.groups.enabled");
-		}
-	});
-	
-	obj._verified = true;
-	
-	Object.defineProperty(obj, "rootURI", {
-		get: function () {
-			if (!_rootURI) {
-				this._init();
-			}
-			return _rootURI.clone();
-		}
-	});
-	
-	Object.defineProperty(obj, "userURI", {
-		get: function () {
-			if (!_userURI) {
-				this._init();
-			}
-			return _userURI.clone();
-		}
-	});
-	
-	
-	obj._init = function () {
-		_rootURI = false;
-		_userURI = false;
-		
-		var url = ZOTERO_CONFIG.API_URL;
-		var username = Zotero.Sync.Server.username;
-		var password = Zotero.Sync.Server.password;
-		
-		var ios = Components.classes["@mozilla.org/network/io-service;1"].
-					getService(Components.interfaces.nsIIOService);
-		var uri = ios.newURI(url, null, null);
-		uri.username = encodeURIComponent(username);
-		uri.password = encodeURIComponent(password);
-		_rootURI = uri;
-		
-		uri = uri.clone();
-		uri.spec += 'users/' + Zotero.Users.getCurrentUserID() + '/';
-		_userURI = uri;
-	};
-	
-	obj.clearCachedCredentials = function() {
-		_rootURI = _userURI = undefined;
-		_cachedCredentials = false;
-	};
-	
 	/**
-	 * Begin download process for individual file
+	 * Get attachment item metadata on storage server
 	 *
-	 * @param	{Zotero.Sync.Storage.Request}	[request]
+	 * @param {Zotero.Item} item
+	 * @param {Zotero.Sync.Storage.Request} request
+	 * @return {Promise<Object>|false} - Promise for object with 'hash', 'filename', 'mtime',
+	 *                                   'compressed', or false if item not found
 	 */
-	obj._downloadFile = function (request) {
-		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
-		if (!item) {
-			throw new Error("Item '" + request.name + "' not found");
-		}
+	_getStorageFileInfo: Zotero.Promise.coroutine(function* (item, request) {
+		var funcName = "Zotero.Sync.Storage.ZFS._getStorageFileInfo()";
 		
-		var self = this;
+		var params = this._getRequestParams(item.libraryID, `items/${item.key}/file`);
+		var uri = this.apiClient.buildRequestURI(params);
 		
-		// Retrieve file info from server to store locally afterwards
-		return getStorageFileInfo(item, request)
-			.then(function (info) {
-				if (!request.isRunning()) {
-					Zotero.debug("Download request '" + request.name
-						+ "' is no longer running after getting remote file info");
-					return false;
-				}
-				
-				if (!info) {
-					Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key);
-					return false;
-				}
-				
-				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();
-						return {
-							localChanges: true,
-							remoteChanges: false
-						};
-					}
-					// 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();
-						return {
-							localChanges: true,
-							remoteChanges: false
-						};
+		try {
+			let req = yield this.apiClient.makeRequest(
+				"GET",
+				uri,
+				{
+					successCodes: [200, 404],
+					requestObserver: function (xmlhttp) {
+						request.setChannel(xmlhttp.channel);
 					}
 				}
-				
-				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 (req.status == 404) {
+				return new Zotero.Sync.Storage.Result;
+			}
+			
+			let info = {};
+			info.hash = req.getResponseHeader('ETag');
+			if (!info.hash) {
+				let msg = `Hash not found in info response in ${funcName} (${item.libraryKey})`;
+				Zotero.debug(msg, 1);
+				Zotero.debug(req.status);
+				Zotero.debug(req.responseText);
+				Components.utils.reportError(msg);
 				try {
-					destFile.create(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644);
+					Zotero.debug(req.getAllResponseHeaders());
 				}
 				catch (e) {
-					Zotero.File.checkFileAccessError(e, destFile, 'create');
+					Zotero.debug("Response headers unavailable");
 				}
-				
-				var deferred = Zotero.Promise.defer();
-				
-				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
-								deferred.resolve(false);
-							}
-						},
-						onProgress: function (a, b, c) {
-							request.onProgress(a, b, c)
-						},
-						onStop: function (request, status, response, data) {
-							data.request.setChannel(false);
-							
-							if (status != 200) {
-								if (status == 404) {
-									deferred.resolve(false);
-									return;
-								}
-								
-								if (status == 0) {
-									if (_s3ConsecutiveFailures >= _maxS3ConsecutiveFailures) {
-										Zotero.debug(_s3ConsecutiveFailures
-											+ " consecutive S3 failures -- aborting", 1);
-										_s3ConsecutiveFailures = 0;
-									}
-									else {
-										let libraryKey = Zotero.Items.getLibraryKeyHash(item);
-										let msg = "S3 returned " + status
-											+ " (" + libraryKey + ") -- retrying download"
-										Components.utils.reportError(msg);
-										Zotero.debug(msg, 1);
-										if (_s3Backoff < _maxS3Backoff) {
-											_s3Backoff *= 2;
-										}
-										_s3ConsecutiveFailures++;
-										Zotero.debug("Delaying " + libraryKey + " download for "
-											+ _s3Backoff + " seconds", 2);
-										Q.delay(_s3Backoff * 1000)
-										.then(function () {
-											deferred.resolve(self._downloadFile(data.request));
-										});
-										return;
-									}
-								}
-								
-								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);
-								// Ignore files not found in S3
-								try {
-									Zotero.debug(Zotero.File.getContents(destFile, null, 4096), 1);
-								}
-								catch (e) {
-									Zotero.debug(e, 1);
-								}
-								deferred.reject(Zotero.Sync.Storage.defaultError);
-								return;
-							}
-							
-							// 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);
-								deferred.resolve(false);
-								return;
-							}
-							
-							Zotero.debug("Finished download of " + destFile.path);
-							
-							try {
-								deferred.resolve(Zotero.Sync.Storage.processDownload(data));
-							}
-							catch (e) {
-								deferred.reject(e);
-							}
-						},
-						onCancel: function (request, status, data) {
-							Zotero.debug("Request cancelled");
-							deferred.resolve(false);
-						},
-						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;
-				Zotero.Utilities.Internal.saveURI(wbp, uri, destFile);
-				
-				return deferred.promise;
-			});
-	};
-	
-	
-	obj._uploadFile = function (request) {
-		var item = Zotero.Sync.Storage.getItemFromRequestName(request.name);
-		if (Zotero.Attachments.getNumFiles(item) > 1) {
-			var deferred = Zotero.Promise.defer();
-			var created = Zotero.Sync.Storage.createUploadFile(
-				request,
-				function (data) {
-					if (!data) {
-						deferred.resolve(false);
-						return;
-					}
-					deferred.resolve(processUploadFile(data));
-				}
-			);
-			if (!created) {
-				return Zotero.Promise.resolve(false);
-			}
-			return deferred.promise;
-		}
-		else {
-			return processUploadFile({ request: request });
-		}
-	};
-	
-	
-	/**
-	 * @return {Promise} A promise for the last sync time
-	 */
-	obj._getLastSyncTime = function (libraryID) {
-		var lastSyncURI = this._getLastSyncURI(libraryID);
-		
-		var self = this;
-		return Zotero.Promise.try(function () {
-			// Cache the credentials at the root
-			return self._cacheCredentials();
-		})
-		.then(function () {
-			return Zotero.HTTP.promise("GET", lastSyncURI,
-				{ headers: _headers, successCodes: [200, 404], debug: true });
-		})
-		.then(function (req) {
-			// Not yet synced
-			if (req.status == 404) {
-				Zotero.debug("No last sync time for library " + libraryID);
-				return null;
+				let e = Zotero.getString('sync.storage.error.zfs.restart', Zotero.appName);
+				throw new Error(e);
 			}
+			info.filename = req.getResponseHeader('X-Zotero-Filename');
+			let mtime = req.getResponseHeader('X-Zotero-Modification-Time');
+			info.mtime = parseInt(mtime);
+			info.compressed = req.getResponseHeader('X-Zotero-Compressed') == 'Yes';
+			Zotero.debug(info);
 			
-			var ts = req.responseText;
-			var date = new Date(ts * 1000);
-			Zotero.debug("Last successful ZFS sync for library "
-				+ libraryID + " was " + date);
-			return ts;
-		})
-		.catch(function (e) {
+			return info;
+		}
+		catch (e) {
 			if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
-				if (e.status == 401 || e.status == 403) {
-					Zotero.debug("Clearing ZFS authentication credentials", 2);
-					_cachedCredentials = false;
-				}
-				
-				return Zotero.Promise.reject(e);
-			}
-			// TODO: handle browser offline exception
-			else {
-				throw e;
-			}
-		});
-	};
-	
-	
-	obj._setLastSyncTime = function (libraryID, localLastSyncTime) {
-		if (localLastSyncTime) {
-			var sql = "REPLACE INTO version VALUES (?, ?)";
-			Zotero.DB.query(
-				sql, ['storage_zfs_' + libraryID, { int: localLastSyncTime }]
-			);
-			return;
-		}
-		
-		var lastSyncURI = this._getLastSyncURI(libraryID);
-		
-		return Zotero.HTTP.promise("POST", lastSyncURI, { headers: _headers, successCodes: [200, 404], debug: true })
-			.then(function (req) {
-				// Not yet synced
-				//
-				// TODO: Don't call this at all if no files uploaded
-				if (req.status == 404) {
-					return;
-				}
-				
-				var ts = req.responseText;
-				
-				var sql = "REPLACE INTO version VALUES (?, ?)";
-				Zotero.DB.query(
-					sql, ['storage_zfs_' + libraryID, { int: ts }]
-				);
-			})
-			.catch(function (e) {
-				var msg = "Unexpected status code " + e.xmlhttp.status
-					+ " setting last file sync time";
-				Zotero.debug(msg, 1);
-				Components.utils.reportError(msg);
-				throw new Error(Zotero.Sync.Storage.defaultError);
-			});
-	};
-	
-	
-	obj._getLastSyncURI = function (libraryID) {
-		if (libraryID === Zotero.Libraries.userLibraryID) {
-			var lastSyncURI = this.userURI;
-		}
-		else if (libraryID) {
-			var ios = Components.classes["@mozilla.org/network/io-service;1"].
-			getService(Components.interfaces.nsIIOService);
-			var uri = ios.newURI(Zotero.URI.getLibraryURI(libraryID), null, null);
-			var path = uri.path;
-			// We don't want the user URI, but it already has the right domain
-			// and credentials, so just start with that and replace the path
-			var lastSyncURI = this.userURI;
-			lastSyncURI.path = path + "/";
-		}
-		else {
-			throw new Error("libraryID not specified");
-		}
-		lastSyncURI.spec += "laststoragesync";
-		return lastSyncURI;
-	}
-	
-	
-	obj._cacheCredentials = function () {
-		if (_cachedCredentials) {
-			Zotero.debug("ZFS credentials are already cached");
-			return Zotero.Promise.resolve();
-		}
-		
-		var uri = this.rootURI;
-		// TODO: move to root uri
-		uri.spec += "?auth=1";
-		
-		return Zotero.HTTP.promise("GET", uri, { headers: _headers }).
-			then(function (req) {
-				Zotero.debug("Credentials are cached");
-				_cachedCredentials = true;
-			})
-			.catch(function (e) {
-				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
-					if (e.status == 401) {
-						var msg = "File sync login failed\n\n"
-							+ "Check your username and password in the Sync "
-							+ "pane of the Zotero preferences.";
-						throw (msg);
-					}
-					
-					var msg = "Unexpected status code " + e.status + " "
-						+ "caching ZFS credentials";
-					Zotero.debug(msg, 1);
-					throw (msg);
+				if (e.xmlhttp.status == 0) {
+					var msg = "Request cancelled getting storage file info";
 				}
 				else {
-					throw (e);
+					var msg = "Unexpected status code " + e.xmlhttp.status
+						+ " getting storage file info for item " + item.libraryKey;
 				}
-			});
-	};
+				Zotero.debug(msg, 1);
+				Zotero.debug(e.xmlhttp.responseText);
+				Components.utils.reportError(msg);
+				throw new Error(Zotero.Sync.Storage.defaultError);
+			}
+			
+			throw e;
+		}
+	}),
 	
 	
 	/**
-	 * Remove all synced files from the server
+	 * Upload the file to the server
+	 *
+	 * @param {Zotero.Sync.Storage.Request} request
+	 * @return {Promise}
 	 */
-	obj._purgeDeletedStorageFiles = function () {
-		return Zotero.Promise.try(function () {
-			// Cache the credentials at the root
-			return this._cacheCredentials();
-		}.bind(this))
-		then(function () {
-			// If we don't have a user id we've never synced and don't need to bother
-			if (!Zotero.Users.getCurrentUserID()) {
-				return false;
-			}
-			
-			var sql = "SELECT value FROM settings WHERE setting=? AND key=?";
-			var values = Zotero.DB.columnQuery(sql, ['storage', 'zfsPurge']);
-			if (!values) {
-				return false;
-			}
-			
-			// TODO: promisify
-			
-			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:
-						throw "Invalid zfsPurge value '" + value
-							+ "' in ZFS purgeDeletedStorageFiles()";
+	_processUploadFile: Zotero.Promise.coroutine(function* (request) {
+		/*
+		updateSizeMultiplier(
+			(100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100
+		);
+		*/
+		
+		var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
+		
+		
+		/*var info = yield this._getStorageFileInfo(item, request);
+		
+		if (request.isFinished()) {
+			Zotero.debug("Upload request '" + request.name
+				+ "' is no longer running after getting file info");
+			return false;
+		}
+		
+		// Check for conflict
+		if ((yield Zotero.Sync.Storage.Local.getSyncState(item.id))
+				!= Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD) {
+			if (info) {
+				// Local file time
+				var fmtime = yield item.attachmentModificationTime;
+				// Remote mod time
+				var mtime = info.mtime;
+				
+				var useLocal = false;
+				var same = !(yield Zotero.Sync.Storage.checkFileModTime(item, fmtime, mtime));
+				
+				// Ignore maxed-out 32-bit ints, from brief problem after switch to 32-bit servers
+				if (!same && mtime == 2147483647) {
+					Zotero.debug("Remote mod time is invalid -- uploading local file version");
+					useLocal = true;
+				}
+				
+				if (same) {
+					yield Zotero.DB.executeTransaction(function* () {
+						yield Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime);
+						yield Zotero.Sync.Storage.setSyncState(
+							item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+						);
+					});
+					return {
+						localChanges: true,
+						remoteChanges: false
+					};
+				}
+				
+				let smtime = yield Zotero.Sync.Storage.getSyncedModificationTime(item.id);
+				if (!useLocal && smtime != mtime) {
+					Zotero.debug("Conflict -- last synced file mod time "
+						+ "does not match time on storage server"
+						+ " (" + smtime + " != " + mtime + ")");
+					return {
+						localChanges: false,
+						remoteChanges: false,
+						conflict: {
+							local: { modTime: fmtime },
+							remote: { modTime: mtime }
+						}
+					};
 				}
 			}
-			uri.spec = uri.spec.substr(0, uri.spec.length - 1);
-			
-			return Zotero.HTTP.promise("POST", uri, "")
-			.then(function (req) {
-				var sql = "DELETE FROM settings WHERE setting=? AND key=?";
-				Zotero.DB.query(sql, ['storage', 'zfsPurge']);
+			else {
+				Zotero.debug("Remote file not found for item " + item.libraryKey);
+			}
+		}*/
+		
+		var result = yield this._getFileUploadParameters(item);
+		if (result.exists) {
+			yield this._updateItemFileInfo(item, result);
+			return new Zotero.Sync.Storage.Result({
+				localChanges: true,
+				remoteChanges: true
 			});
-		}.bind(this));
-	};
-	
-	return obj;
-}());
+		}
+		else if (result instanceof Zotero.Sync.Storage.Result) {
+			return result;
+		}
+		return this._uploadFile(request, item, result);
+	})
+}
diff --git a/chrome/content/zotero/xpcom/sync.js b/chrome/content/zotero/xpcom/sync.js
index a8779cf68..4f79121d1 100644
--- a/chrome/content/zotero/xpcom/sync.js
+++ b/chrome/content/zotero/xpcom/sync.js
@@ -1488,43 +1488,6 @@ Zotero.Sync.Server = new function () {
 
 
 
-Zotero.BufferedInputListener = function (callback) {
-	this._callback = callback;
-}
-
-Zotero.BufferedInputListener.prototype = {
-	binaryInputStream: null,
-	size: 0,
-	data: '',
-	
-	onStartRequest: function(request, context) {},
-	
-	onStopRequest: function(request, context, status) {
-		this.binaryInputStream.close();
-		delete this.binaryInputStream;
-		
-		this._callback(this.data);
-	},
-	
-	onDataAvailable: function(request, context, inputStream, offset, count) {
-		this.size += count;
-		
-		this.binaryInputStream = Components.classes["@mozilla.org/binaryinputstream;1"]
-			.createInstance(Components.interfaces.nsIBinaryInputStream)
-		this.binaryInputStream.setInputStream(inputStream);
-		this.data += this.binaryInputStream.readBytes(this.binaryInputStream.available());
-	},
-	
-	QueryInterface: function (iid) {
-		if (iid.equals(Components.interfaces.nsISupports)
-			   || iid.equals(Components.interfaces.nsIStreamListener)) {
-			return this;
-		}
-		throw Components.results.NS_ERROR_NO_INTERFACE;
-	}
-}
-
-
 Zotero.Sync.Server.Data = new function() {
 	var _noMergeTypes = ['search'];
 	
diff --git a/chrome/content/zotero/xpcom/sync/syncAPIClient.js b/chrome/content/zotero/xpcom/sync/syncAPIClient.js
index 3c01b44bb..81d3eaf9a 100644
--- a/chrome/content/zotero/xpcom/sync/syncAPIClient.js
+++ b/chrome/content/zotero/xpcom/sync/syncAPIClient.js
@@ -28,14 +28,15 @@ if (!Zotero.Sync) {
 }
 
 Zotero.Sync.APIClient = function (options) {
-	this.baseURL = options.baseURL;
-	this.apiKey = options.apiKey;
-	this.concurrentCaller = options.concurrentCaller;
+	if (!options.baseURL) throw new Error("baseURL not set");
+	if (!options.apiVersion) throw new Error("apiVersion not set");
+	if (!options.apiKey) throw new Error("apiKey not set");
+	if (!options.caller) throw new Error("caller not set");
 	
-	if (options.apiVersion == undefined) {
-		throw new Error("options.apiVersion not set");
-	}
+	this.baseURL = options.baseURL;
 	this.apiVersion = options.apiVersion;
+	this.apiKey = options.apiKey;
+	this.caller = options.caller;
 }
 
 Zotero.Sync.APIClient.prototype = {
@@ -44,7 +45,7 @@ Zotero.Sync.APIClient.prototype = {
 	
 	getKeyInfo: Zotero.Promise.coroutine(function* () {
 		var uri = this.baseURL + "keys/" + this.apiKey;
-		var xmlhttp = yield this._makeRequest("GET", uri, { successCodes: [200, 404] });
+		var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 404] });
 		if (xmlhttp.status == 404) {
 			return false;
 		}
@@ -63,7 +64,7 @@ Zotero.Sync.APIClient.prototype = {
 		if (!userID) throw new Error("User ID not provided");
 		
 		var uri = this.baseURL + "users/" + userID + "/groups?format=versions";
-		var xmlhttp = yield this._makeRequest("GET", uri);
+		var xmlhttp = yield this.makeRequest("GET", uri);
 		return this._parseJSON(xmlhttp.responseText);
 	}),
 	
@@ -76,7 +77,7 @@ Zotero.Sync.APIClient.prototype = {
 		if (!groupID) throw new Error("Group ID not provided");
 		
 		var uri = this.baseURL + "groups/" + groupID;
-		var xmlhttp = yield this._makeRequest("GET", uri, { successCodes: [200, 404] });
+		var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 404] });
 		if (xmlhttp.status == 404) {
 			return false;
 		}
@@ -93,7 +94,7 @@ Zotero.Sync.APIClient.prototype = {
 		if (since) {
 			params.since = since;
 		}
-		var uri = this._buildRequestURI(params);
+		var uri = this.buildRequestURI(params);
 		var options = {
 			successCodes: [200, 304]
 		};
@@ -102,7 +103,7 @@ Zotero.Sync.APIClient.prototype = {
 				"If-Modified-Since-Version": since
 			};
 		}
-		var xmlhttp = yield this._makeRequest("GET", uri, options);
+		var xmlhttp = yield this.makeRequest("GET", uri, options);
 		if (xmlhttp.status == 304) {
 			return false;
 		}
@@ -128,8 +129,8 @@ Zotero.Sync.APIClient.prototype = {
 			libraryTypeID: libraryTypeID,
 			since: since || 0
 		};
-		var uri = this._buildRequestURI(params);
-		var xmlhttp = yield this._makeRequest("GET", uri, { successCodes: [200, 409] });
+		var uri = this.buildRequestURI(params);
+		var xmlhttp = yield this.makeRequest("GET", uri, { successCodes: [200, 409] });
 		if (xmlhttp.status == 409) {
 			Zotero.debug(`'since' value '${since}' is earlier than the beginning of the delete log`);
 			return false;
@@ -154,7 +155,7 @@ Zotero.Sync.APIClient.prototype = {
 	 * @param {String} libraryType  'user' or 'group'
 	 * @param {Integer} libraryTypeID  userID or groupID
 	 * @param {String} objectType  'item', 'collection', 'search'
-	 * @param {Object} queryParams  Query parameters (see _buildRequestURI())
+	 * @param {Object} queryParams  Query parameters (see buildRequestURI())
 	 * @return {Promise<Object>|FALSE} Object with 'libraryVersion' and 'results'
 	 */
 	getVersions: Zotero.Promise.coroutine(function* (libraryType, libraryTypeID, objectType, queryParams, libraryVersion) {
@@ -176,7 +177,7 @@ Zotero.Sync.APIClient.prototype = {
 		}
 		
 		// TODO: Use pagination
-		var uri = this._buildRequestURI(params);
+		var uri = this.buildRequestURI(params);
 		
 		var options = {
 			successCodes: [200, 304]
@@ -186,7 +187,7 @@ Zotero.Sync.APIClient.prototype = {
 				"If-Modified-Since-Version": libraryVersion
 			};
 		}
-		var xmlhttp = yield this._makeRequest("GET", uri, options);
+		var xmlhttp = yield this.makeRequest("GET", uri, options);
 		if (xmlhttp.status == 304) {
 			return false;
 		}
@@ -256,10 +257,10 @@ Zotero.Sync.APIClient.prototype = {
 		if (objectType == 'item') {
 			params.includeTrashed = 1;
 		}
-		var uri = this._buildRequestURI(params);
+		var uri = this.buildRequestURI(params);
 		
 		return [
-			this._makeRequest("GET", uri)
+			this.makeRequest("GET", uri)
 			.then(function (xmlhttp) {
 				return this._parseJSON(xmlhttp.responseText)
 			}.bind(this))
@@ -294,9 +295,9 @@ Zotero.Sync.APIClient.prototype = {
 			libraryType: libraryType,
 			libraryTypeID: libraryTypeID
 		};
-		var uri = this._buildRequestURI(params);
+		var uri = this.buildRequestURI(params);
 		
-		var xmlhttp = yield this._makeRequest(method, uri, {
+		var xmlhttp = yield this.makeRequest(method, uri, {
 			headers: {
 				"If-Unmodified-Since-Version": version
 			},
@@ -319,7 +320,7 @@ Zotero.Sync.APIClient.prototype = {
 	}),
 	
 	
-	_buildRequestURI: function (params) {
+	buildRequestURI: function (params) {
 		var uri = this.baseURL;
 		
 		switch (params.libraryType) {
@@ -332,6 +333,10 @@ Zotero.Sync.APIClient.prototype = {
 			break;
 		}
 		
+		if (params.target === undefined) {
+			throw new Error("'target' not provided");
+		}
+		
 		uri += "/" + params.target;
 		
 		if (params.objectKey) {
@@ -382,30 +387,33 @@ Zotero.Sync.APIClient.prototype = {
 	},
 	
 	
-	_makeRequest: function (method, uri, options) {
-		if (!options) {
-			options = {};
+	getHeaders: function (headers = {}) {
+		headers["Zotero-API-Version"] = this.apiVersion;
+		if (this.apiKey) {
+			headers["Zotero-API-Key"] = this.apiKey;
 		}
-		if (!options.headers) {
-			options.headers = {};
-		}
-		options.headers["Zotero-API-Version"] = this.apiVersion;
+		return headers;
+	},
+	
+	
+	makeRequest: function (method, uri, options = {}) {
+		options.headers = this.getHeaders(options.headers);
 		options.dontCache = true;
 		options.foreground = !options.background;
 		options.responseType = options.responseType || 'text';
-		if (this.apiKey) {
-			options.headers.Authorization = "Bearer " + this.apiKey;
-		}
-		var self = this;
-		return this.concurrentCaller.fcall(function () {
-			return Zotero.HTTP.request(method, uri, options)
-			.catch(function (e) {
-				if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
-					self._checkResponse(e.xmlhttp);
-				}
+		return this.caller.start(Zotero.Promise.coroutine(function* () {
+			try {
+				var xmlhttp = yield Zotero.HTTP.request(method, uri, options);
+				this._checkBackoff(xmlhttp);
+				return xmlhttp;
+			}
+			catch (e) {
+				/*if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
+					this._checkRetry(e.xmlhttp);
+				}*/
 				throw e;
-			});
-		});
+			}
+		}.bind(this)));
 	},
 	
 	
@@ -422,21 +430,6 @@ Zotero.Sync.APIClient.prototype = {
 	},
 	
 	
-	_checkResponse: function (xmlhttp) {
-		this._checkBackoff(xmlhttp);
-		this._checkAuth(xmlhttp);
-	},
-	
-	
-	_checkAuth: function (xmlhttp) {
-		if (xmlhttp.status == 403) {
-			var e = new Zotero.Error(Zotero.getString('sync.error.invalidLogin'), "INVALID_SYNC_LOGIN");
-			e.fatal = true;
-			throw e;
-		}
-	},
-	
-	
 	_checkBackoff: function (xmlhttp) {
 		var backoff = xmlhttp.getResponseHeader("Backoff");
 		if (backoff) {
@@ -444,7 +437,7 @@ Zotero.Sync.APIClient.prototype = {
 			if (backoff > 3600) {
 				// TODO: Update status?
 				
-				this.concurrentCaller.pause(backoff * 1000);
+				this.caller.pause(backoff * 1000);
 			}
 		}
 	}
diff --git a/chrome/content/zotero/xpcom/sync/syncEngine.js b/chrome/content/zotero/xpcom/sync/syncEngine.js
index 91eafb050..714bc1bfa 100644
--- a/chrome/content/zotero/xpcom/sync/syncEngine.js
+++ b/chrome/content/zotero/xpcom/sync/syncEngine.js
@@ -76,7 +76,13 @@ Zotero.Sync.Data.Engine = function (options) {
 		onError: this.onError
 	}
 	
-	this.syncCachePromise = Zotero.Promise.resolve().bind(this);
+	Components.utils.import("resource://zotero/concurrentCaller.js");
+	this.syncCacheProcessor = new ConcurrentCaller({
+		id: "Sync Cache Processor",
+		numConcurrent: 1,
+		logger: Zotero.debug,
+		stopOnError: this.stopOnError
+	});
 };
 
 Zotero.Sync.Data.Engine.prototype.UPLOAD_RESULT_SUCCESS = 1;
@@ -167,12 +173,8 @@ Zotero.Sync.Data.Engine.prototype.start = Zotero.Promise.coroutine(function* ()
 		}
 	}
 	
-	// TEMP: make more reliable
-	while (this.syncCachePromise.isPending()) {
-		Zotero.debug("Waiting for sync cache to be processed");
-		yield this.syncCachePromise;
-		yield Zotero.Promise.delay(50);
-	}
+	Zotero.debug("Waiting for sync cache to be processed");
+	yield this.syncCacheProcessor.wait();
 	
 	yield Zotero.Libraries.updateLastSyncTime(this.libraryID);
 	
@@ -286,12 +288,7 @@ Zotero.Sync.Data.Engine.prototype._startDownload = Zotero.Promise.coroutine(func
 		}
 		
 		// Wait for sync process to clear
-		// TEMP: make more reliable
-		while (this.syncCachePromise.isPending()) {
-			Zotero.debug("Waiting for sync cache to be processed");
-			yield this.syncCachePromise;
-			yield Zotero.Promise.delay(50);
-		}
+		yield this.syncCacheProcessor.wait();
 		
 		//
 		// Get deleted objects
@@ -671,7 +668,8 @@ Zotero.Sync.Data.Engine.prototype._startUpload = Zotero.Promise.coroutine(functi
 						
 						if (state == 'successful') {
 							// Update local object with saved data if necessary
-							yield obj.fromJSON(current.data);
+							yield obj.loadAllData();
+							obj.fromJSON(current.data);
 							toSave.push(obj);
 							toCache.push(current);
 						}
@@ -701,8 +699,11 @@ Zotero.Sync.Data.Engine.prototype._startUpload = Zotero.Promise.coroutine(functi
 				
 				// Handle failed objects
 				for (let index in json.results.failed) {
-					let e = json.results.failed[index];
-					Zotero.logError(e.message);
+					let { code, message } = json.results.failed[index];
+					e = new Error(message);
+					e.name = "ZoteroUploadObjectError";
+					e.code = code;
+					Zotero.logError(e);
 					
 					// This shouldn't happen, because the upload request includes a library
 					// version and should prevent an outdated upload before the object version is
@@ -711,12 +712,11 @@ Zotero.Sync.Data.Engine.prototype._startUpload = Zotero.Promise.coroutine(functi
 						return this.UPLOAD_RESULT_OBJECT_CONFLICT;
 					}
 					
-					if (this.stopOnError) {
-						Zotero.debug("WE FAILED!!!");
-						throw new Error(e.message);
-					}
 					if (this.onError) {
-						this.onError(e.message);
+						this.onError(e);
+					}
+					if (this.stopOnError) {
+						throw new Error(e);
 					}
 					batch[index].tries++;
 					// Mark 400 errors as permanently failed
@@ -990,7 +990,7 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
 			this._failedCheck();
 			
 			let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
-			let ObjectType = objectType[0].toUpperCase() + objectType.substr(1);
+			let ObjectType = Zotero.Utilities.capitalize(objectType);
 			
 			// TODO: localize
 			this.setStatus("Updating " + objectTypePlural + " in " + this.libraryName);
@@ -1037,8 +1037,7 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
 			let cacheVersions = yield Zotero.Sync.Data.Local.getLatestCacheObjectVersions(
 				this.libraryID, objectType
 			);
-			// Queue objects that are out of date or don't exist locally and aren't up-to-date
-			// in the cache
+			// Queue objects that are out of date or don't exist locally
 			for (let key in results.versions) {
 				let version = results.versions[key];
 				let obj = yield objectsClass.getByLibraryAndKeyAsync(this.libraryID, key, {
@@ -1060,12 +1059,12 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
 				}
 				
 				if (obj) {
-					Zotero.debug(Zotero.Utilities.capitalize(objectType) + " " + obj.libraryKey
+					Zotero.debug(ObjectType + " " + obj.libraryKey
 						+ " is older than version in sync cache");
 				}
 				else {
-					Zotero.debug(Zotero.Utilities.capitalize(objectType) + " "
-						+ this.libraryID + "/" + key + " in sync cache not found locally");
+					Zotero.debug(ObjectType + " " + this.libraryID + "/" + key
+						+ " in sync cache not found locally");
 				}
 				
 				toDownload.push(key);
@@ -1127,7 +1126,7 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
 		break;
 	}
 	
-	yield this.syncCachePromise;
+	yield this.syncCacheProcessor.wait();
 	
 	yield Zotero.Libraries.setVersion(this.libraryID, lastLibraryVersion);
 	
@@ -1145,20 +1144,19 @@ Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function*
  * @param {String} objectType
  */
 Zotero.Sync.Data.Engine.prototype._processCache = function (objectType) {
-	var self = this;
-	this.syncCachePromise = this.syncCachePromise.then(function () {
-		self._failedCheck();
+	this.syncCacheProcessor.start(function () {
+		this._failedCheck();
 		return Zotero.Sync.Data.Local.processSyncCacheForObjectType(
-			self.libraryID, objectType, self.options
+			this.libraryID, objectType, this.options
 		)
 		.catch(function (e) {
 			Zotero.logError(e);
-			if (self.stopOnError) {
+			if (this.stopOnError) {
 				Zotero.debug("WE FAILED!!!");
-				self.failed = e;
+				this.failed = e;
 			}
-		});
-	})
+		}.bind(this));
+	}.bind(this))
 }
 
 
diff --git a/chrome/content/zotero/xpcom/sync/syncEventListeners.js b/chrome/content/zotero/xpcom/sync/syncEventListeners.js
index 9f3fff136..82f8ecbfb 100644
--- a/chrome/content/zotero/xpcom/sync/syncEventListeners.js
+++ b/chrome/content/zotero/xpcom/sync/syncEventListeners.js
@@ -39,10 +39,9 @@ Zotero.Sync.EventListeners.ChangeListener = new function () {
 		
 		var syncSQL = "REPLACE INTO syncDeleteLog (syncObjectTypeID, libraryID, key, synced) "
 			+ "VALUES (?, ?, ?, 0)";
+		var storageSQL = "REPLACE INTO storageDeleteLog VALUES (?, ?, 0)";
 		
-		if (type == 'item' && Zotero.Sync.Storage.WebDAV.includeUserFiles) {
-			var storageSQL = "REPLACE INTO storageDeleteLog VALUES (?, ?, 0)";
-		}
+		var storageForLibrary = {};
 		
 		return Zotero.DB.executeTransaction(function* () {
 			for (let i = 0; i < ids.length; i++) {
@@ -74,18 +73,25 @@ Zotero.Sync.EventListeners.ChangeListener = new function () {
 						key
 					]
 				);
-				if (storageSQL && oldItem.itemType == 'attachment' &&
-						[
-							Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
-							Zotero.Attachments.LINK_MODE_IMPORTED_URL
-						].indexOf(oldItem.linkMode) != -1) {
-					yield Zotero.DB.queryAsync(
-						storageSQL,
-						[
-							libraryID,
-							key
-						]
-					);
+				
+				if (type == 'item') {
+					if (storageForLibrary[libraryID] === undefined) {
+						storageForLibrary[libraryID] =
+							Zotero.Sync.Storage.Local.getModeForLibrary(libraryID) == 'webdav';
+					}
+					if (storageForLibrary[libraryID] && oldItem.itemType == 'attachment' &&
+							[
+								Zotero.Attachments.LINK_MODE_IMPORTED_FILE,
+								Zotero.Attachments.LINK_MODE_IMPORTED_URL
+							].indexOf(oldItem.linkMode) != -1) {
+						yield Zotero.DB.queryAsync(
+							storageSQL,
+							[
+								libraryID,
+								key
+							]
+						);
+					}
 				}
 			}
 		});
@@ -215,3 +221,23 @@ Zotero.Sync.EventListeners.progressListener = {
 		
 	}
 };
+
+
+Zotero.Sync.EventListeners.StorageFileOpenListener = {
+	init: function () {
+		Zotero.Notifier.registerObserver(this, ['file'], 'storageFileOpen');
+	},
+	
+	notify: function (event, type, ids, extraData) {
+		if (event == 'open' && type == 'file') {
+			let timestamp = new Date().getTime();
+			
+			for (let i = 0; i < ids.length; i++) {
+				Zotero.Sync.Storage.Local.uploadCheckFiles.push({
+					itemID: ids[i],
+					timestamp: timestamp
+				});
+			}
+		}
+	}
+}
diff --git a/chrome/content/zotero/xpcom/sync/syncLocal.js b/chrome/content/zotero/xpcom/sync/syncLocal.js
index a5ba10f7a..104958e03 100644
--- a/chrome/content/zotero/xpcom/sync/syncLocal.js
+++ b/chrome/content/zotero/xpcom/sync/syncLocal.js
@@ -28,6 +28,8 @@ if (!Zotero.Sync.Data) {
 }
 
 Zotero.Sync.Data.Local = {
+	_loginManagerHost: 'https://api.zotero.org',
+	_loginManagerRealm: 'Zotero Web API',
 	_lastSyncTime: null,
 	_lastClassicSyncTime: null,
 	
@@ -39,6 +41,71 @@ Zotero.Sync.Data.Local = {
 	}),
 	
 	
+	getAPIKey: function () {
+		var apiKey = Zotero.Prefs.get('devAPIKey');
+		if (apiKey) {
+			return apiKey;
+		}
+		var loginManager = Components.classes["@mozilla.org/login-manager;1"]
+			.getService(Components.interfaces.nsILoginManager);
+		var logins = loginManager.findLogins(
+			{}, this._loginManagerHost, null, this._loginManagerRealm
+		);
+		// Get API from returned array of nsILoginInfo objects
+		if (logins.length) {
+			return logins[0].password;
+		}
+		if (!apiKey) {
+			let username = Zotero.Prefs.get('sync.server.username');
+			if (username) {
+				let password = Zotero.Sync.Data.Local.getLegacyPassword(username);
+				if (!password) {
+					return false;
+				}
+				throw new Error("Unimplemented");
+				// Get API key from server
+				
+				// Store API key
+				
+				// Remove old logins and username pref
+			}
+		}
+		return apiKey;
+	},
+	
+	
+	setAPIKey: function (apiKey) {
+		var loginManager = Components.classes["@mozilla.org/login-manager;1"]
+			.getService(Components.interfaces.nsILoginManager);
+		var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+				Components.interfaces.nsILoginInfo, "init");
+		var loginInfo = new nsLoginInfo(
+			this._loginManagerHost,
+			null,
+			this._loginManagerRealm,
+			'API Key',
+			apiKey,
+			"",
+			""
+		);
+		loginManager.addLogin(loginInfo);
+	},
+	
+	
+	getLegacyPassword: function (username) {
+		var loginManager = Components.classes["@mozilla.org/login-manager;1"]
+			.getService(Components.interfaces.nsILoginManager);
+		var logins = loginManager.findLogins({}, "chrome://zotero", "Zotero Storage Server", null);
+		// Find user from returned array of nsILoginInfo objects
+		for (let login of logins) {
+			if (login.username == username) {
+				return login.password;
+			}
+		}
+		return false;
+	},
+	
+	
 	getLastSyncTime: function () {
 		if (_lastSyncTime === null) {
 			throw new Error("Last sync time not yet loaded");
@@ -86,7 +153,7 @@ Zotero.Sync.Data.Local = {
 		var sql = "SELECT " + objectsClass.idColumn + " FROM " + objectsClass.table
 			+ " WHERE libraryID=? AND synced=0";
 		
-		// RETRIEVE PARENT DOWN? EVEN POSSIBLE?
+		// TODO: RETRIEVE PARENT DOWN? EVEN POSSIBLE?
 		// items via parent
 		// collections via getDescendents?
 		
@@ -154,6 +221,35 @@ Zotero.Sync.Data.Local = {
 	}),
 	
 	
+	getCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, keyVersionPairs) {
+		if (!keyVersionPairs.length) return [];
+		var sql = "SELECT data FROM syncCache SC JOIN (SELECT "
+			+ keyVersionPairs.map(function (pair) {
+				Zotero.DataObjectUtilities.checkKey(pair[0]);
+				return "'" + pair[0] + "' AS key, " + parseInt(pair[1]) + " AS version";
+			}).join(" UNION SELECT ")
+			+ ") AS pairs ON (pairs.key=SC.key AND pairs.version=SC.version) "
+			+ "WHERE libraryID=? AND "
+			+ "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)";
+		var rows = yield Zotero.DB.columnQueryAsync(sql, [libraryID, objectType]);
+		return rows.map(row => JSON.parse(row));
+	}),
+	
+	
+	saveCacheObject: Zotero.Promise.coroutine(function* (objectType, libraryID, json) {
+		json = this._checkCacheJSON(json);
+		
+		Zotero.debug("Saving to sync cache:");
+		Zotero.debug(json);
+		
+		var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
+		var sql = "INSERT OR REPLACE INTO syncCache "
+			+ "(libraryID, key, syncObjectTypeID, version, data) VALUES (?, ?, ?, ?, ?)";
+		var params = [libraryID, json.key, syncObjectTypeID, json.version, JSON.stringify(json)];
+		return Zotero.DB.queryAsync(sql, params);
+	}),
+	
+	
 	saveCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, jsonArray) {
 		if (!Array.isArray(jsonArray)) {
 			throw new Error("'json' must be an array");
@@ -165,20 +261,7 @@ Zotero.Sync.Data.Local = {
 			return;
 		}
 		
-		jsonArray = jsonArray.map(o => {
-			if (o.key === undefined) {
-				throw new Error("Missing 'key' property in JSON");
-			}
-			if (o.version === undefined) {
-				throw new Error("Missing 'version' property in JSON");
-			}
-			// If direct data object passed, wrap in fake response object
-			return o.data === undefined ? {
-				key: o.key,
-				version: o.version,
-				data: o
-			} : o;
-		});
+		jsonArray = jsonArray.map(json => this._checkCacheJSON(json));
 		
 		Zotero.debug("Saving to sync cache:");
 		Zotero.debug(jsonArray);
@@ -206,6 +289,22 @@ Zotero.Sync.Data.Local = {
 	}),
 	
 	
+	_checkCacheJSON: function (json) {
+		if (json.key === undefined) {
+			throw new Error("Missing 'key' property in JSON");
+		}
+		if (json.version === undefined) {
+			throw new Error("Missing 'version' property in JSON");
+		}
+		// If direct data object passed, wrap in fake response object
+		return json.data === undefined ? {
+			key: json.key,
+			version: json.version,
+			data: json
+		} :  json;
+	},
+	
+	
 	processSyncCache: Zotero.Promise.coroutine(function* (libraryID, options) {
 		for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) {
 			yield this.processSyncCacheForObjectType(libraryID, objectType, options);
@@ -213,8 +312,7 @@ Zotero.Sync.Data.Local = {
 	}),
 	
 	
-	processSyncCacheForObjectType: Zotero.Promise.coroutine(function* (libraryID, objectType, options) {
-		options = options || {};
+	processSyncCacheForObjectType: Zotero.Promise.coroutine(function* (libraryID, objectType, options = {}) {
 		var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
 		var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType);
 		var ObjectType = Zotero.Utilities.capitalize(objectType);
@@ -227,7 +325,6 @@ Zotero.Sync.Data.Local = {
 		var numSkipped = 0;
 		
 		var data = yield this._getUnwrittenData(libraryID, objectType);
-		
 		if (!data.length) {
 			Zotero.debug("No unwritten " + objectTypePlural + " in sync cache");
 			return;
@@ -260,9 +357,9 @@ Zotero.Sync.Data.Local = {
 					for (let i = 0; i < chunk.length; i++) {
 						let json = chunk[i];
 						let jsonData = json.data;
-						let isNewObject;
 						let objectKey = json.key;
 						
+						Zotero.debug(`Processing ${objectType} ${libraryID}/${objectKey}`);
 						Zotero.debug(json);
 						
 						if (!jsonData) {
@@ -302,26 +399,22 @@ Zotero.Sync.Data.Local = {
 							}*/
 						}
 						
+						let isNewObject = false;
+						let skipCache = false;
 						let obj = yield objectsClass.getByLibraryAndKeyAsync(
 							libraryID, objectKey, { noCache: true }
 						);
 						if (obj) {
 							Zotero.debug("Matching local " + objectType + " exists", 4);
-							isNewObject = false;
 							
-							// Local object has not been modified since last sync
-							if (obj.synced) {
-								// Overwrite local below
-							}
-							else {
+							// Local object has been modified since last sync
+							if (!obj.synced) {
 								Zotero.debug("Local " + objectType + " " + obj.libraryKey
 										+ " has been modified since last sync", 4);
 								
 								let cachedJSON = yield this.getCacheObject(
 									objectType, obj.libraryID, obj.key, obj.version
 								);
-								Zotero.debug("GOT CACHED");
-								Zotero.debug(cachedJSON);
 								
 								let jsonDataLocal = yield obj.toJSON();
 								
@@ -333,42 +426,51 @@ Zotero.Sync.Data.Local = {
 									['dateAdded', 'dateModified']
 								);
 								
-								// If no changes, update local version and keep as unsynced
+								// If no changes, update local version number and mark as synced
 								if (!result.changes.length && !result.conflicts.length) {
-									Zotero.debug("No remote changes to apply to local " + objectType
-										+ " " + obj.libraryKey);
-									yield obj.updateVersion(json.version);
+									Zotero.debug("No remote changes to apply to local "
+										+ objectType + " " + obj.libraryKey);
+									obj.version = json.version;
+									obj.synced = true;
+									yield obj.save();
+									continue;
+								}
+								
+								if (result.conflicts.length) {
+									if (objectType != 'item') {
+										throw new Error(`Unexpected conflict on ${objectType} object`);
+									}
+									Zotero.debug("Conflict!");
+									conflicts.push({
+										left: jsonDataLocal,
+										right: jsonData,
+										changes: result.changes,
+										conflicts: result.conflicts
+									});
 									continue;
 								}
 								
 								// If no conflicts, apply remote changes automatically
-								if (!result.conflicts.length) {
-									Zotero.DataObjectUtilities.applyChanges(
-										jsonData, result.changes
-									);
-									let saved = yield this._saveObjectFromJSON(obj, jsonData, options);
-									if (saved) numSaved++;
-									continue;
-								}
-								
-								if (objectType != 'item') {
-									throw new Error(`Unexpected conflict on ${objectType} object`);
-								}
-								
-								conflicts.push({
-									left: jsonDataLocal,
-									right: jsonData,
-									changes: result.changes,
-									conflicts: result.conflicts
-								});
-								continue;
+								Zotero.debug(`Applying remote changes to ${objectType} `
+									+ obj.libraryKey);
+								Zotero.debug(result.changes);
+								Zotero.DataObjectUtilities.applyChanges(
+									jsonDataLocal, result.changes
+								);
+								// Transfer properties that aren't in the changeset
+								['version', 'dateAdded', 'dateModified'].forEach(x => {
+									if (jsonDataLocal[x] !== jsonData[x]) {
+										Zotero.debug(`Applying remote '${x}' value`);
+									}
+									jsonDataLocal[x] = jsonData[x];
+								})
+								jsonData = jsonDataLocal;
 							}
-							
-							let saved = yield this._saveObjectFromJSON(obj, jsonData, options);
-							if (saved) numSaved++;
 						}
 						// Object doesn't exist locally
 						else {
+							Zotero.debug(ObjectType + " doesn't exist locally");
+							
 							isNewObject = true;
 							
 							// Check if object has been deleted locally
@@ -376,6 +478,8 @@ Zotero.Sync.Data.Local = {
 								objectType, libraryID, objectKey
 							);
 							if (dateDeleted) {
+								Zotero.debug(ObjectType + " was deleted locally");
+								
 								switch (objectType) {
 								case 'item':
 									conflicts.push({
@@ -410,24 +514,30 @@ Zotero.Sync.Data.Local = {
 							obj.key = objectKey;
 							yield obj.loadPrimaryData();
 							
-							let saved = yield this._saveObjectFromJSON(obj, jsonData, options, {
-								// Don't cache new items immediately, which skips reloading after save
-								skipCache: true
-							});
-							if (saved) numSaved++;
+							// Don't cache new items immediately, which skips reloading after save
+							skipCache = true;
+						}
+						
+						let saved = yield this._saveObjectFromJSON(
+							obj, jsonData, options, { skipCache }
+						);
+						// Mark updated attachments for download
+						if (saved && objectType == 'item' && obj.isImportedAttachment()) {
+							yield this._checkAttachmentForDownload(
+								obj, jsonData.mtime, isNewObject
+							);
+						}
+						
+						if (saved) {
+							numSaved++;
 						}
 					}
 				}.bind(this));
 			}.bind(this)
 		);
 		
-		// Keep retrying if we skipped any, as long as we're still making progress
-		if (numSkipped && numSaved != 0) {
-			Zotero.debug("More " + objectTypePlural + " in cache -- continuing");
-			yield this.processSyncCacheForObjectType(libraryID, objectType, options);
-		}
-		
 		if (conflicts.length) {
+			// Sort conflicts by local Date Modified/Deleted
 			conflicts.sort(function (a, b) {
 				var d1 = a.left.dateDeleted || a.left.dateModified;
 				var d2 = b.left.dateDeleted || b.left.dateModified;
@@ -442,6 +552,7 @@ Zotero.Sync.Data.Local = {
 			
 			var mergeData = this.resolveConflicts(conflicts);
 			if (mergeData) {
+				Zotero.debug("Processing resolved conflicts");
 				let mergeOptions = {};
 				Object.assign(mergeOptions, options);
 				// Tell _saveObjectFromJSON not to save with 'synced' set to true
@@ -484,11 +595,55 @@ Zotero.Sync.Data.Local = {
 			}
 		}
 		
+		// Keep retrying if we skipped any, as long as we're still making progress
+		if (numSkipped && numSaved != 0) {
+			Zotero.debug("More " + objectTypePlural + " in cache -- continuing");
+			return this.processSyncCacheForObjectType(libraryID, objectType, options);
+		}
+		
 		data = yield this._getUnwrittenData(libraryID, objectType);
-		Zotero.debug("Skipping " + data.length + " "
-			+ (data.length == 1 ? objectType : objectTypePlural)
-			+ " in sync cache");
-		return data;
+		if (data.length) {
+			Zotero.debug(`Skipping ${data.length} `
+				+ (data.length == 1 ? objectType : objectTypePlural)
+				+ " in sync cache");
+		}
+	}),
+	
+	
+	_checkAttachmentForDownload: Zotero.Promise.coroutine(function* (item, mtime, isNewObject) {
+		var markToDownload = false;
+		if (!isNewObject) {
+			// Convert previously used Unix timestamps to ms-based timestamps
+			if (mtime < 10000000000) {
+				Zotero.debug("Converting Unix timestamp '" + mtime + "' to ms");
+				mtime = mtime * 1000;
+			}
+			var fmtime = null;
+			try {
+				fmtime = yield item.attachmentModificationTime;
+			}
+			catch (e) {
+				// This will probably fail later too, but ignore it for now
+				Zotero.logError(e);
+			}
+			if (fmtime) {
+				let state = Zotero.Sync.Storage.Local.checkFileModTime(item, fmtime, mtime);
+				if (state !== false) {
+					markToDownload = true;
+				}
+			}
+			else {
+				markToDownload = true;
+			}
+		}
+		else {
+			markToDownload = true;
+		}
+		if (markToDownload) {
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				item.id, Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD
+			);
+		}
 	}),
 	
 	
@@ -501,6 +656,8 @@ Zotero.Sync.Data.Local = {
 	
 	
 	resolveConflicts: function (conflicts) {
+		Zotero.debug("Showing conflict resolution window");
+		
 		var io = {
 			dataIn: {
 				captions: [
@@ -511,9 +668,7 @@ Zotero.Sync.Data.Local = {
 				conflicts
 			}
 		};
-		
 		var url = 'chrome://zotero/content/merge.xul';
-		
 		var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
 		   .getService(Components.interfaces.nsIWindowMediator);
 		var lastWin = wm.getMostRecentWindow("navigator:browser");
@@ -553,7 +708,8 @@ Zotero.Sync.Data.Local = {
 	
 	_saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) {
 		try {
-			yield obj.fromJSON(json);
+			yield obj.loadAllData();
+			obj.fromJSON(json);
 			if (!options.saveAsChanged) {
 				obj.version = json.version;
 				obj.synced = true;
@@ -611,6 +767,11 @@ Zotero.Sync.Data.Local = {
 		var changeset1 = Zotero.DataObjectUtilities.diff(originalJSON, currentJSON, ignoreFields);
 		var changeset2 = Zotero.DataObjectUtilities.diff(originalJSON, newJSON, ignoreFields);
 		
+		Zotero.debug("CHANGESET1");
+		Zotero.debug(changeset1);
+		Zotero.debug("CHANGESET2");
+		Zotero.debug(changeset2);
+		
 		var conflicts = [];
 		
 		for (let i = 0; i < changeset1.length; i++) {
@@ -725,27 +886,43 @@ Zotero.Sync.Data.Local = {
 		var conflicts = [];
 		
 		for (let i = 0; i < changeset.length; i++) {
-			let c = changeset[i];
+			let c2 = changeset[i];
 			
 			// Member changes are additive only, so ignore removals
-			if (c.op.endsWith('-remove')) {
+			if (c2.op.endsWith('-remove')) {
 				continue;
 			}
 			
 			// Record member changes
-			if (c.op.startsWith('member-') || c.op.startsWith('property-member-')) {
-				changes.push(c);
+			if (c2.op.startsWith('member-') || c2.op.startsWith('property-member-')) {
+				changes.push(c2);
 				continue;
 			}
 			
 			// Automatically apply remote changes for non-items, even if in conflict
 			if (objectType != 'item') {
-				changes.push(c);
+				changes.push(c2);
 				continue;
 			}
 			
 			// Field changes are conflicts
-			conflicts.push(c);
+			//
+			// Since we don't know what changed, use only 'add' and 'delete'
+			if (c2.op == 'modify') {
+				c2.op = 'add';
+			}
+			let val = currentJSON[c2.field];
+			let c1 = {
+				field: c2.field,
+				op: val !== undefined ? 'add' : 'delete'
+			};
+			if (val !== undefined) {
+				c1.value = val;
+			}
+			if (c2.op == 'modify') {
+				c2.op = 'add';
+			}
+			conflicts.push([c1, c2]);
 		}
 		
 		return { changes, conflicts };
diff --git a/chrome/content/zotero/xpcom/sync/syncRunner.js b/chrome/content/zotero/xpcom/sync/syncRunner.js
index 430639a22..e440b07f1 100644
--- a/chrome/content/zotero/xpcom/sync/syncRunner.js
+++ b/chrome/content/zotero/xpcom/sync/syncRunner.js
@@ -29,34 +29,62 @@ if (!Zotero.Sync) {
 	Zotero.Sync = {};
 }
 
-Zotero.Sync.Runner_Module = function () {
+// Initialized as Zotero.Sync.Runner in zotero.js
+Zotero.Sync.Runner_Module = function (options = {}) {
+	const stopOnError = true;
+	
 	Zotero.defineProperty(this, 'background', { get: () => _background });
 	Zotero.defineProperty(this, 'lastSyncStatus', { get: () => _lastSyncStatus });
 	
-	const stopOnError = true;
+	this.baseURL = options.baseURL || ZOTERO_CONFIG.API_URL;
+	this.apiVersion = options.apiVersion || ZOTERO_CONFIG.API_VERSION;
+	this.apiKey = options.apiKey || Zotero.Sync.Data.Local.getAPIKey();
+	
+	Components.utils.import("resource://zotero/concurrentCaller.js");
+	this.caller = new ConcurrentCaller(4);
+	this.caller.setLogger(msg => Zotero.debug(msg));
+	this.caller.stopOnError = stopOnError;
+	this.caller.onError = function (e) {
+		this.addError(e);
+		if (e.fatal) {
+			this.caller.stop();
+			throw e;
+		}
+	}.bind(this);
 	
 	var _autoSyncTimer;
 	var _background;
 	var _firstInSession = true;
 	var _syncInProgress = false;
 	
+	var _syncEngines = [];
+	var _storageEngines = [];
+	
 	var _lastSyncStatus;
 	var _currentSyncStatusLabel;
 	var _currentLastSyncLabel;
 	var _errors = [];
 	
 	
+	this.getAPIClient = function () {
+		return new Zotero.Sync.APIClient({
+			baseURL: this.baseURL,
+			apiVersion: this.apiVersion,
+			apiKey: this.apiKey,
+			caller: this.caller
+		});
+	}
+	
+	
 	/**
 	 * Begin a sync session
 	 *
-	 * @param {Object} [options]
-	 * @param {String} [apiKey]
-	 * @param {Boolean} [background=false] - Whether this is a background request, which prevents
-	 *                                       some alerts from being shown
-	 * @param {String} [baseURL]
-	 * @param {Integer[]} [libraries] - IDs of libraries to sync
-	 * @param {Function} [onError] - Function to pass errors to instead of handling internally
-	 *                               (used for testing)
+	 * @param {Object}    [options]
+	 * @param {Boolean}   [options.background=false]  Whether this is a background request, which
+	 *                                                prevents some alerts from being shown
+	 * @param {Integer[]} [options.libraries]         IDs of libraries to sync
+	 * @param {Function}  [options.onError]           Function to pass errors to instead of
+	 *                                                handling internally (used for testing)
 	 */
 	this.sync = Zotero.Promise.coroutine(function* (options = {}) {
 		// Clear message list
@@ -84,14 +112,13 @@ Zotero.Sync.Runner_Module = function () {
 		// Purge deleted objects so they don't cause sync errors (e.g., long tags)
 		yield Zotero.purgeDataObjects(true);
 		
-		options.apiKey = options.apiKey || Zotero.Prefs.get('devAPIKey');
-		if (!options.apiKey) {
-			let msg = "API key not provided";
+		if (!this.apiKey) {
+			let msg = "API key not set";
 			let e = new Zotero.Error(msg, 0, { dialogButtonText: null })
 			this.updateIcons(e);
+			_syncInProgress = false;
 			return false;
 		}
-		options.baseURL = options.baseURL || ZOTERO_CONFIG.API_URL;
 		if (_firstInSession) {
 			options.firstInSession = true;
 			_firstInSession = false;
@@ -102,66 +129,45 @@ Zotero.Sync.Runner_Module = function () {
 		this.updateIcons('animate');
 		
 		try {
-			Components.utils.import("resource://zotero/concurrent-caller.js");
-			var caller = new ConcurrentCaller(4); // TEMP: one for now
-			caller.setLogger(msg => Zotero.debug(msg));
-			caller.stopOnError = stopOnError;
-			caller.onError = function (e) {
-				this.addError(e);
-				if (e.fatal) {
-					caller.stop();
-					throw e;
-				}
-			}.bind(this);
+			let client = this.getAPIClient();
 			
-			// TODO: Use a single client for all operations?
-			var client = new Zotero.Sync.APIClient({
-				baseURL: options.baseURL,
-				apiVersion: ZOTERO_CONFIG.API_VERSION,
-				apiKey: options.apiKey,
-				concurrentCaller: caller,
-				background: options.background
-			});
-			
-			var keyInfo = yield this.checkAccess(client, options);
+			let keyInfo = yield this.checkAccess(client, options);
 			if (!keyInfo) {
-				this.stop();
+				this.end();
 				Zotero.debug("Syncing cancelled");
 				return false;
 			}
 			
-			var libraries = yield this.checkLibraries(client, options, keyInfo, libraries);
-			
-			for (let libraryID of libraries) {
-				try {
-					let engine = new Zotero.Sync.Data.Engine({
-						libraryID: libraryID,
-						apiClient: client,
-						setStatus: this.setSyncStatus.bind(this),
-						stopOnError: stopOnError,
-						onError: this.addError.bind(this)
-					});
-					yield engine.start();
-				}
-				catch (e) {
-					Zotero.debug("Sync failed for library " + libraryID);
-					Zotero.debug(e, 1);
-					Components.utils.reportError(e);
-					this.checkError(e);
+			let engineOptions = {
+				apiClient: client,
+				caller: this.caller,
+				setStatus: this.setSyncStatus.bind(this),
+				stopOnError,
+				onError: function (e) {
 					if (options.onError) {
 						options.onError(e);
 					}
 					else {
-						this.addError(e);
+						this.addError.bind(this);
 					}
-					if (stopOnError || e.fatal) {
-						caller.stop();
-						break;
-					}
-				}
-			}
+				}.bind(this),
+				background: _background,
+				firstInSession: _firstInSession
+			};
 			
-			yield Zotero.Sync.Data.Local.updateLastSyncTime();
+			let nextLibraries = yield this.checkLibraries(
+				client, options, keyInfo, options.libraries
+			);
+			// Sync data, files, and then any data that needs to be uploaded
+			let attempt = 1;
+			while (nextLibraries.length) {
+				if (attempt > 3) {
+					throw new Error("Too many sync attempts -- stopping");
+				}
+				nextLibraries = yield _doDataSync(nextLibraries, engineOptions);
+				nextLibraries = yield _doFileSync(nextLibraries, engineOptions);
+				attempt++;
+			}
 		}
 		catch (e) {
 			if (options.onError) {
@@ -171,62 +177,19 @@ Zotero.Sync.Runner_Module = function () {
 				this.addError(e);
 			}
 		}
-		
-		this.stop();
+		finally {
+			this.end();
+		}
 		
 		Zotero.debug("Done syncing");
 		
+		/*if (results.changesMade) {
+			Zotero.debug("Changes made during file sync "
+				+ "-- performing additional data sync");
+			this.sync(options);
+		}*/
+		
 		return;
-		
-		var storageSync = function () {
-			Zotero.Sync.Runner.setSyncStatus(Zotero.getString('sync.status.syncingFiles'));
-			
-			Zotero.Sync.Storage.sync(options)
-			.then(function (results) {
-				Zotero.debug("File sync is finished");
-				
-				if (results.errors.length) {
-					Zotero.debug(results.errors, 1);
-					for each(var e in results.errors) {
-						Components.utils.reportError(e);
-					}
-					Zotero.Sync.Runner.setErrors(results.errors);
-					return;
-				}
-				
-				if (results.changesMade) {
-					Zotero.debug("Changes made during file sync "
-						+ "-- performing additional data sync");
-					Zotero.Sync.Server.sync(finalCallbacks);
-				}
-				else {
-					Zotero.Sync.Runner.stop();
-				}
-			})
-			.catch(function (e) {
-				Zotero.debug("File sync failed", 1);
-				Zotero.Sync.Runner.error(e);
-			})
-			.done();
-		};
-		
-		Zotero.Sync.Server.sync({
-			// Sync 1 success
-			onSuccess: storageSync,
-			
-			// Sync 1 skip
-			onSkip: storageSync,
-			
-			// Sync 1 stop
-			onStop: function () {
-				Zotero.Sync.Runner.stop();
-			},
-			
-			// Sync 1 error
-			onError: function (e) {
-				Zotero.Sync.Runner.error(e);
-			}
-		});
 	});
 	
 	
@@ -242,8 +205,9 @@ Zotero.Sync.Runner_Module = function () {
 		}
 		
 		// Sanity check
-		if (!json.userID) throw new Error("userID not found in response");
-		if (!json.username) throw new Error("username not found in response");
+		if (!json.userID) throw new Error("userID not found in key response");
+		if (!json.username) throw new Error("username not found in key response");
+		if (!json.access) throw new Error("'access' not found in key response");
 		
 		// Make sure user hasn't changed, and prompt to update database if so
 		if (!(yield this.checkUser(json.userID, json.username))) {
@@ -446,8 +410,6 @@ Zotero.Sync.Runner_Module = function () {
 	 *
 	 * @param	{Integer}	userID			New userID
 	 * @param	{Integer}	libraryID		New libraryID
-	 * @param	{Integer}	noServerData	The server account is empty — this is
-	 * 											the account after a server clear
 	 * @return {Boolean} - True to continue, false to cancel
 	 */
 	this.checkUser = Zotero.Promise.coroutine(function* (userID, username) {
@@ -544,7 +506,154 @@ Zotero.Sync.Runner_Module = function () {
 	});
 	
 	
+	var _doDataSync = Zotero.Promise.coroutine(function* (libraries, options, skipUpdateLastSyncTime) {
+		var successfulLibraries = [];
+		for (let libraryID of libraries) {
+			try {
+				let opts = {};
+				Object.assign(opts, options);
+				opts.libraryID = libraryID;
+				
+				let engine = new Zotero.Sync.Data.Engine(opts);
+				yield engine.start();
+				successfulLibraries.push(libraryID);
+			}
+			catch (e) {
+				Zotero.debug("Sync failed for library " + libraryID);
+				Zotero.logError(e);
+				this.checkError(e);
+				if (options.onError) {
+					options.onError(e);
+				}
+				else {
+					this.addError(e);
+				}
+				if (stopOnError || e.fatal) {
+					Zotero.debug("Stopping on error", 1);
+					options.caller.stop();
+					break;
+				}
+			}
+		}
+		// Update last-sync time if any libraries synced
+		// TEMP: Do we want to show updated time if some libraries haven't synced?
+		if (!libraries.length || successfulLibraries.length) {
+			yield Zotero.Sync.Data.Local.updateLastSyncTime();
+		}
+		return successfulLibraries;
+	}.bind(this));
+	
+	
+	var _doFileSync = Zotero.Promise.coroutine(function* (libraries, options) {
+		Zotero.debug("Starting file syncing");
+		this.setSyncStatus(Zotero.getString('sync.status.syncingFiles'));
+		let librariesToSync = [];
+		for (let libraryID of libraries) {
+			try {
+				let opts = {};
+				Object.assign(opts, options);
+				opts.libraryID = libraryID;
+				
+				let tries = 3;
+				while (true) {
+					if (tries == 0) {
+						throw new Error("Too many file sync attempts for library " + libraryID);
+					}
+					tries--;
+					let engine = new Zotero.Sync.Storage.Engine(opts);
+					let results = yield engine.start();
+					if (results.syncRequired) {
+						librariesToSync.push(libraryID);
+					}
+					else if (results.fileSyncRequired) {
+						Zotero.debug("Another file sync required -- restarting");
+						continue;
+					}
+					break;
+				}
+			}
+			catch (e) {
+				Zotero.debug("File sync failed for library " + libraryID);
+				Zotero.debug(e, 1);
+				Components.utils.reportError(e);
+				this.checkError(e);
+				if (options.onError) {
+					options.onError(e);
+				}
+				else {
+					this.addError(e);
+				}
+				if (stopOnError || e.fatal) {
+					options.caller.stop();
+					break;
+				}
+			}
+		}
+		Zotero.debug("Done with file syncing");
+		return librariesToSync;
+	}.bind(this));
+	
+	
+	/**
+	 * Download a single file on demand (not within a sync process)
+	 */
+	this.downloadFile = Zotero.Promise.coroutine(function* (item, requestCallbacks) {
+		if (Zotero.HTTP.browserIsOffline()) {
+			Zotero.debug("Browser is offline", 2);
+			return false;
+		}
+		
+		// TEMP
+		var options = {};
+		
+		var itemID = item.id;
+		var modeClass = Zotero.Sync.Storage.Local.getClassForLibrary(item.libraryID);
+		var controller = new modeClass({
+			apiClient: this.getAPIClient()
+		});
+		
+		// TODO: verify WebDAV on-demand?
+		if (!controller.verified) {
+			Zotero.debug("File syncing is not active for item's library -- skipping download");
+			return false;
+		}
+		
+		if (!item.isImportedAttachment()) {
+			throw new Error("Not an imported attachment");
+		}
+		
+		if (yield item.getFilePathAsync()) {
+			Zotero.debug("File already exists -- replacing");
+		}
+		
+		// TODO: start sync icon?
+		// TODO: create queue for cancelling
+		
+		if (!requestCallbacks) {
+			requestCallbacks = {};
+		}
+		var onStart = function (request) {
+			return controller.downloadFile(request);
+		};
+		var request = new Zotero.Sync.Storage.Request({
+			type: 'download',
+			libraryID: item.libraryID,
+			name: item.libraryKey,
+			onStart: requestCallbacks.onStart
+				? [onStart, requestCallbacks.onStart]
+				: [onStart]
+		});
+		return request.start();
+	});
+	
+	
 	this.stop = function () {
+		_syncEngines.forEach(engine => engine.stop());
+		_storageEngines.forEach(engine => engine.stop());
+	}
+	
+	
+	this.end = function () {
 		this.updateIcons(_errors);
 		_errors = [];
 		_syncInProgress = false;
@@ -669,7 +778,6 @@ Zotero.Sync.Runner_Module = function () {
 		if (libraryID) {
 			e.libraryID = libraryID;
 		}
-		Zotero.logError(e);
 		_errors.push(this.parseError(e));
 	}
 	
@@ -1027,7 +1135,8 @@ Zotero.Sync.Runner_Module = function () {
 		var lastSyncTime = Zotero.Sync.Data.Local.getLastSyncTime();
 		if (!lastSyncTime) {
 			try {
-				lastSyncTime = Zotero.Sync.Data.Local.getLastClassicSyncTime()
+				lastSyncTime = Zotero.Sync.Data.Local.getLastClassicSyncTime();
+				Zotero.debug(lastSyncTime);
 			}
 			catch (e) {
 				Zotero.debug(e, 2);
@@ -1052,5 +1161,3 @@ Zotero.Sync.Runner_Module = function () {
 		_currentLastSyncLabel.hidden = false;
 	}
 }
-
-Zotero.Sync.Runner = new Zotero.Sync.Runner_Module;
diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js
index 1a6f09e6b..7cbb0ee92 100644
--- a/chrome/content/zotero/xpcom/zotero.js
+++ b/chrome/content/zotero/xpcom/zotero.js
@@ -607,6 +607,7 @@ Components.utils.import("resource://gre/modules/osfile.jsm");
 				yield Zotero.Sync.Data.Local.init();
 				yield Zotero.Sync.Data.Utilities.init();
 				Zotero.Sync.EventListeners.init();
+				Zotero.Sync.Runner = new Zotero.Sync.Runner_Module;
 				
 				Zotero.MIMETypeHandler.init();
 				yield Zotero.Proxies.init();
@@ -2706,6 +2707,9 @@ Zotero.Browser = new function() {
 			if(!win) {
 				var win = Services.ww.activeWindow;
 			}
+			if (!win) {
+				throw new Error("Parent window not available for hidden browser");
+			}
 		}
 		
 		// Create a hidden browser
diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js
index 7840b2444..0126a10cb 100644
--- a/chrome/content/zotero/zoteroPane.js
+++ b/chrome/content/zotero/zoteroPane.js
@@ -1325,6 +1325,7 @@ var ZoteroPane = new function()
 				else if (item.isAttachment()) {
 					var attachmentBox = document.getElementById('zotero-attachment-box');
 					attachmentBox.mode = this.collectionsView.editable ? 'edit' : 'view';
+					yield item.loadNote();
 					attachmentBox.item = item;
 					
 					document.getElementById('zotero-item-pane-content').selectedIndex = 3;
@@ -3692,38 +3693,41 @@ var ZoteroPane = new function()
 				}
 			}
 			else {
-				if (!item.isImportedAttachment() || !Zotero.Sync.Storage.downloadAsNeeded(item.libraryID)) {
+				if (!item.isImportedAttachment()
+						|| !Zotero.Sync.Storage.Local.downloadAsNeeded(item.libraryID)) {
 					this.showAttachmentNotFoundDialog(itemID, noLocateOnMissing);
 					return;
 				}
 				
 				let downloadedItem = item;
-				yield Zotero.Sync.Storage.downloadFile(
-					downloadedItem,
-					{
-						onProgress: function (progress, progressMax) {}
-					}
-				)
-				.then(function () {
-					if (!downloadedItem.getFile()) {
-						ZoteroPane_Local.showAttachmentNotFoundDialog(downloadedItem.id, noLocateOnMissing);
-						return;
-					}
-					
-					// check if unchanged?
-					// maybe not necessary, since we'll get an error if there's an error
-					
-					
-					Zotero.Notifier.trigger('redraw', 'item', []);
-					Zotero.debug('downloaded');
-					Zotero.debug(downloadedItem.id);
-					return ZoteroPane_Local.viewAttachment(downloadedItem.id, event, false, forceExternalViewer);
-				})
-				.catch(function (e) {
+				try {
+					yield Zotero.Sync.Runner.downloadFile(
+						downloadedItem,
+						{
+							onProgress: function (progress, progressMax) {}
+						}
+					);
+				}
+				catch (e) {
 					// TODO: show error somewhere else
 					Zotero.debug(e, 1);
 					ZoteroPane_Local.syncAlert(e);
-				});
+					return;
+				}
+				
+				if (!(yield downloadedItem.getFilePathAsync())) {
+					ZoteroPane_Local.showAttachmentNotFoundDialog(downloadedItem.id, noLocateOnMissing);
+					return;
+				}
+				
+				// check if unchanged?
+				// maybe not necessary, since we'll get an error if there's an error
+				
+				
+				Zotero.Notifier.trigger('redraw', 'item', []);
+				Zotero.debug('downloaded');
+				Zotero.debug(downloadedItem.id);
+				return ZoteroPane_Local.viewAttachment(downloadedItem.id, event, false, forceExternalViewer);
 			}
 		}
 	});
@@ -3962,7 +3966,7 @@ var ZoteroPane = new function()
 	
 	
 	this.syncAlert = function (e) {
-		e = Zotero.Sync.Runner.parseSyncError(e);
+		e = Zotero.Sync.Runner.parseError(e);
 		
 		var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
 					.getService(Components.interfaces.nsIPromptService);
diff --git a/chrome/content/zotero/zoteroPane.xul b/chrome/content/zotero/zoteroPane.xul
index 4f6b73121..003a2da04 100644
--- a/chrome/content/zotero/zoteroPane.xul
+++ b/chrome/content/zotero/zoteroPane.xul
@@ -195,9 +195,10 @@
 					</hbox>
 					<hbox align="center" pack="end">
 						<hbox id="zotero-tb-sync-progress-box" hidden="true" align="center">
+							<!-- TODO: localize -->
 							<toolbarbutton id="zotero-tb-sync-storage-cancel"
-								tooltiptext="Cancel Storage Sync"
-								oncommand="Zotero.Sync.Storage.QueueManager.cancel()"/>
+								tooltiptext="Stop sync"
+								oncommand="Zotero.Sync.Runner.stop()"/>
 							<progressmeter id="zotero-tb-sync-progress" mode="determined"
 								value="0" tooltip="zotero-tb-sync-progress-tooltip">
 							</progressmeter>
diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties
index 4dcb6ab42..db276f36e 100644
--- a/chrome/locale/en-US/zotero/zotero.properties
+++ b/chrome/locale/en-US/zotero/zotero.properties
@@ -961,12 +961,12 @@ rtfScan.saveTitle					= Select a location in which to save the formatted file
 rtfScan.scannedFileSuffix			= (Scanned)
 
 
-file.accessError.theFile			= The file '%S'
-file.accessError.aFile				= A file
-file.accessError.cannotBe			= cannot be
-file.accessError.created			= created
-file.accessError.updated			= updated
-file.accessError.deleted			= deleted
+file.accessError.theFileCannotBeCreated = The file '%S' cannot be created.
+file.accessError.theFileCannotBeUpdated = The file '%S' cannot be updated.
+file.accessError.theFileCannotBeDeleted = The file '%S' cannot be deleted.
+file.accessError.aFileCannotBeCreated   = A file cannot be created.
+file.accessError.aFileCannotBeUpdated   = A file cannot be updated.
+file.accessError.aFileCannotBeDeleted   = A file cannot be deleted.
 file.accessError.message.windows	= Check that the file is not currently in use, that its permissions allow write access, and that it has a valid filename.
 file.accessError.message.other		= Check that the file is not currently in use and that its permissions allow write access.
 file.accessError.restart			= Restarting your computer or disabling security software may also help.
diff --git a/components/zotero-service.js b/components/zotero-service.js
index 63cfc3a99..a3f114dd2 100644
--- a/components/zotero-service.js
+++ b/components/zotero-service.js
@@ -107,11 +107,12 @@ const xpcomFilesLocal = [
 	'sync/syncRunner',
 	'sync/syncUtilities',
 	'storage',
+	'storage/storageEngine',
+	'storage/storageLocal',
+	'storage/storageRequest',
+	'storage/storageResult',
+	'storage/storageUtilities',
 	'storage/streamListener',
-	'storage/queueManager',
-	'storage/queue',
-	'storage/request',
-	'storage/mode',
 	'storage/zfs',
 	'storage/webdav',
 	'syncedSettings',
diff --git a/test/resource/httpd.js b/test/resource/httpd.js
new file mode 100644
index 000000000..c72e47f50
--- /dev/null
+++ b/test/resource/httpd.js
@@ -0,0 +1,5356 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * An implementation of an HTTP server both as a loadable script and as an XPCOM
+ * component.  See the accompanying README file for user documentation on
+ * httpd.js.
+ */
+
+this.EXPORTED_SYMBOLS = [
+  "HTTP_400",
+  "HTTP_401",
+  "HTTP_402",
+  "HTTP_403",
+  "HTTP_404",
+  "HTTP_405",
+  "HTTP_406",
+  "HTTP_407",
+  "HTTP_408",
+  "HTTP_409",
+  "HTTP_410",
+  "HTTP_411",
+  "HTTP_412",
+  "HTTP_413",
+  "HTTP_414",
+  "HTTP_415",
+  "HTTP_417",
+  "HTTP_500",
+  "HTTP_501",
+  "HTTP_502",
+  "HTTP_503",
+  "HTTP_504",
+  "HTTP_505",
+  "HttpError",
+  "HttpServer",
+];
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cr = Components.results;
+const Cu = Components.utils;
+const CC = Components.Constructor;
+
+const PR_UINT32_MAX = Math.pow(2, 32) - 1;
+
+/** True if debugging output is enabled, false otherwise. */
+var DEBUG = false; // non-const *only* so tweakable in server tests
+
+/** True if debugging output should be timestamped. */
+var DEBUG_TIMESTAMP = false; // non-const so tweakable in server tests
+
+var gGlobalObject = this;
+
+/**
+ * Asserts that the given condition holds.  If it doesn't, the given message is
+ * dumped, a stack trace is printed, and an exception is thrown to attempt to
+ * stop execution (which unfortunately must rely upon the exception not being
+ * accidentally swallowed by the code that uses it).
+ */
+function NS_ASSERT(cond, msg)
+{
+  if (DEBUG && !cond)
+  {
+    dumpn("###!!!");
+    dumpn("###!!! ASSERTION" + (msg ? ": " + msg : "!"));
+    dumpn("###!!! Stack follows:");
+
+    var stack = new Error().stack.split(/\n/);
+    dumpn(stack.map(function(val) { return "###!!!   " + val; }).join("\n"));
+
+    throw Cr.NS_ERROR_ABORT;
+  }
+}
+
+/** Constructs an HTTP error object. */
+this.HttpError = function HttpError(code, description)
+{
+  this.code = code;
+  this.description = description;
+}
+HttpError.prototype =
+{
+  toString: function()
+  {
+    return this.code + " " + this.description;
+  }
+};
+
+/**
+ * Errors thrown to trigger specific HTTP server responses.
+ */
+this.HTTP_400 = new HttpError(400, "Bad Request");
+this.HTTP_401 = new HttpError(401, "Unauthorized");
+this.HTTP_402 = new HttpError(402, "Payment Required");
+this.HTTP_403 = new HttpError(403, "Forbidden");
+this.HTTP_404 = new HttpError(404, "Not Found");
+this.HTTP_405 = new HttpError(405, "Method Not Allowed");
+this.HTTP_406 = new HttpError(406, "Not Acceptable");
+this.HTTP_407 = new HttpError(407, "Proxy Authentication Required");
+this.HTTP_408 = new HttpError(408, "Request Timeout");
+this.HTTP_409 = new HttpError(409, "Conflict");
+this.HTTP_410 = new HttpError(410, "Gone");
+this.HTTP_411 = new HttpError(411, "Length Required");
+this.HTTP_412 = new HttpError(412, "Precondition Failed");
+this.HTTP_413 = new HttpError(413, "Request Entity Too Large");
+this.HTTP_414 = new HttpError(414, "Request-URI Too Long");
+this.HTTP_415 = new HttpError(415, "Unsupported Media Type");
+this.HTTP_417 = new HttpError(417, "Expectation Failed");
+
+this.HTTP_500 = new HttpError(500, "Internal Server Error");
+this.HTTP_501 = new HttpError(501, "Not Implemented");
+this.HTTP_502 = new HttpError(502, "Bad Gateway");
+this.HTTP_503 = new HttpError(503, "Service Unavailable");
+this.HTTP_504 = new HttpError(504, "Gateway Timeout");
+this.HTTP_505 = new HttpError(505, "HTTP Version Not Supported");
+
+/** Creates a hash with fields corresponding to the values in arr. */
+function array2obj(arr)
+{
+  var obj = {};
+  for (var i = 0; i < arr.length; i++)
+    obj[arr[i]] = arr[i];
+  return obj;
+}
+
+/** Returns an array of the integers x through y, inclusive. */
+function range(x, y)
+{
+  var arr = [];
+  for (var i = x; i <= y; i++)
+    arr.push(i);
+  return arr;
+}
+
+/** An object (hash) whose fields are the numbers of all HTTP error codes. */
+const HTTP_ERROR_CODES = array2obj(range(400, 417).concat(range(500, 505)));
+
+
+/**
+ * The character used to distinguish hidden files from non-hidden files, a la
+ * the leading dot in Apache.  Since that mechanism also hides files from
+ * easy display in LXR, ls output, etc. however, we choose instead to use a
+ * suffix character.  If a requested file ends with it, we append another
+ * when getting the file on the server.  If it doesn't, we just look up that
+ * file.  Therefore, any file whose name ends with exactly one of the character
+ * is "hidden" and available for use by the server.
+ */
+const HIDDEN_CHAR = "^";
+
+/**
+ * The file name suffix indicating the file containing overridden headers for
+ * a requested file.
+ */
+const HEADERS_SUFFIX = HIDDEN_CHAR + "headers" + HIDDEN_CHAR;
+
+/** Type used to denote SJS scripts for CGI-like functionality. */
+const SJS_TYPE = "sjs";
+
+/** Base for relative timestamps produced by dumpn(). */
+var firstStamp = 0;
+
+/** dump(str) with a trailing "\n" -- only outputs if DEBUG. */
+function dumpn(str)
+{
+  if (DEBUG)
+  {
+    var prefix = "HTTPD-INFO | ";
+    if (DEBUG_TIMESTAMP)
+    {
+      if (firstStamp === 0)
+        firstStamp = Date.now();
+
+      var elapsed = Date.now() - firstStamp; // milliseconds
+      var min = Math.floor(elapsed / 60000);
+      var sec = (elapsed % 60000) / 1000;
+
+      if (sec < 10)
+        prefix += min + ":0" + sec.toFixed(3) + " | ";
+      else
+        prefix += min + ":" + sec.toFixed(3) + " | ";
+    }
+
+    dump(prefix + str + "\n");
+  }
+}
+
+/** Dumps the current JS stack if DEBUG. */
+function dumpStack()
+{
+  // peel off the frames for dumpStack() and Error()
+  var stack = new Error().stack.split(/\n/).slice(2);
+  stack.forEach(dumpn);
+}
+
+
+/** The XPCOM thread manager. */
+var gThreadManager = null;
+
+/** The XPCOM prefs service. */
+var gRootPrefBranch = null;
+function getRootPrefBranch()
+{
+  if (!gRootPrefBranch)
+  {
+    gRootPrefBranch = Cc["@mozilla.org/preferences-service;1"]
+                        .getService(Ci.nsIPrefBranch);
+  }
+  return gRootPrefBranch;
+}
+
+/**
+ * JavaScript constructors for commonly-used classes; precreating these is a
+ * speedup over doing the same from base principles.  See the docs at
+ * http://developer.mozilla.org/en/docs/Components.Constructor for details.
+ */
+const ServerSocket = CC("@mozilla.org/network/server-socket;1",
+                        "nsIServerSocket",
+                        "init");
+const ScriptableInputStream = CC("@mozilla.org/scriptableinputstream;1",
+                                 "nsIScriptableInputStream",
+                                 "init");
+const Pipe = CC("@mozilla.org/pipe;1",
+                "nsIPipe",
+                "init");
+const FileInputStream = CC("@mozilla.org/network/file-input-stream;1",
+                           "nsIFileInputStream",
+                           "init");
+const ConverterInputStream = CC("@mozilla.org/intl/converter-input-stream;1",
+                                "nsIConverterInputStream",
+                                "init");
+const WritablePropertyBag = CC("@mozilla.org/hash-property-bag;1",
+                               "nsIWritablePropertyBag2");
+const SupportsString = CC("@mozilla.org/supports-string;1",
+                          "nsISupportsString");
+
+/* These two are non-const only so a test can overwrite them. */
+var BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
+                           "nsIBinaryInputStream",
+                           "setInputStream");
+var BinaryOutputStream = CC("@mozilla.org/binaryoutputstream;1",
+                            "nsIBinaryOutputStream",
+                            "setOutputStream");
+
+/**
+ * Returns the RFC 822/1123 representation of a date.
+ *
+ * @param date : Number
+ *   the date, in milliseconds from midnight (00:00:00), January 1, 1970 GMT
+ * @returns string
+ *   the representation of the given date
+ */
+function toDateString(date)
+{
+  //
+  // rfc1123-date = wkday "," SP date1 SP time SP "GMT"
+  // date1        = 2DIGIT SP month SP 4DIGIT
+  //                ; day month year (e.g., 02 Jun 1982)
+  // time         = 2DIGIT ":" 2DIGIT ":" 2DIGIT
+  //                ; 00:00:00 - 23:59:59
+  // wkday        = "Mon" | "Tue" | "Wed"
+  //              | "Thu" | "Fri" | "Sat" | "Sun"
+  // month        = "Jan" | "Feb" | "Mar" | "Apr"
+  //              | "May" | "Jun" | "Jul" | "Aug"
+  //              | "Sep" | "Oct" | "Nov" | "Dec"
+  //
+
+  const wkdayStrings = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+  const monthStrings = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
+                        "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+  /**
+   * Processes a date and returns the encoded UTC time as a string according to
+   * the format specified in RFC 2616.
+   *
+   * @param date : Date
+   *   the date to process
+   * @returns string
+   *   a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59"
+   */
+  function toTime(date)
+  {
+    var hrs = date.getUTCHours();
+    var rv  = (hrs < 10) ? "0" + hrs : hrs;
+    
+    var mins = date.getUTCMinutes();
+    rv += ":";
+    rv += (mins < 10) ? "0" + mins : mins;
+
+    var secs = date.getUTCSeconds();
+    rv += ":";
+    rv += (secs < 10) ? "0" + secs : secs;
+
+    return rv;
+  }
+
+  /**
+   * Processes a date and returns the encoded UTC date as a string according to
+   * the date1 format specified in RFC 2616.
+   *
+   * @param date : Date
+   *   the date to process
+   * @returns string
+   *   a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59"
+   */
+  function toDate1(date)
+  {
+    var day = date.getUTCDate();
+    var month = date.getUTCMonth();
+    var year = date.getUTCFullYear();
+
+    var rv = (day < 10) ? "0" + day : day;
+    rv += " " + monthStrings[month];
+    rv += " " + year;
+
+    return rv;
+  }
+
+  date = new Date(date);
+
+  const fmtString = "%wkday%, %date1% %time% GMT";
+  var rv = fmtString.replace("%wkday%", wkdayStrings[date.getUTCDay()]);
+  rv = rv.replace("%time%", toTime(date));
+  return rv.replace("%date1%", toDate1(date));
+}
+
+/**
+ * Prints out a human-readable representation of the object o and its fields,
+ * omitting those whose names begin with "_" if showMembers != true (to ignore
+ * "private" properties exposed via getters/setters).
+ */
+function printObj(o, showMembers)
+{
+  var s = "******************************\n";
+  s +=    "o = {\n";
+  for (var i in o)
+  {
+    if (typeof(i) != "string" ||
+        (showMembers || (i.length > 0 && i[0] != "_")))
+      s+= "      " + i + ": " + o[i] + ",\n";
+  }
+  s +=    "    };\n";
+  s +=    "******************************";
+  dumpn(s);
+}
+
+/**
+ * Instantiates a new HTTP server.
+ */
+function nsHttpServer()
+{
+  if (!gThreadManager)
+    gThreadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+
+  /** The port on which this server listens. */
+  this._port = undefined;
+
+  /** The socket associated with this. */
+  this._socket = null;
+
+  /** The handler used to process requests to this server. */
+  this._handler = new ServerHandler(this);
+
+  /** Naming information for this server. */
+  this._identity = new ServerIdentity();
+
+  /**
+   * Indicates when the server is to be shut down at the end of the request.
+   */
+  this._doQuit = false;
+
+  /**
+   * True if the socket in this is closed (and closure notifications have been
+   * sent and processed if the socket was ever opened), false otherwise.
+   */
+  this._socketClosed = true;
+
+  /**
+   * Used for tracking existing connections and ensuring that all connections
+   * are properly cleaned up before server shutdown; increases by 1 for every
+   * new incoming connection.
+   */
+  this._connectionGen = 0;
+
+  /**
+   * Hash of all open connections, indexed by connection number at time of
+   * creation.
+   */
+  this._connections = {};
+}
+nsHttpServer.prototype =
+{
+  classID: Components.ID("{54ef6f81-30af-4b1d-ac55-8ba811293e41}"),
+
+  // NSISERVERSOCKETLISTENER
+
+  /**
+   * Processes an incoming request coming in on the given socket and contained
+   * in the given transport.
+   *
+   * @param socket : nsIServerSocket
+   *   the socket through which the request was served
+   * @param trans : nsISocketTransport
+   *   the transport for the request/response
+   * @see nsIServerSocketListener.onSocketAccepted
+   */
+  onSocketAccepted: function(socket, trans)
+  {
+    dumpn("*** onSocketAccepted(socket=" + socket + ", trans=" + trans + ")");
+
+    dumpn(">>> new connection on " + trans.host + ":" + trans.port);
+
+    const SEGMENT_SIZE = 8192;
+    const SEGMENT_COUNT = 1024;
+    try
+    {
+      var input = trans.openInputStream(0, SEGMENT_SIZE, SEGMENT_COUNT)
+                       .QueryInterface(Ci.nsIAsyncInputStream);
+      var output = trans.openOutputStream(0, 0, 0);
+    }
+    catch (e)
+    {
+      dumpn("*** error opening transport streams: " + e);
+      trans.close(Cr.NS_BINDING_ABORTED);
+      return;
+    }
+
+    var connectionNumber = ++this._connectionGen;
+
+    try
+    {
+      var conn = new Connection(input, output, this, socket.port, trans.port,
+                                connectionNumber);
+      var reader = new RequestReader(conn);
+
+      // XXX add request timeout functionality here!
+
+      // Note: must use main thread here, or we might get a GC that will cause
+      //       threadsafety assertions.  We really need to fix XPConnect so that
+      //       you can actually do things in multi-threaded JS.  :-(
+      input.asyncWait(reader, 0, 0, gThreadManager.mainThread);
+    }
+    catch (e)
+    {
+      // Assume this connection can't be salvaged and bail on it completely;
+      // don't attempt to close it so that we can assert that any connection
+      // being closed is in this._connections.
+      dumpn("*** error in initial request-processing stages: " + e);
+      trans.close(Cr.NS_BINDING_ABORTED);
+      return;
+    }
+
+    this._connections[connectionNumber] = conn;
+    dumpn("*** starting connection " + connectionNumber);
+  },
+
+  /**
+   * Called when the socket associated with this is closed.
+   *
+   * @param socket : nsIServerSocket
+   *   the socket being closed
+   * @param status : nsresult
+   *   the reason the socket stopped listening (NS_BINDING_ABORTED if the server
+   *   was stopped using nsIHttpServer.stop)
+   * @see nsIServerSocketListener.onStopListening
+   */
+  onStopListening: function(socket, status)
+  {
+    dumpn(">>> shutting down server on port " + socket.port);
+    for (var n in this._connections) {
+      if (!this._connections[n]._requestStarted) {
+        this._connections[n].close();
+      }
+    }
+    this._socketClosed = true;
+    if (this._hasOpenConnections()) {
+      dumpn("*** open connections!!!");
+    }
+    if (!this._hasOpenConnections())
+    {
+      dumpn("*** no open connections, notifying async from onStopListening");
+
+      // Notify asynchronously so that any pending teardown in stop() has a
+      // chance to run first.
+      var self = this;
+      var stopEvent =
+        {
+          run: function()
+          {
+            dumpn("*** _notifyStopped async callback");
+            self._notifyStopped();
+          }
+        };
+      gThreadManager.currentThread
+                    .dispatch(stopEvent, Ci.nsIThread.DISPATCH_NORMAL);
+    }
+  },
+
+  // NSIHTTPSERVER
+
+  //
+  // see nsIHttpServer.start
+  //
+  start: function(port)
+  {
+    this._start(port, "localhost")
+  },
+
+  _start: function(port, host)
+  {
+    if (this._socket)
+      throw Cr.NS_ERROR_ALREADY_INITIALIZED;
+
+    this._port = port;
+    this._doQuit = this._socketClosed = false;
+
+    this._host = host;
+
+    // The listen queue needs to be long enough to handle
+    // network.http.max-persistent-connections-per-server or
+    // network.http.max-persistent-connections-per-proxy concurrent
+    // connections, plus a safety margin in case some other process is
+    // talking to the server as well.
+    var prefs = getRootPrefBranch();
+    var maxConnections = 5 + Math.max(
+      prefs.getIntPref("network.http.max-persistent-connections-per-server"),
+      prefs.getIntPref("network.http.max-persistent-connections-per-proxy"));
+
+    try
+    {
+      var loopback = true;
+      if (this._host != "127.0.0.1" && this._host != "localhost") {
+        var loopback = false;
+      }
+
+      // When automatically selecting a port, sometimes the chosen port is
+      // "blocked" from clients. We don't want to use these ports because
+      // tests will intermittently fail. So, we simply keep trying to to
+      // get a server socket until a valid port is obtained. We limit
+      // ourselves to finite attempts just so we don't loop forever.
+      var ios = Cc["@mozilla.org/network/io-service;1"]
+                  .getService(Ci.nsIIOService);
+      var socket;
+      for (var i = 100; i; i--)
+      {
+        var temp = new ServerSocket(this._port,
+                                    loopback, // true = localhost, false = everybody
+                                    maxConnections);
+
+        var allowed = ios.allowPort(temp.port, "http");
+        if (!allowed)
+        {
+          dumpn(">>>Warning: obtained ServerSocket listens on a blocked " +
+                "port: " + temp.port);
+        }
+
+        if (!allowed && this._port == -1)
+        {
+          dumpn(">>>Throwing away ServerSocket with bad port.");
+          temp.close();
+          continue;
+        }
+
+        socket = temp;
+        break;
+      }
+
+      if (!socket) {
+        throw new Error("No socket server available. Are there no available ports?");
+      }
+
+      dumpn(">>> listening on port " + socket.port + ", " + maxConnections +
+            " pending connections");
+      socket.asyncListen(this);
+      this._port = socket.port;
+      this._identity._initialize(socket.port, host, true);
+      this._socket = socket;
+    }
+    catch (e)
+    {
+      dump("\n!!! could not start server on port " + port + ": " + e + "\n\n");
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+    }
+  },
+
+  //
+  // see nsIHttpServer.stop
+  //
+  stop: function(callback)
+  {
+    if (!callback)
+      throw Cr.NS_ERROR_NULL_POINTER;
+    if (!this._socket)
+      throw Cr.NS_ERROR_UNEXPECTED;
+
+    this._stopCallback = typeof callback === "function"
+                       ? callback
+                       : function() { callback.onStopped(); };
+
+    dumpn(">>> stopping listening on port " + this._socket.port);
+    this._socket.close();
+    this._socket = null;
+
+    // We can't have this identity any more, and the port on which we're running
+    // this server now could be meaningless the next time around.
+    this._identity._teardown();
+
+    this._doQuit = false;
+
+    // socket-close notification and pending request completion happen async
+  },
+
+  //
+  // see nsIHttpServer.registerFile
+  //
+  registerFile: function(path, file)
+  {
+    if (file && (!file.exists() || file.isDirectory()))
+      throw Cr.NS_ERROR_INVALID_ARG;
+
+    this._handler.registerFile(path, file);
+  },
+
+  //
+  // see nsIHttpServer.registerDirectory
+  //
+  registerDirectory: function(path, directory)
+  {
+    // XXX true path validation!
+    if (path.charAt(0) != "/" ||
+        path.charAt(path.length - 1) != "/" ||
+        (directory &&
+         (!directory.exists() || !directory.isDirectory())))
+      throw Cr.NS_ERROR_INVALID_ARG;
+
+    // XXX determine behavior of nonexistent /foo/bar when a /foo/bar/ mapping
+    //     exists!
+
+    this._handler.registerDirectory(path, directory);
+  },
+
+  //
+  // see nsIHttpServer.registerPathHandler
+  //
+  registerPathHandler: function(path, handler)
+  {
+    this._handler.registerPathHandler(path, handler);
+  },
+
+  //
+  // see nsIHttpServer.registerPrefixHandler
+  //
+  registerPrefixHandler: function(prefix, handler)
+  {
+    this._handler.registerPrefixHandler(prefix, handler);
+  },
+
+  //
+  // see nsIHttpServer.registerErrorHandler
+  //
+  registerErrorHandler: function(code, handler)
+  {
+    this._handler.registerErrorHandler(code, handler);
+  },
+
+  //
+  // see nsIHttpServer.setIndexHandler
+  //
+  setIndexHandler: function(handler)
+  {
+    this._handler.setIndexHandler(handler);
+  },
+
+  //
+  // see nsIHttpServer.registerContentType
+  //
+  registerContentType: function(ext, type)
+  {
+    this._handler.registerContentType(ext, type);
+  },
+
+  //
+  // see nsIHttpServer.serverIdentity
+  //
+  get identity()
+  {
+    return this._identity;
+  },
+
+  //
+  // see nsIHttpServer.getState
+  //
+  getState: function(path, k)
+  {
+    return this._handler._getState(path, k);
+  },
+
+  //
+  // see nsIHttpServer.setState
+  //
+  setState: function(path, k, v)
+  {
+    return this._handler._setState(path, k, v);
+  },
+
+  //
+  // see nsIHttpServer.getSharedState
+  //
+  getSharedState: function(k)
+  {
+    return this._handler._getSharedState(k);
+  },
+
+  //
+  // see nsIHttpServer.setSharedState
+  //
+  setSharedState: function(k, v)
+  {
+    return this._handler._setSharedState(k, v);
+  },
+
+  //
+  // see nsIHttpServer.getObjectState
+  //
+  getObjectState: function(k)
+  {
+    return this._handler._getObjectState(k);
+  },
+
+  //
+  // see nsIHttpServer.setObjectState
+  //
+  setObjectState: function(k, v)
+  {
+    return this._handler._setObjectState(k, v);
+  },
+
+
+  // NSISUPPORTS
+
+  //
+  // see nsISupports.QueryInterface
+  //
+  QueryInterface: function(iid)
+  {
+    if (iid.equals(Ci.nsIHttpServer) ||
+        iid.equals(Ci.nsIServerSocketListener) ||
+        iid.equals(Ci.nsISupports))
+      return this;
+
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  },
+
+
+  // NON-XPCOM PUBLIC API
+
+  /**
+   * Returns true iff this server is not running (and is not in the process of
+   * serving any requests still to be processed when the server was last
+   * stopped after being run).
+   */
+  isStopped: function()
+  {
+    return this._socketClosed && !this._hasOpenConnections();
+  },
+
+  // PRIVATE IMPLEMENTATION
+
+  /** True if this server has any open connections to it, false otherwise. */
+  _hasOpenConnections: function()
+  {
+    //
+    // If we have any open connections, they're tracked as numeric properties on
+    // |this._connections|.  The non-standard __count__ property could be used
+    // to check whether there are any properties, but standard-wise, even
+    // looking forward to ES5, there's no less ugly yet still O(1) way to do
+    // this.
+    //
+    for (var n in this._connections)
+      return true;
+    return false;
+  },
+
+  /** Calls the server-stopped callback provided when stop() was called. */
+  _notifyStopped: function()
+  {
+    NS_ASSERT(this._stopCallback !== null, "double-notifying?");
+    NS_ASSERT(!this._hasOpenConnections(), "should be done serving by now");
+
+    //
+    // NB: We have to grab this now, null out the member, *then* call the
+    //     callback here, or otherwise the callback could (indirectly) futz with
+    //     this._stopCallback by starting and immediately stopping this, at
+    //     which point we'd be nulling out a field we no longer have a right to
+    //     modify.
+    //
+    var callback = this._stopCallback;
+    this._stopCallback = null;
+    try
+    {
+      callback();
+    }
+    catch (e)
+    {
+      // not throwing because this is specified as being usually (but not
+      // always) asynchronous
+      dump("!!! error running onStopped callback: " + e + "\n");
+    }
+  },
+
+  /**
+   * Notifies this server that the given connection has been closed.
+   *
+   * @param connection : Connection
+   *   the connection that was closed
+   */
+  _connectionClosed: function(connection)
+  {
+    NS_ASSERT(connection.number in this._connections,
+              "closing a connection " + this + " that we never added to the " +
+              "set of open connections?");
+    NS_ASSERT(this._connections[connection.number] === connection,
+              "connection number mismatch?  " +
+              this._connections[connection.number]);
+    delete this._connections[connection.number];
+
+    // Fire a pending server-stopped notification if it's our responsibility.
+    if (!this._hasOpenConnections() && this._socketClosed)
+      this._notifyStopped();
+    // Bug 508125: Add a GC here else we'll use gigabytes of memory running
+    // mochitests. We can't rely on xpcshell doing an automated GC, as that
+    // would interfere with testing GC stuff...
+    Components.utils.forceGC();
+  },
+
+  /**
+   * Requests that the server be shut down when possible.
+   */
+  _requestQuit: function()
+  {
+    dumpn(">>> requesting a quit");
+    dumpStack();
+    this._doQuit = true;
+  }
+};
+
+this.HttpServer = nsHttpServer;
+
+//
+// RFC 2396 section 3.2.2:
+//
+// host        = hostname | IPv4address
+// hostname    = *( domainlabel "." ) toplabel [ "." ]
+// domainlabel = alphanum | alphanum *( alphanum | "-" ) alphanum
+// toplabel    = alpha | alpha *( alphanum | "-" ) alphanum
+// IPv4address = 1*digit "." 1*digit "." 1*digit "." 1*digit
+//
+
+const HOST_REGEX =
+  new RegExp("^(?:" +
+               // *( domainlabel "." )
+               "(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)*" +
+               // toplabel
+               "[a-z](?:[a-z0-9-]*[a-z0-9])?" +
+             "|" +
+               // IPv4 address 
+               "\\d+\\.\\d+\\.\\d+\\.\\d+" +
+             ")$",
+             "i");
+
+
+/**
+ * Represents the identity of a server.  An identity consists of a set of
+ * (scheme, host, port) tuples denoted as locations (allowing a single server to
+ * serve multiple sites or to be used behind both HTTP and HTTPS proxies for any
+ * host/port).  Any incoming request must be to one of these locations, or it
+ * will be rejected with an HTTP 400 error.  One location, denoted as the
+ * primary location, is the location assigned in contexts where a location
+ * cannot otherwise be endogenously derived, such as for HTTP/1.0 requests.
+ *
+ * A single identity may contain at most one location per unique host/port pair;
+ * other than that, no restrictions are placed upon what locations may
+ * constitute an identity.
+ */
+function ServerIdentity()
+{
+  /** The scheme of the primary location. */
+  this._primaryScheme = "http";
+
+  /** The hostname of the primary location. */
+  this._primaryHost = "127.0.0.1"
+
+  /** The port number of the primary location. */
+  this._primaryPort = -1;
+
+  /**
+   * The current port number for the corresponding server, stored so that a new
+   * primary location can always be set if the current one is removed.
+   */
+  this._defaultPort = -1;
+
+  /**
+   * Maps hosts to maps of ports to schemes, e.g. the following would represent
+   * https://example.com:789/ and http://example.org/:
+   *
+   *   {
+   *     "xexample.com": { 789: "https" },
+   *     "xexample.org": { 80: "http" }
+   *   }
+   *
+   * Note the "x" prefix on hostnames, which prevents collisions with special
+   * JS names like "prototype".
+   */
+  this._locations = { "xlocalhost": {} };
+}
+ServerIdentity.prototype =
+{
+  // NSIHTTPSERVERIDENTITY
+
+  //
+  // see nsIHttpServerIdentity.primaryScheme
+  //
+  get primaryScheme()
+  {
+    if (this._primaryPort === -1)
+      throw Cr.NS_ERROR_NOT_INITIALIZED;
+    return this._primaryScheme;
+  },
+
+  //
+  // see nsIHttpServerIdentity.primaryHost
+  //
+  get primaryHost()
+  {
+    if (this._primaryPort === -1)
+      throw Cr.NS_ERROR_NOT_INITIALIZED;
+    return this._primaryHost;
+  },
+
+  //
+  // see nsIHttpServerIdentity.primaryPort
+  //
+  get primaryPort()
+  {
+    if (this._primaryPort === -1)
+      throw Cr.NS_ERROR_NOT_INITIALIZED;
+    return this._primaryPort;
+  },
+
+  //
+  // see nsIHttpServerIdentity.add
+  //
+  add: function(scheme, host, port)
+  {
+    this._validate(scheme, host, port);
+
+    var entry = this._locations["x" + host];
+    if (!entry)
+      this._locations["x" + host] = entry = {};
+
+    entry[port] = scheme;
+  },
+
+  //
+  // see nsIHttpServerIdentity.remove
+  //
+  remove: function(scheme, host, port)
+  {
+    this._validate(scheme, host, port);
+
+    var entry = this._locations["x" + host];
+    if (!entry)
+      return false;
+
+    var present = port in entry;
+    delete entry[port];
+
+    if (this._primaryScheme == scheme &&
+        this._primaryHost == host &&
+        this._primaryPort == port &&
+        this._defaultPort !== -1)
+    {
+      // Always keep at least one identity in existence at any time, unless
+      // we're in the process of shutting down (the last condition above).
+      this._primaryPort = -1;
+      this._initialize(this._defaultPort, host, false);
+    }
+
+    return present;
+  },
+
+  //
+  // see nsIHttpServerIdentity.has
+  //
+  has: function(scheme, host, port)
+  {
+    this._validate(scheme, host, port);
+
+    return "x" + host in this._locations &&
+           scheme === this._locations["x" + host][port];
+  },
+
+  //
+  // see nsIHttpServerIdentity.has
+  //
+  getScheme: function(host, port)
+  {
+    this._validate("http", host, port);
+
+    var entry = this._locations["x" + host];
+    if (!entry)
+      return "";
+
+    return entry[port] || "";
+  },
+
+  //
+  // see nsIHttpServerIdentity.setPrimary
+  //
+  setPrimary: function(scheme, host, port)
+  {
+    this._validate(scheme, host, port);
+
+    this.add(scheme, host, port);
+
+    this._primaryScheme = scheme;
+    this._primaryHost = host;
+    this._primaryPort = port;
+  },
+
+
+  // NSISUPPORTS
+
+  //
+  // see nsISupports.QueryInterface
+  //
+  QueryInterface: function(iid)
+  {
+    if (iid.equals(Ci.nsIHttpServerIdentity) || iid.equals(Ci.nsISupports))
+      return this;
+
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  },
+
+
+  // PRIVATE IMPLEMENTATION
+
+  /**
+   * Initializes the primary name for the corresponding server, based on the
+   * provided port number.
+   */
+  _initialize: function(port, host, addSecondaryDefault)
+  {
+    this._host = host;
+    if (this._primaryPort !== -1)
+      this.add("http", host, port);
+    else
+      this.setPrimary("http", "localhost", port);
+    this._defaultPort = port;
+
+    // Only add this if we're being called at server startup
+    if (addSecondaryDefault && host != "127.0.0.1")
+      this.add("http", "127.0.0.1", port);
+  },
+
+  /**
+   * Called at server shutdown time, unsets the primary location only if it was
+   * the default-assigned location and removes the default location from the
+   * set of locations used.
+   */
+  _teardown: function()
+  {
+    if (this._host != "127.0.0.1") {
+      // Not the default primary location, nothing special to do here
+      this.remove("http", "127.0.0.1", this._defaultPort);
+    }
+    
+    // This is a *very* tricky bit of reasoning here; make absolutely sure the
+    // tests for this code pass before you commit changes to it.
+    if (this._primaryScheme == "http" &&
+        this._primaryHost == this._host &&
+        this._primaryPort == this._defaultPort)
+    {
+      // Make sure we don't trigger the readding logic in .remove(), then remove
+      // the default location.
+      var port = this._defaultPort;
+      this._defaultPort = -1;
+      this.remove("http", this._host, port);
+
+      // Ensure a server start triggers the setPrimary() path in ._initialize()
+      this._primaryPort = -1;
+    }
+    else
+    {
+      // No reason not to remove directly as it's not our primary location
+      this.remove("http", this._host, this._defaultPort);
+    }
+  },
+
+  /**
+   * Ensures scheme, host, and port are all valid with respect to RFC 2396.
+   *
+   * @throws NS_ERROR_ILLEGAL_VALUE
+   *   if any argument doesn't match the corresponding production
+   */
+  _validate: function(scheme, host, port)
+  {
+    if (scheme !== "http" && scheme !== "https")
+    {
+      dumpn("*** server only supports http/https schemes: '" + scheme + "'");
+      dumpStack();
+      throw Cr.NS_ERROR_ILLEGAL_VALUE;
+    }
+    if (!HOST_REGEX.test(host))
+    {
+      dumpn("*** unexpected host: '" + host + "'");
+      throw Cr.NS_ERROR_ILLEGAL_VALUE;
+    }
+    if (port < 0 || port > 65535)
+    {
+      dumpn("*** unexpected port: '" + port + "'");
+      throw Cr.NS_ERROR_ILLEGAL_VALUE;
+    }
+  }
+};
+
+
+/**
+ * Represents a connection to the server (and possibly in the future the thread
+ * on which the connection is processed).
+ *
+ * @param input : nsIInputStream
+ *   stream from which incoming data on the connection is read
+ * @param output : nsIOutputStream
+ *   stream to write data out the connection
+ * @param server : nsHttpServer
+ *   the server handling the connection
+ * @param port : int
+ *   the port on which the server is running
+ * @param outgoingPort : int
+ *   the outgoing port used by this connection
+ * @param number : uint
+ *   a serial number used to uniquely identify this connection
+ */
+function Connection(input, output, server, port, outgoingPort, number)
+{
+  dumpn("*** opening new connection " + number + " on port " + outgoingPort);
+
+  /** Stream of incoming data. */
+  this.input = input;
+
+  /** Stream for outgoing data. */
+  this.output = output;
+
+  /** The server associated with this request. */
+  this.server = server;
+
+  /** The port on which the server is running. */
+  this.port = port;
+
+  /** The outgoing poort used by this connection. */
+  this._outgoingPort = outgoingPort;
+
+  /** The serial number of this connection. */
+  this.number = number;
+
+  /**
+   * The request for which a response is being generated, null if the
+   * incoming request has not been fully received or if it had errors.
+   */
+  this.request = null;
+
+  /** This allows a connection to disambiguate between a peer initiating a
+   *  close and the socket being forced closed on shutdown.
+   */
+  this._closed = false;
+
+  /** State variable for debugging. */
+  this._processed = false;
+
+  /** whether or not 1st line of request has been received */
+  this._requestStarted = false; 
+}
+Connection.prototype =
+{
+  /** Closes this connection's input/output streams. */
+  close: function()
+  {
+    if (this._closed)
+        return;
+
+    dumpn("*** closing connection " + this.number +
+          " on port " + this._outgoingPort);
+
+    this.input.close();
+    this.output.close();
+    this._closed = true;
+
+    var server = this.server;
+    server._connectionClosed(this);
+
+    // If an error triggered a server shutdown, act on it now
+    if (server._doQuit)
+      server.stop(function() { /* not like we can do anything better */ });
+  },
+
+  /**
+   * Initiates processing of this connection, using the data in the given
+   * request.
+   *
+   * @param request : Request
+   *   the request which should be processed
+   */
+  process: function(request)
+  {
+    NS_ASSERT(!this._closed && !this._processed);
+
+    this._processed = true;
+
+    this.request = request;
+    this.server._handler.handleResponse(this);
+  },
+
+  /**
+   * Initiates processing of this connection, generating a response with the
+   * given HTTP error code.
+   *
+   * @param code : uint
+   *   an HTTP code, so in the range [0, 1000)
+   * @param request : Request
+   *   incomplete data about the incoming request (since there were errors
+   *   during its processing
+   */
+  processError: function(code, request)
+  {
+    NS_ASSERT(!this._closed && !this._processed);
+
+    this._processed = true;
+    this.request = request;
+    this.server._handler.handleError(code, this);
+  },
+
+  /** Converts this to a string for debugging purposes. */
+  toString: function()
+  {
+    return "<Connection(" + this.number +
+           (this.request ? ", " + this.request.path : "") +"): " +
+           (this._closed ? "closed" : "open") + ">";
+  },
+
+  requestStarted: function()
+  {
+    this._requestStarted = true;
+  }
+};
+
+
+
+/** Returns an array of count bytes from the given input stream. */
+function readBytes(inputStream, count)
+{
+  return new BinaryInputStream(inputStream).readByteArray(count);
+}
+
+
+
+/** Request reader processing states; see RequestReader for details. */
+const READER_IN_REQUEST_LINE = 0;
+const READER_IN_HEADERS      = 1;
+const READER_IN_BODY         = 2;
+const READER_FINISHED        = 3;
+
+
+/**
+ * Reads incoming request data asynchronously, does any necessary preprocessing,
+ * and forwards it to the request handler.  Processing occurs in three states:
+ *
+ *   READER_IN_REQUEST_LINE     Reading the request's status line
+ *   READER_IN_HEADERS          Reading headers in the request
+ *   READER_IN_BODY             Reading the body of the request
+ *   READER_FINISHED            Entire request has been read and processed
+ *
+ * During the first two stages, initial metadata about the request is gathered
+ * into a Request object.  Once the status line and headers have been processed,
+ * we start processing the body of the request into the Request.  Finally, when
+ * the entire body has been read, we create a Response and hand it off to the
+ * ServerHandler to be given to the appropriate request handler.
+ *
+ * @param connection : Connection
+ *   the connection for the request being read
+ */
+function RequestReader(connection)
+{
+  /** Connection metadata for this request. */
+  this._connection = connection;
+
+  /**
+   * A container providing line-by-line access to the raw bytes that make up the
+   * data which has been read from the connection but has not yet been acted
+   * upon (by passing it to the request handler or by extracting request
+   * metadata from it).
+   */
+  this._data = new LineData();
+
+  /**
+   * The amount of data remaining to be read from the body of this request.
+   * After all headers in the request have been read this is the value in the
+   * Content-Length header, but as the body is read its value decreases to zero.
+   */
+  this._contentLength = 0;
+
+  /** The current state of parsing the incoming request. */
+  this._state = READER_IN_REQUEST_LINE;
+
+  /** Metadata constructed from the incoming request for the request handler. */
+  this._metadata = new Request(connection.port);
+
+  /**
+   * Used to preserve state if we run out of line data midway through a
+   * multi-line header.  _lastHeaderName stores the name of the header, while
+   * _lastHeaderValue stores the value we've seen so far for the header.
+   *
+   * These fields are always either both undefined or both strings.
+   */
+  this._lastHeaderName = this._lastHeaderValue = undefined;
+}
+RequestReader.prototype =
+{
+  // NSIINPUTSTREAMCALLBACK
+
+  /**
+   * Called when more data from the incoming request is available.  This method
+   * then reads the available data from input and deals with that data as
+   * necessary, depending upon the syntax of already-downloaded data.
+   *
+   * @param input : nsIAsyncInputStream
+   *   the stream of incoming data from the connection
+   */
+  onInputStreamReady: function(input)
+  {
+    dumpn("*** onInputStreamReady(input=" + input + ") on thread " +
+          gThreadManager.currentThread + " (main is " +
+          gThreadManager.mainThread + ")");
+    dumpn("*** this._state == " + this._state);
+
+    // Handle cases where we get more data after a request error has been
+    // discovered but *before* we can close the connection.
+    var data = this._data;
+    if (!data)
+      return;
+
+    try
+    {
+      data.appendBytes(readBytes(input, input.available()));
+    }
+    catch (e)
+    {
+      if (streamClosed(e))
+      {
+        dumpn("*** WARNING: unexpected error when reading from socket; will " +
+              "be treated as if the input stream had been closed");
+        dumpn("*** WARNING: actual error was: " + e);
+      }
+
+      // We've lost a race -- input has been closed, but we're still expecting
+      // to read more data.  available() will throw in this case, and since
+      // we're dead in the water now, destroy the connection.
+      dumpn("*** onInputStreamReady called on a closed input, destroying " +
+            "connection");
+      this._connection.close();
+      return;
+    }
+
+    switch (this._state)
+    {
+      default:
+        NS_ASSERT(false, "invalid state: " + this._state);
+        break;
+
+      case READER_IN_REQUEST_LINE:
+        if (!this._processRequestLine())
+          break;
+        /* fall through */
+
+      case READER_IN_HEADERS:
+        if (!this._processHeaders())
+          break;
+        /* fall through */
+
+      case READER_IN_BODY:
+        this._processBody();
+    }
+
+    if (this._state != READER_FINISHED)
+      input.asyncWait(this, 0, 0, gThreadManager.currentThread);
+  },
+
+  //
+  // see nsISupports.QueryInterface
+  //
+  QueryInterface: function(aIID)
+  {
+    if (aIID.equals(Ci.nsIInputStreamCallback) ||
+        aIID.equals(Ci.nsISupports))
+      return this;
+
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  },
+
+
+  // PRIVATE API
+
+  /**
+   * Processes unprocessed, downloaded data as a request line.
+   *
+   * @returns boolean
+   *   true iff the request line has been fully processed
+   */
+  _processRequestLine: function()
+  {
+    NS_ASSERT(this._state == READER_IN_REQUEST_LINE);
+
+    // Servers SHOULD ignore any empty line(s) received where a Request-Line
+    // is expected (section 4.1).
+    var data = this._data;
+    var line = {};
+    var readSuccess;
+    while ((readSuccess = data.readLine(line)) && line.value == "")
+      dumpn("*** ignoring beginning blank line...");
+
+    // if we don't have a full line, wait until we do
+    if (!readSuccess)
+      return false;
+
+    // we have the first non-blank line
+    try
+    {
+      this._parseRequestLine(line.value);
+      this._state = READER_IN_HEADERS;
+      this._connection.requestStarted();
+      return true;
+    }
+    catch (e)
+    {
+      this._handleError(e);
+      return false;
+    }
+  },
+
+  /**
+   * Processes stored data, assuming it is either at the beginning or in
+   * the middle of processing request headers.
+   *
+   * @returns boolean
+   *   true iff header data in the request has been fully processed
+   */
+  _processHeaders: function()
+  {
+    NS_ASSERT(this._state == READER_IN_HEADERS);
+
+    // XXX things to fix here:
+    //
+    // - need to support RFC 2047-encoded non-US-ASCII characters
+
+    try
+    {
+      var done = this._parseHeaders();
+      if (done)
+      {
+        var request = this._metadata;
+
+        // XXX this is wrong for requests with transfer-encodings applied to
+        //     them, particularly chunked (which by its nature can have no
+        //     meaningful Content-Length header)!
+        this._contentLength = request.hasHeader("Content-Length")
+                            ? parseInt(request.getHeader("Content-Length"), 10)
+                            : 0;
+        dumpn("_processHeaders, Content-length=" + this._contentLength);
+
+        this._state = READER_IN_BODY;
+      }
+      return done;
+    }
+    catch (e)
+    {
+      this._handleError(e);
+      return false;
+    }
+  },
+
+  /**
+   * Processes stored data, assuming it is either at the beginning or in
+   * the middle of processing the request body.
+   *
+   * @returns boolean
+   *   true iff the request body has been fully processed
+   */
+  _processBody: function()
+  {
+    NS_ASSERT(this._state == READER_IN_BODY);
+
+    // XXX handle chunked transfer-coding request bodies!
+
+    try
+    {
+      if (this._contentLength > 0)
+      {
+        var data = this._data.purge();
+        var count = Math.min(data.length, this._contentLength);
+        dumpn("*** loading data=" + data + " len=" + data.length +
+              " excess=" + (data.length - count));
+
+        var bos = new BinaryOutputStream(this._metadata._bodyOutputStream);
+        bos.writeByteArray(data, count);
+        this._contentLength -= count;
+      }
+
+      dumpn("*** remaining body data len=" + this._contentLength);
+      if (this._contentLength == 0)
+      {
+        this._validateRequest();
+        this._state = READER_FINISHED;
+        this._handleResponse();
+        return true;
+      }
+      
+      return false;
+    }
+    catch (e)
+    {
+      this._handleError(e);
+      return false;
+    }
+  },
+
+  /**
+   * Does various post-header checks on the data in this request.
+   *
+   * @throws : HttpError
+   *   if the request was malformed in some way
+   */
+  _validateRequest: function()
+  {
+    NS_ASSERT(this._state == READER_IN_BODY);
+
+    dumpn("*** _validateRequest");
+
+    var metadata = this._metadata;
+    var headers = metadata._headers;
+
+    // 19.6.1.1 -- servers MUST report 400 to HTTP/1.1 requests w/o Host header
+    var identity = this._connection.server.identity;
+    if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1))
+    {
+      if (!headers.hasHeader("Host"))
+      {
+        dumpn("*** malformed HTTP/1.1 or greater request with no Host header!");
+        throw HTTP_400;
+      }
+
+      // If the Request-URI wasn't absolute, then we need to determine our host.
+      // We have to determine what scheme was used to access us based on the
+      // server identity data at this point, because the request just doesn't
+      // contain enough data on its own to do this, sadly.
+      if (!metadata._host)
+      {
+        var host, port;
+        var hostPort = headers.getHeader("Host");
+        var colon = hostPort.indexOf(":");
+        if (colon < 0)
+        {
+          host = hostPort;
+          port = "";
+        }
+        else
+        {
+          host = hostPort.substring(0, colon);
+          port = hostPort.substring(colon + 1);
+        }
+
+        // NB: We allow an empty port here because, oddly, a colon may be
+        //     present even without a port number, e.g. "example.com:"; in this
+        //     case the default port applies.
+        if (!HOST_REGEX.test(host) || !/^\d*$/.test(port))
+        {
+          dumpn("*** malformed hostname (" + hostPort + ") in Host " +
+                "header, 400 time");
+          throw HTTP_400;
+        }
+
+        // If we're not given a port, we're stuck, because we don't know what
+        // scheme to use to look up the correct port here, in general.  Since
+        // the HTTPS case requires a tunnel/proxy and thus requires that the
+        // requested URI be absolute (and thus contain the necessary
+        // information), let's assume HTTP will prevail and use that.
+        port = +port || 80;
+
+        var scheme = identity.getScheme(host, port);
+        if (!scheme)
+        {
+          dumpn("*** unrecognized hostname (" + hostPort + ") in Host " +
+                "header, 400 time");
+          throw HTTP_400;
+        }
+
+        metadata._scheme = scheme;
+        metadata._host = host;
+        metadata._port = port;
+      }
+    }
+    else
+    {
+      NS_ASSERT(metadata._host === undefined,
+                "HTTP/1.0 doesn't allow absolute paths in the request line!");
+
+      metadata._scheme = identity.primaryScheme;
+      metadata._host = identity.primaryHost;
+      metadata._port = identity.primaryPort;
+    }
+
+    NS_ASSERT(identity.has(metadata._scheme, metadata._host, metadata._port),
+              "must have a location we recognize by now!");
+  },
+
+  /**
+   * Handles responses in case of error, either in the server or in the request.
+   *
+   * @param e
+   *   the specific error encountered, which is an HttpError in the case where
+   *   the request is in some way invalid or cannot be fulfilled; if this isn't
+   *   an HttpError we're going to be paranoid and shut down, because that
+   *   shouldn't happen, ever
+   */
+  _handleError: function(e)
+  {
+    // Don't fall back into normal processing!
+    this._state = READER_FINISHED;
+
+    var server = this._connection.server;
+    if (e instanceof HttpError)
+    {
+      var code = e.code;
+    }
+    else
+    {
+      dumpn("!!! UNEXPECTED ERROR: " + e +
+            (e.lineNumber ? ", line " + e.lineNumber : ""));
+
+      // no idea what happened -- be paranoid and shut down
+      code = 500;
+      server._requestQuit();
+    }
+
+    // make attempted reuse of data an error
+    this._data = null;
+
+    this._connection.processError(code, this._metadata);
+  },
+
+  /**
+   * Now that we've read the request line and headers, we can actually hand off
+   * the request to be handled.
+   *
+   * This method is called once per request, after the request line and all
+   * headers and the body, if any, have been received.
+   */
+  _handleResponse: function()
+  {
+    NS_ASSERT(this._state == READER_FINISHED);
+
+    // We don't need the line-based data any more, so make attempted reuse an
+    // error.
+    this._data = null;
+
+    this._connection.process(this._metadata);
+  },
+
+
+  // PARSING
+
+  /**
+   * Parses the request line for the HTTP request associated with this.
+   *
+   * @param line : string
+   *   the request line
+   */
+  _parseRequestLine: function(line)
+  {
+    NS_ASSERT(this._state == READER_IN_REQUEST_LINE);
+
+    dumpn("*** _parseRequestLine('" + line + "')");
+
+    var metadata = this._metadata;
+
+    // clients and servers SHOULD accept any amount of SP or HT characters
+    // between fields, even though only a single SP is required (section 19.3)
+    var request = line.split(/[ \t]+/);
+    if (!request || request.length != 3)
+    {
+      dumpn("*** No request in line");
+      throw HTTP_400;
+    }
+
+    metadata._method = request[0];
+
+    // get the HTTP version
+    var ver = request[2];
+    var match = ver.match(/^HTTP\/(\d+\.\d+)$/);
+    if (!match)
+    {
+      dumpn("*** No HTTP version in line");
+      throw HTTP_400;
+    }
+
+    // determine HTTP version
+    try
+    {
+      metadata._httpVersion = new nsHttpVersion(match[1]);
+      if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_0))
+        throw "unsupported HTTP version";
+    }
+    catch (e)
+    {
+      // we support HTTP/1.0 and HTTP/1.1 only
+      throw HTTP_501;
+    }
+
+
+    var fullPath = request[1];
+    var serverIdentity = this._connection.server.identity;
+
+    var scheme, host, port;
+
+    if (fullPath.charAt(0) != "/")
+    {
+      // No absolute paths in the request line in HTTP prior to 1.1
+      if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1))
+      {
+        dumpn("*** Metadata version too low");
+        throw HTTP_400;
+      }
+
+      try
+      {
+        var uri = Cc["@mozilla.org/network/io-service;1"]
+                    .getService(Ci.nsIIOService)
+                    .newURI(fullPath, null, null);
+        fullPath = uri.path;
+        scheme = uri.scheme;
+        host = metadata._host = uri.asciiHost;
+        port = uri.port;
+        if (port === -1)
+        {
+          if (scheme === "http")
+          {
+            port = 80;
+          }
+          else if (scheme === "https")
+          {
+            port = 443;
+          }
+          else
+          {
+            dumpn("*** Unknown scheme: " + scheme);
+            throw HTTP_400;
+          }
+        }
+      }
+      catch (e)
+      {
+        // If the host is not a valid host on the server, the response MUST be a
+        // 400 (Bad Request) error message (section 5.2).  Alternately, the URI
+        // is malformed.
+        dumpn("*** Threw when dealing with URI: " + e);
+        throw HTTP_400;
+      }
+
+      if (!serverIdentity.has(scheme, host, port) || fullPath.charAt(0) != "/")
+      {
+        dumpn("*** serverIdentity unknown or path does not start with '/'");
+        throw HTTP_400;
+      }
+    }
+
+    var splitter = fullPath.indexOf("?");
+    if (splitter < 0)
+    {
+      // _queryString already set in ctor
+      metadata._path = fullPath;
+    }
+    else
+    {
+      metadata._path = fullPath.substring(0, splitter);
+      metadata._queryString = fullPath.substring(splitter + 1);
+    }
+
+    metadata._scheme = scheme;
+    metadata._host = host;
+    metadata._port = port;
+  },
+
+  /**
+   * Parses all available HTTP headers in this until the header-ending CRLFCRLF,
+   * adding them to the store of headers in the request.
+   *
+   * @throws
+   *   HTTP_400 if the headers are malformed
+   * @returns boolean
+   *   true if all headers have now been processed, false otherwise
+   */
+  _parseHeaders: function()
+  {
+    NS_ASSERT(this._state == READER_IN_HEADERS);
+
+    dumpn("*** _parseHeaders");
+
+    var data = this._data;
+
+    var headers = this._metadata._headers;
+    var lastName = this._lastHeaderName;
+    var lastVal = this._lastHeaderValue;
+
+    var line = {};
+    while (true)
+    {
+      dumpn("*** Last name: '" + lastName + "'");
+      dumpn("*** Last val: '" + lastVal + "'");
+      NS_ASSERT(!((lastVal === undefined) ^ (lastName === undefined)),
+                lastName === undefined ?
+                  "lastVal without lastName?  lastVal: '" + lastVal + "'" :
+                  "lastName without lastVal?  lastName: '" + lastName + "'");
+
+      if (!data.readLine(line))
+      {
+        // save any data we have from the header we might still be processing
+        this._lastHeaderName = lastName;
+        this._lastHeaderValue = lastVal;
+        return false;
+      }
+
+      var lineText = line.value;
+      dumpn("*** Line text: '" + lineText + "'");
+      var firstChar = lineText.charAt(0);
+
+      // blank line means end of headers
+      if (lineText == "")
+      {
+        // we're finished with the previous header
+        if (lastName)
+        {
+          try
+          {
+            headers.setHeader(lastName, lastVal, true);
+          }
+          catch (e)
+          {
+            dumpn("*** setHeader threw on last header, e == " + e);
+            throw HTTP_400;
+          }
+        }
+        else
+        {
+          // no headers in request -- valid for HTTP/1.0 requests
+        }
+
+        // either way, we're done processing headers
+        this._state = READER_IN_BODY;
+        return true;
+      }
+      else if (firstChar == " " || firstChar == "\t")
+      {
+        // multi-line header if we've already seen a header line
+        if (!lastName)
+        {
+          dumpn("We don't have a header to continue!");
+          throw HTTP_400;
+        }
+
+        // append this line's text to the value; starts with SP/HT, so no need
+        // for separating whitespace
+        lastVal += lineText;
+      }
+      else
+      {
+        // we have a new header, so set the old one (if one existed)
+        if (lastName)
+        {
+          try
+          {
+            headers.setHeader(lastName, lastVal, true);
+          }
+          catch (e)
+          {
+            dumpn("*** setHeader threw on a header, e == " + e);
+            throw HTTP_400;
+          }
+        }
+
+        var colon = lineText.indexOf(":"); // first colon must be splitter
+        if (colon < 1)
+        {
+          dumpn("*** No colon or missing header field-name");
+          throw HTTP_400;
+        }
+
+        // set header name, value (to be set in the next loop, usually)
+        lastName = lineText.substring(0, colon);
+        lastVal = lineText.substring(colon + 1);
+      } // empty, continuation, start of header
+    } // while (true)
+  }
+};
+
+
+/** The character codes for CR and LF. */
+const CR = 0x0D, LF = 0x0A;
+
+/**
+ * Calculates the number of characters before the first CRLF pair in array, or
+ * -1 if the array contains no CRLF pair.
+ *
+ * @param array : Array
+ *   an array of numbers in the range [0, 256), each representing a single
+ *   character; the first CRLF is the lowest index i where
+ *   |array[i] == "\r".charCodeAt(0)| and |array[i+1] == "\n".charCodeAt(0)|,
+ *   if such an |i| exists, and -1 otherwise
+ * @param start : uint
+ *   start index from which to begin searching in array
+ * @returns int
+ *   the index of the first CRLF if any were present, -1 otherwise
+ */
+function findCRLF(array, start)
+{
+  for (var i = array.indexOf(CR, start); i >= 0; i = array.indexOf(CR, i + 1))
+  {
+    if (array[i + 1] == LF)
+      return i;
+  }
+  return -1;
+}
+
+
+/**
+ * A container which provides line-by-line access to the arrays of bytes with
+ * which it is seeded.
+ */
+function LineData()
+{
+  /** An array of queued bytes from which to get line-based characters. */
+  this._data = [];
+
+  /** Start index from which to search for CRLF. */
+  this._start = 0;
+}
+LineData.prototype =
+{
+  /**
+   * Appends the bytes in the given array to the internal data cache maintained
+   * by this.
+   */
+  appendBytes: function(bytes)
+  {
+    var count = bytes.length;
+    var quantum = 262144; // just above half SpiderMonkey's argument-count limit
+    if (count < quantum)
+    {
+      Array.prototype.push.apply(this._data, bytes);
+      return;
+    }
+
+    // Large numbers of bytes may cause Array.prototype.push to be called with
+    // more arguments than the JavaScript engine supports.  In that case append
+    // bytes in fixed-size amounts until all bytes are appended.
+    for (var start = 0; start < count; start += quantum)
+    {
+      var slice = bytes.slice(start, Math.min(start + quantum, count));
+      Array.prototype.push.apply(this._data, slice);
+    }
+  },
+
+  /**
+   * Removes and returns a line of data, delimited by CRLF, from this.
+   *
+   * @param out
+   *   an object whose "value" property will be set to the first line of text
+   *   present in this, sans CRLF, if this contains a full CRLF-delimited line
+   *   of text; if this doesn't contain enough data, the value of the property
+   *   is undefined
+   * @returns boolean
+   *   true if a full line of data could be read from the data in this, false
+   *   otherwise
+   */
+  readLine: function(out)
+  {
+    var data = this._data;
+    var length = findCRLF(data, this._start);
+    if (length < 0)
+    {
+      this._start = data.length;
+
+      // But if our data ends in a CR, we have to back up one, because
+      // the first byte in the next packet might be an LF and if we
+      // start looking at data.length we won't find it.
+      if (data.length > 0 && data[data.length - 1] === CR)
+        --this._start;
+
+      return false;
+    }
+
+    // Reset for future lines.
+    this._start = 0;
+
+    //
+    // We have the index of the CR, so remove all the characters, including
+    // CRLF, from the array with splice, and convert the removed array
+    // (excluding the trailing CRLF characters) into the corresponding string.
+    //
+    var leading = data.splice(0, length + 2);
+    var quantum = 262144;
+    var line = "";
+    for (var start = 0; start < length; start += quantum)
+    {
+      var slice = leading.slice(start, Math.min(start + quantum, length));
+      line += String.fromCharCode.apply(null, slice);
+    }
+
+    out.value = line;
+    return true;
+  },
+
+  /**
+   * Removes the bytes currently within this and returns them in an array.
+   *
+   * @returns Array
+   *   the bytes within this when this method is called
+   */
+  purge: function()
+  {
+    var data = this._data;
+    this._data = [];
+    return data;
+  }
+};
+
+
+
+/**
+ * Creates a request-handling function for an nsIHttpRequestHandler object.
+ */
+function createHandlerFunc(handler)
+{
+  return function(metadata, response) { handler.handle(metadata, response); };
+}
+
+
+/**
+ * The default handler for directories; writes an HTML response containing a
+ * slightly-formatted directory listing.
+ */
+function defaultIndexHandler(metadata, response)
+{
+  response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+  var path = htmlEscape(decodeURI(metadata.path));
+
+  //
+  // Just do a very basic bit of directory listings -- no need for too much
+  // fanciness, especially since we don't have a style sheet in which we can
+  // stick rules (don't want to pollute the default path-space).
+  //
+
+  var body = '<html>\
+                <head>\
+                  <title>' + path + '</title>\
+                </head>\
+                <body>\
+                  <h1>' + path + '</h1>\
+                  <ol style="list-style-type: none">';
+
+  var directory = metadata.getProperty("directory");
+  NS_ASSERT(directory && directory.isDirectory());
+
+  var fileList = [];
+  var files = directory.directoryEntries;
+  while (files.hasMoreElements())
+  {
+    var f = files.getNext().QueryInterface(Ci.nsIFile);
+    var name = f.leafName;
+    if (!f.isHidden() &&
+        (name.charAt(name.length - 1) != HIDDEN_CHAR ||
+         name.charAt(name.length - 2) == HIDDEN_CHAR))
+      fileList.push(f);
+  }
+
+  fileList.sort(fileSort);
+
+  for (var i = 0; i < fileList.length; i++)
+  {
+    var file = fileList[i];
+    try
+    {
+      var name = file.leafName;
+      if (name.charAt(name.length - 1) == HIDDEN_CHAR)
+        name = name.substring(0, name.length - 1);
+      var sep = file.isDirectory() ? "/" : "";
+
+      // Note: using " to delimit the attribute here because encodeURIComponent
+      //       passes through '.
+      var item = '<li><a href="' + encodeURIComponent(name) + sep + '">' +
+                   htmlEscape(name) + sep +
+                 '</a></li>';
+
+      body += item;
+    }
+    catch (e) { /* some file system error, ignore the file */ }
+  }
+
+  body    += '    </ol>\
+                </body>\
+              </html>';
+
+  response.bodyOutputStream.write(body, body.length);
+}
+
+/**
+ * Sorts a and b (nsIFile objects) into an aesthetically pleasing order.
+ */
+function fileSort(a, b)
+{
+  var dira = a.isDirectory(), dirb = b.isDirectory();
+
+  if (dira && !dirb)
+    return -1;
+  if (dirb && !dira)
+    return 1;
+
+  var namea = a.leafName.toLowerCase(), nameb = b.leafName.toLowerCase();
+  return nameb > namea ? -1 : 1;
+}
+
+
+/**
+ * Converts an externally-provided path into an internal path for use in
+ * determining file mappings.
+ *
+ * @param path
+ *   the path to convert
+ * @param encoded
+ *   true if the given path should be passed through decodeURI prior to
+ *   conversion
+ * @throws URIError
+ *   if path is incorrectly encoded
+ */
+function toInternalPath(path, encoded)
+{
+  if (encoded)
+    path = decodeURI(path);
+
+  var comps = path.split("/");
+  for (var i = 0, sz = comps.length; i < sz; i++)
+  {
+    var comp = comps[i];
+    if (comp.charAt(comp.length - 1) == HIDDEN_CHAR)
+      comps[i] = comp + HIDDEN_CHAR;
+  }
+  return comps.join("/");
+}
+
+const PERMS_READONLY = (4 << 6) | (4 << 3) | 4;
+
+/**
+ * Adds custom-specified headers for the given file to the given response, if
+ * any such headers are specified.
+ *
+ * @param file
+ *   the file on the disk which is to be written
+ * @param metadata
+ *   metadata about the incoming request
+ * @param response
+ *   the Response to which any specified headers/data should be written
+ * @throws HTTP_500
+ *   if an error occurred while processing custom-specified headers
+ */
+function maybeAddHeaders(file, metadata, response)
+{
+  var name = file.leafName;
+  if (name.charAt(name.length - 1) == HIDDEN_CHAR)
+    name = name.substring(0, name.length - 1);
+
+  var headerFile = file.parent;
+  headerFile.append(name + HEADERS_SUFFIX);
+
+  if (!headerFile.exists())
+    return;
+
+  const PR_RDONLY = 0x01;
+  var fis = new FileInputStream(headerFile, PR_RDONLY, PERMS_READONLY,
+                                Ci.nsIFileInputStream.CLOSE_ON_EOF);
+
+  try
+  {
+    var lis = new ConverterInputStream(fis, "UTF-8", 1024, 0x0);
+    lis.QueryInterface(Ci.nsIUnicharLineInputStream);
+
+    var line = {value: ""};
+    var more = lis.readLine(line);
+
+    if (!more && line.value == "")
+      return;
+
+
+    // request line
+
+    var status = line.value;
+    if (status.indexOf("HTTP ") == 0)
+    {
+      status = status.substring(5);
+      var space = status.indexOf(" ");
+      var code, description;
+      if (space < 0)
+      {
+        code = status;
+        description = "";
+      }
+      else
+      {
+        code = status.substring(0, space);
+        description = status.substring(space + 1, status.length);
+      }
+    
+      response.setStatusLine(metadata.httpVersion, parseInt(code, 10), description);
+
+      line.value = "";
+      more = lis.readLine(line);
+    }
+
+    // headers
+    while (more || line.value != "")
+    {
+      var header = line.value;
+      var colon = header.indexOf(":");
+
+      response.setHeader(header.substring(0, colon),
+                         header.substring(colon + 1, header.length),
+                         false); // allow overriding server-set headers
+
+      line.value = "";
+      more = lis.readLine(line);
+    }
+  }
+  catch (e)
+  {
+    dumpn("WARNING: error in headers for " + metadata.path + ": " + e);
+    throw HTTP_500;
+  }
+  finally
+  {
+    fis.close();
+  }
+}
+
+
+/**
+ * An object which handles requests for a server, executing default and
+ * overridden behaviors as instructed by the code which uses and manipulates it.
+ * Default behavior includes the paths / and /trace (diagnostics), with some
+ * support for HTTP error pages for various codes and fallback to HTTP 500 if
+ * those codes fail for any reason.
+ *
+ * @param server : nsHttpServer
+ *   the server in which this handler is being used
+ */
+function ServerHandler(server)
+{
+  // FIELDS
+
+  /**
+   * The nsHttpServer instance associated with this handler.
+   */
+  this._server = server;
+
+  /**
+   * A FileMap object containing the set of path->nsILocalFile mappings for
+   * all directory mappings set in the server (e.g., "/" for /var/www/html/,
+   * "/foo/bar/" for /local/path/, and "/foo/bar/baz/" for /local/path2).
+   *
+   * Note carefully: the leading and trailing "/" in each path (not file) are
+   * removed before insertion to simplify the code which uses this.  You have
+   * been warned!
+   */
+  this._pathDirectoryMap = new FileMap();
+
+  /**
+   * Custom request handlers for the server in which this resides.  Path-handler
+   * pairs are stored as property-value pairs in this property.
+   *
+   * @see ServerHandler.prototype._defaultPaths
+   */
+  this._overridePaths = {};
+
+  /**
+   * Custom request handlers for the path prefixes on the server in which this
+   * resides.  Path-handler pairs are stored as property-value pairs in this
+   * property.
+   *
+   * @see ServerHandler.prototype._defaultPaths
+   */
+  this._overridePrefixes = {};
+
+  /**
+   * Custom request handlers for the error handlers in the server in which this
+   * resides.  Path-handler pairs are stored as property-value pairs in this
+   * property.
+   *
+   * @see ServerHandler.prototype._defaultErrors
+   */
+  this._overrideErrors = {};
+
+  /**
+   * Maps file extensions to their MIME types in the server, overriding any
+   * mapping that might or might not exist in the MIME service.
+   */
+  this._mimeMappings = {};
+
+  /**
+   * The default handler for requests for directories, used to serve directories
+   * when no index file is present.
+   */
+  this._indexHandler = defaultIndexHandler;
+
+  /** Per-path state storage for the server. */
+  this._state = {};
+
+  /** Entire-server state storage. */
+  this._sharedState = {};
+
+  /** Entire-server state storage for nsISupports values. */
+  this._objectState = {};
+}
+ServerHandler.prototype =
+{
+  // PUBLIC API
+
+  /**
+   * Handles a request to this server, responding to the request appropriately
+   * and initiating server shutdown if necessary.
+   *
+   * This method never throws an exception.
+   *
+   * @param connection : Connection
+   *   the connection for this request
+   */
+  handleResponse: function(connection)
+  {
+    var request = connection.request;
+    var response = new Response(connection);
+
+    var path = request.path;
+    dumpn("*** path == " + path);
+
+    try
+    {
+      try
+      {
+        if (path in this._overridePaths)
+        {
+          // explicit paths first, then files based on existing directory mappings,
+          // then (if the file doesn't exist) built-in server default paths
+          dumpn("calling override for " + path);
+          this._overridePaths[path](request, response);
+        }
+        else
+        {
+          var longestPrefix = "";
+          for (let prefix in this._overridePrefixes) {
+            if (prefix.length > longestPrefix.length &&
+                path.substr(0, prefix.length) == prefix)
+            {
+              longestPrefix = prefix;
+            }
+          }
+          if (longestPrefix.length > 0)
+          {
+            dumpn("calling prefix override for " + longestPrefix);
+            this._overridePrefixes[longestPrefix](request, response);
+          }
+          else
+          {
+            this._handleDefault(request, response);
+          }
+        }
+      }
+      catch (e)
+      {
+        if (response.partiallySent())
+        {
+          response.abort(e);
+          return;
+        }
+
+        if (!(e instanceof HttpError))
+        {
+          dumpn("*** unexpected error: e == " + e);
+          throw HTTP_500;
+        }
+        if (e.code !== 404)
+          throw e;
+
+        dumpn("*** default: " + (path in this._defaultPaths));
+
+        response = new Response(connection);
+        if (path in this._defaultPaths)
+          this._defaultPaths[path](request, response);
+        else
+          throw HTTP_404;
+      }
+    }
+    catch (e)
+    {
+      if (response.partiallySent())
+      {
+        response.abort(e);
+        return;
+      }
+
+      var errorCode = "internal";
+
+      try
+      {
+        if (!(e instanceof HttpError))
+          throw e;
+
+        errorCode = e.code;
+        dumpn("*** errorCode == " + errorCode);
+
+        response = new Response(connection);
+        if (e.customErrorHandling)
+          e.customErrorHandling(response);
+        this._handleError(errorCode, request, response);
+        return;
+      }
+      catch (e2)
+      {
+        dumpn("*** error handling " + errorCode + " error: " +
+              "e2 == " + e2 + ", shutting down server");
+
+        connection.server._requestQuit();
+        response.abort(e2);
+        return;
+      }
+    }
+
+    response.complete();
+  },
+
+  //
+  // see nsIHttpServer.registerFile
+  //
+  registerFile: function(path, file)
+  {
+    if (!file)
+    {
+      dumpn("*** unregistering '" + path + "' mapping");
+      delete this._overridePaths[path];
+      return;
+    }
+
+    dumpn("*** registering '" + path + "' as mapping to " + file.path);
+    file = file.clone();
+
+    var self = this;
+    this._overridePaths[path] =
+      function(request, response)
+      {
+        if (!file.exists())
+          throw HTTP_404;
+
+        response.setStatusLine(request.httpVersion, 200, "OK");
+        self._writeFileResponse(request, file, response, 0, file.fileSize);
+      };
+  },
+
+  //
+  // see nsIHttpServer.registerPathHandler
+  //
+  registerPathHandler: function(path, handler)
+  {
+    // XXX true path validation!
+    if (path.charAt(0) != "/")
+      throw Cr.NS_ERROR_INVALID_ARG;
+
+    this._handlerToField(handler, this._overridePaths, path);
+  },
+
+  //
+  // see nsIHttpServer.registerPrefixHandler
+  //
+  registerPrefixHandler: function(path, handler)
+  {
+    // XXX true path validation!
+    if (path.charAt(0) != "/" || path.charAt(path.length - 1) != "/")
+      throw Cr.NS_ERROR_INVALID_ARG;
+
+    this._handlerToField(handler, this._overridePrefixes, path);
+  },
+
+  //
+  // see nsIHttpServer.registerDirectory
+  //
+  registerDirectory: function(path, directory)
+  {
+    // strip off leading and trailing '/' so that we can use lastIndexOf when
+    // determining exactly how a path maps onto a mapped directory --
+    // conditional is required here to deal with "/".substring(1, 0) being
+    // converted to "/".substring(0, 1) per the JS specification
+    var key = path.length == 1 ? "" : path.substring(1, path.length - 1);
+
+    // the path-to-directory mapping code requires that the first character not
+    // be "/", or it will go into an infinite loop
+    if (key.charAt(0) == "/")
+      throw Cr.NS_ERROR_INVALID_ARG;
+
+    key = toInternalPath(key, false);
+
+    if (directory)
+    {
+      dumpn("*** mapping '" + path + "' to the location " + directory.path);
+      this._pathDirectoryMap.put(key, directory);
+    }
+    else
+    {
+      dumpn("*** removing mapping for '" + path + "'");
+      this._pathDirectoryMap.put(key, null);
+    }
+  },
+
+  //
+  // see nsIHttpServer.registerErrorHandler
+  //
+  registerErrorHandler: function(err, handler)
+  {
+    if (!(err in HTTP_ERROR_CODES))
+      dumpn("*** WARNING: registering non-HTTP/1.1 error code " +
+            "(" + err + ") handler -- was this intentional?");
+
+    this._handlerToField(handler, this._overrideErrors, err);
+  },
+
+  //
+  // see nsIHttpServer.setIndexHandler
+  //
+  setIndexHandler: function(handler)
+  {
+    if (!handler)
+      handler = defaultIndexHandler;
+    else if (typeof(handler) != "function")
+      handler = createHandlerFunc(handler);
+
+    this._indexHandler = handler;
+  },
+
+  //
+  // see nsIHttpServer.registerContentType
+  //
+  registerContentType: function(ext, type)
+  {
+    if (!type)
+      delete this._mimeMappings[ext];
+    else
+      this._mimeMappings[ext] = headerUtils.normalizeFieldValue(type);
+  },
+
+  // PRIVATE API
+
+  /**
+   * Sets or remove (if handler is null) a handler in an object with a key.
+   *
+   * @param handler
+   *   a handler, either function or an nsIHttpRequestHandler
+   * @param dict
+   *   The object to attach the handler to.
+   * @param key
+   *   The field name of the handler.
+   */
+  _handlerToField: function(handler, dict, key)
+  {
+    // for convenience, handler can be a function if this is run from xpcshell
+    if (typeof(handler) == "function")
+      dict[key] = handler;
+    else if (handler)
+      dict[key] = createHandlerFunc(handler);
+    else
+      delete dict[key];
+  },
+
+  /**
+   * Handles a request which maps to a file in the local filesystem (if a base
+   * path has already been set; otherwise the 404 error is thrown).
+   *
+   * @param metadata : Request
+   *   metadata for the incoming request
+   * @param response : Response
+   *   an uninitialized Response to the given request, to be initialized by a
+   *   request handler
+   * @throws HTTP_###
+   *   if an HTTP error occurred (usually HTTP_404); note that in this case the
+   *   calling code must handle post-processing of the response
+   */
+  _handleDefault: function(metadata, response)
+  {
+    dumpn("*** _handleDefault()");
+
+    response.setStatusLine(metadata.httpVersion, 200, "OK");
+
+    var path = metadata.path;
+    NS_ASSERT(path.charAt(0) == "/", "invalid path: <" + path + ">");
+
+    // determine the actual on-disk file; this requires finding the deepest
+    // path-to-directory mapping in the requested URL
+    var file = this._getFileForPath(path);
+
+    // the "file" might be a directory, in which case we either serve the
+    // contained index.html or make the index handler write the response
+    if (file.exists() && file.isDirectory())
+    {
+      file.append("index.html"); // make configurable?
+      if (!file.exists() || file.isDirectory())
+      {
+        metadata._ensurePropertyBag();
+        metadata._bag.setPropertyAsInterface("directory", file.parent);
+        this._indexHandler(metadata, response);
+        return;
+      }
+    }
+
+    // alternately, the file might not exist
+    if (!file.exists())
+      throw HTTP_404;
+
+    var start, end;
+    if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1) &&
+        metadata.hasHeader("Range") &&
+        this._getTypeFromFile(file) !== SJS_TYPE)
+    {
+      var rangeMatch = metadata.getHeader("Range").match(/^bytes=(\d+)?-(\d+)?$/);
+      if (!rangeMatch)
+      {
+        dumpn("*** Range header bogosity: '" + metadata.getHeader("Range") + "'");
+        throw HTTP_400;
+      }
+
+      if (rangeMatch[1] !== undefined)
+        start = parseInt(rangeMatch[1], 10);
+
+      if (rangeMatch[2] !== undefined)
+        end = parseInt(rangeMatch[2], 10);
+
+      if (start === undefined && end === undefined)
+      {
+        dumpn("*** More Range header bogosity: '" + metadata.getHeader("Range") + "'");
+        throw HTTP_400;
+      }
+
+      // No start given, so the end is really the count of bytes from the
+      // end of the file.
+      if (start === undefined)
+      {
+        start = Math.max(0, file.fileSize - end);
+        end   = file.fileSize - 1;
+      }
+
+      // start and end are inclusive
+      if (end === undefined || end >= file.fileSize)
+        end = file.fileSize - 1;
+
+      if (start !== undefined && start >= file.fileSize) {
+        var HTTP_416 = new HttpError(416, "Requested Range Not Satisfiable");
+        HTTP_416.customErrorHandling = function(errorResponse)
+        {
+          maybeAddHeaders(file, metadata, errorResponse);
+        };
+        throw HTTP_416;
+      }
+
+      if (end < start)
+      {
+        response.setStatusLine(metadata.httpVersion, 200, "OK");
+        start = 0;
+        end = file.fileSize - 1;
+      }
+      else
+      {
+        response.setStatusLine(metadata.httpVersion, 206, "Partial Content");
+        var contentRange = "bytes " + start + "-" + end + "/" + file.fileSize;
+        response.setHeader("Content-Range", contentRange);
+      }
+    }
+    else
+    {
+      start = 0;
+      end = file.fileSize - 1;
+    }
+
+    // finally...
+    dumpn("*** handling '" + path + "' as mapping to " + file.path + " from " +
+          start + " to " + end + " inclusive");
+    this._writeFileResponse(metadata, file, response, start, end - start + 1);
+  },
+
+  /**
+   * Writes an HTTP response for the given file, including setting headers for
+   * file metadata.
+   *
+   * @param metadata : Request
+   *   the Request for which a response is being generated
+   * @param file : nsILocalFile
+   *   the file which is to be sent in the response
+   * @param response : Response
+   *   the response to which the file should be written
+   * @param offset: uint
+   *   the byte offset to skip to when writing
+   * @param count: uint
+   *   the number of bytes to write
+   */
+  _writeFileResponse: function(metadata, file, response, offset, count)
+  {
+    const PR_RDONLY = 0x01;
+
+    var type = this._getTypeFromFile(file);
+    if (type === SJS_TYPE)
+    {
+      var fis = new FileInputStream(file, PR_RDONLY, PERMS_READONLY,
+                                    Ci.nsIFileInputStream.CLOSE_ON_EOF);
+
+      try
+      {
+        var sis = new ScriptableInputStream(fis);
+        var s = Cu.Sandbox(gGlobalObject);
+        s.importFunction(dump, "dump");
+        s.importFunction(atob, "atob");
+        s.importFunction(btoa, "btoa");
+
+        // Define a basic key-value state-preservation API across requests, with
+        // keys initially corresponding to the empty string.
+        var self = this;
+        var path = metadata.path;
+        s.importFunction(function getState(k)
+        {
+          return self._getState(path, k);
+        });
+        s.importFunction(function setState(k, v)
+        {
+          self._setState(path, k, v);
+        });
+        s.importFunction(function getSharedState(k)
+        {
+          return self._getSharedState(k);
+        });
+        s.importFunction(function setSharedState(k, v)
+        {
+          self._setSharedState(k, v);
+        });
+        s.importFunction(function getObjectState(k, callback)
+        {
+          callback(self._getObjectState(k));
+        });
+        s.importFunction(function setObjectState(k, v)
+        {
+          self._setObjectState(k, v);
+        });
+        s.importFunction(function registerPathHandler(p, h)
+        {
+          self.registerPathHandler(p, h);
+        });
+
+        // Make it possible for sjs files to access their location
+        this._setState(path, "__LOCATION__", file.path);
+
+        try
+        {
+          // Alas, the line number in errors dumped to console when calling the
+          // request handler is simply an offset from where we load the SJS file.
+          // Work around this in a reasonably non-fragile way by dynamically
+          // getting the line number where we evaluate the SJS file.  Don't
+          // separate these two lines!
+          var line = new Error().lineNumber;
+          Cu.evalInSandbox(sis.read(file.fileSize), s, "latest");
+        }
+        catch (e)
+        {
+          dumpn("*** syntax error in SJS at " + file.path + ": " + e);
+          throw HTTP_500;
+        }
+
+        try
+        {
+          s.handleRequest(metadata, response);
+        }
+        catch (e)
+        {
+          dump("*** error running SJS at " + file.path + ": " +
+               e + " on line " +
+               (e instanceof Error
+               ? e.lineNumber + " in httpd.js"
+               : (e.lineNumber - line)) + "\n");
+          throw HTTP_500;
+        }
+      }
+      finally
+      {
+        fis.close();
+      }
+    }
+    else
+    {
+      try
+      {
+        response.setHeader("Last-Modified",
+                           toDateString(file.lastModifiedTime),
+                           false);
+      }
+      catch (e) { /* lastModifiedTime threw, ignore */ }
+
+      response.setHeader("Content-Type", type, false);
+      maybeAddHeaders(file, metadata, response);
+      response.setHeader("Content-Length", "" + count, false);
+
+      var fis = new FileInputStream(file, PR_RDONLY, PERMS_READONLY,
+                                    Ci.nsIFileInputStream.CLOSE_ON_EOF);
+
+      offset = offset || 0;
+      count  = count || file.fileSize;
+      NS_ASSERT(offset === 0 || offset < file.fileSize, "bad offset");
+      NS_ASSERT(count >= 0, "bad count");
+      NS_ASSERT(offset + count <= file.fileSize, "bad total data size");
+
+      try
+      {
+        if (offset !== 0)
+        {
+          // Seek (or read, if seeking isn't supported) to the correct offset so
+          // the data sent to the client matches the requested range.
+          if (fis instanceof Ci.nsISeekableStream)
+            fis.seek(Ci.nsISeekableStream.NS_SEEK_SET, offset);
+          else
+            new ScriptableInputStream(fis).read(offset);
+        }
+      }
+      catch (e)
+      {
+        fis.close();
+        throw e;
+      }
+
+      let writeMore = function () {
+        gThreadManager.currentThread
+                      .dispatch(writeData, Ci.nsIThread.DISPATCH_NORMAL);
+      }
+
+      var input = new BinaryInputStream(fis);
+      var output = new BinaryOutputStream(response.bodyOutputStream);
+      var writeData =
+        {
+          run: function()
+          {
+            var chunkSize = Math.min(65536, count);
+            count -= chunkSize;
+            NS_ASSERT(count >= 0, "underflow");
+
+            try
+            {
+              var data = input.readByteArray(chunkSize);
+              NS_ASSERT(data.length === chunkSize,
+                        "incorrect data returned?  got " + data.length +
+                        ", expected " + chunkSize);
+              output.writeByteArray(data, data.length);
+              if (count === 0)
+              {
+                fis.close();
+                response.finish();
+              }
+              else
+              {
+                writeMore();
+              }
+            }
+            catch (e)
+            {
+              try
+              {
+                fis.close();
+              }
+              finally
+              {
+                response.finish();
+              }
+              throw e;
+            }
+          }
+        };
+
+      writeMore();
+
+      // Now that we know copying will start, flag the response as async.
+      response.processAsync();
+    }
+  },
+
+  /**
+   * Get the value corresponding to a given key for the given path for SJS state
+   * preservation across requests.
+   *
+   * @param path : string
+   *   the path from which the given state is to be retrieved
+   * @param k : string
+   *   the key whose corresponding value is to be returned
+   * @returns string
+   *   the corresponding value, which is initially the empty string
+   */
+  _getState: function(path, k)
+  {
+    var state = this._state;
+    if (path in state && k in state[path])
+      return state[path][k];
+    return "";
+  },
+
+  /**
+   * Set the value corresponding to a given key for the given path for SJS state
+   * preservation across requests.
+   *
+   * @param path : string
+   *   the path from which the given state is to be retrieved
+   * @param k : string
+   *   the key whose corresponding value is to be set
+   * @param v : string
+   *   the value to be set
+   */
+  _setState: function(path, k, v)
+  {
+    if (typeof v !== "string")
+      throw new Error("non-string value passed");
+    var state = this._state;
+    if (!(path in state))
+      state[path] = {};
+    state[path][k] = v;
+  },
+
+  /**
+   * Get the value corresponding to a given key for SJS state preservation
+   * across requests.
+   *
+   * @param k : string
+   *   the key whose corresponding value is to be returned
+   * @returns string
+   *   the corresponding value, which is initially the empty string
+   */
+  _getSharedState: function(k)
+  {
+    var state = this._sharedState;
+    if (k in state)
+      return state[k];
+    return "";
+  },
+
+  /**
+   * Set the value corresponding to a given key for SJS state preservation
+   * across requests.
+   *
+   * @param k : string
+   *   the key whose corresponding value is to be set
+   * @param v : string
+   *   the value to be set
+   */
+  _setSharedState: function(k, v)
+  {
+    if (typeof v !== "string")
+      throw new Error("non-string value passed");
+    this._sharedState[k] = v;
+  },
+
+  /**
+   * Returns the object associated with the given key in the server for SJS
+   * state preservation across requests.
+   *
+   * @param k : string
+   *  the key whose corresponding object is to be returned
+   * @returns nsISupports
+   *  the corresponding object, or null if none was present
+   */
+  _getObjectState: function(k)
+  {
+    if (typeof k !== "string")
+      throw new Error("non-string key passed");
+    return this._objectState[k] || null;
+  },
+
+  /**
+   * Sets the object associated with the given key in the server for SJS
+   * state preservation across requests.
+   *
+   * @param k : string
+   *  the key whose corresponding object is to be set
+   * @param v : nsISupports
+   *  the object to be associated with the given key; may be null
+   */
+  _setObjectState: function(k, v)
+  {
+    if (typeof k !== "string")
+      throw new Error("non-string key passed");
+    if (typeof v !== "object")
+      throw new Error("non-object value passed");
+    if (v && !("QueryInterface" in v))
+    {
+      throw new Error("must pass an nsISupports; use wrappedJSObject to ease " +
+                      "pain when using the server from JS");
+    }
+
+    this._objectState[k] = v;
+  },
+
+  /**
+   * Gets a content-type for the given file, first by checking for any custom
+   * MIME-types registered with this handler for the file's extension, second by
+   * asking the global MIME service for a content-type, and finally by failing
+   * over to application/octet-stream.
+   *
+   * @param file : nsIFile
+   *   the nsIFile for which to get a file type
+   * @returns string
+   *   the best content-type which can be determined for the file
+   */
+  _getTypeFromFile: function(file)
+  {
+    try
+    {
+      var name = file.leafName;
+      var dot = name.lastIndexOf(".");
+      if (dot > 0)
+      {
+        var ext = name.slice(dot + 1);
+        if (ext in this._mimeMappings)
+          return this._mimeMappings[ext];
+      }
+      return Cc["@mozilla.org/uriloader/external-helper-app-service;1"]
+               .getService(Ci.nsIMIMEService)
+               .getTypeFromFile(file);
+    }
+    catch (e)
+    {
+      return "application/octet-stream";
+    }
+  },
+
+  /**
+   * Returns the nsILocalFile which corresponds to the path, as determined using
+   * all registered path->directory mappings and any paths which are explicitly
+   * overridden.
+   *
+   * @param path : string
+   *   the server path for which a file should be retrieved, e.g. "/foo/bar"
+   * @throws HttpError
+   *   when the correct action is the corresponding HTTP error (i.e., because no
+   *   mapping was found for a directory in path, the referenced file doesn't
+   *   exist, etc.)
+   * @returns nsILocalFile
+   *   the file to be sent as the response to a request for the path
+   */
+  _getFileForPath: function(path)
+  {
+    // decode and add underscores as necessary
+    try
+    {
+      path = toInternalPath(path, true);
+    }
+    catch (e)
+    {
+      dumpn("*** toInternalPath threw " + e);
+      throw HTTP_400; // malformed path
+    }
+
+    // next, get the directory which contains this path
+    var pathMap = this._pathDirectoryMap;
+
+    // An example progression of tmp for a path "/foo/bar/baz/" might be:
+    // "foo/bar/baz/", "foo/bar/baz", "foo/bar", "foo", ""
+    var tmp = path.substring(1);
+    while (true)
+    {
+      // do we have a match for current head of the path?
+      var file = pathMap.get(tmp);
+      if (file)
+      {
+        // XXX hack; basically disable showing mapping for /foo/bar/ when the
+        //     requested path was /foo/bar, because relative links on the page
+        //     will all be incorrect -- we really need the ability to easily
+        //     redirect here instead
+        if (tmp == path.substring(1) &&
+            tmp.length != 0 &&
+            tmp.charAt(tmp.length - 1) != "/")
+          file = null;
+        else
+          break;
+      }
+
+      // if we've finished trying all prefixes, exit
+      if (tmp == "")
+        break;
+
+      tmp = tmp.substring(0, tmp.lastIndexOf("/"));
+    }
+
+    // no mapping applies, so 404
+    if (!file)
+      throw HTTP_404;
+
+
+    // last, get the file for the path within the determined directory
+    var parentFolder = file.parent;
+    var dirIsRoot = (parentFolder == null);
+
+    // Strategy here is to append components individually, making sure we
+    // never move above the given directory; this allows paths such as
+    // "<file>/foo/../bar" but prevents paths such as "<file>/../base-sibling";
+    // this component-wise approach also means the code works even on platforms
+    // which don't use "/" as the directory separator, such as Windows
+    var leafPath = path.substring(tmp.length + 1);
+    var comps = leafPath.split("/");
+    for (var i = 0, sz = comps.length; i < sz; i++)
+    {
+      var comp = comps[i];
+
+      if (comp == "..")
+        file = file.parent;
+      else if (comp == "." || comp == "")
+        continue;
+      else
+        file.append(comp);
+
+      if (!dirIsRoot && file.equals(parentFolder))
+        throw HTTP_403;
+    }
+
+    return file;
+  },
+
+  /**
+   * Writes the error page for the given HTTP error code over the given
+   * connection.
+   *
+   * @param errorCode : uint
+   *   the HTTP error code to be used
+   * @param connection : Connection
+   *   the connection on which the error occurred
+   */
+  handleError: function(errorCode, connection)
+  {
+    var response = new Response(connection);
+
+    dumpn("*** error in request: " + errorCode);
+
+    this._handleError(errorCode, new Request(connection.port), response);
+  }, 
+
+  /**
+   * Handles a request which generates the given error code, using the
+   * user-defined error handler if one has been set, gracefully falling back to
+   * the x00 status code if the code has no handler, and failing to status code
+   * 500 if all else fails.
+   *
+   * @param errorCode : uint
+   *   the HTTP error which is to be returned
+   * @param metadata : Request
+   *   metadata for the request, which will often be incomplete since this is an
+   *   error
+   * @param response : Response
+   *   an uninitialized Response should be initialized when this method
+   *   completes with information which represents the desired error code in the
+   *   ideal case or a fallback code in abnormal circumstances (i.e., 500 is a
+   *   fallback for 505, per HTTP specs)
+   */
+  _handleError: function(errorCode, metadata, response)
+  {
+    if (!metadata)
+      throw Cr.NS_ERROR_NULL_POINTER;
+
+    var errorX00 = errorCode - (errorCode % 100);
+
+    try
+    {
+      if (!(errorCode in HTTP_ERROR_CODES))
+        dumpn("*** WARNING: requested invalid error: " + errorCode);
+
+      // RFC 2616 says that we should try to handle an error by its class if we
+      // can't otherwise handle it -- if that fails, we revert to handling it as
+      // a 500 internal server error, and if that fails we throw and shut down
+      // the server
+
+      // actually handle the error
+      try
+      {
+        if (errorCode in this._overrideErrors)
+          this._overrideErrors[errorCode](metadata, response);
+        else
+          this._defaultErrors[errorCode](metadata, response);
+      }
+      catch (e)
+      {
+        if (response.partiallySent())
+        {
+          response.abort(e);
+          return;
+        }
+
+        // don't retry the handler that threw
+        if (errorX00 == errorCode)
+          throw HTTP_500;
+
+        dumpn("*** error in handling for error code " + errorCode + ", " +
+              "falling back to " + errorX00 + "...");
+        response = new Response(response._connection);
+        if (errorX00 in this._overrideErrors)
+          this._overrideErrors[errorX00](metadata, response);
+        else if (errorX00 in this._defaultErrors)
+          this._defaultErrors[errorX00](metadata, response);
+        else
+          throw HTTP_500;
+      }
+    }
+    catch (e)
+    {
+      if (response.partiallySent())
+      {
+        response.abort();
+        return;
+      }
+
+      // we've tried everything possible for a meaningful error -- now try 500
+      dumpn("*** error in handling for error code " + errorX00 + ", falling " +
+            "back to 500...");
+
+      try
+      {
+        response = new Response(response._connection);
+        if (500 in this._overrideErrors)
+          this._overrideErrors[500](metadata, response);
+        else
+          this._defaultErrors[500](metadata, response);
+      }
+      catch (e2)
+      {
+        dumpn("*** multiple errors in default error handlers!");
+        dumpn("*** e == " + e + ", e2 == " + e2);
+        response.abort(e2);
+        return;
+      }
+    }
+
+    response.complete();
+  },
+
+  // FIELDS
+
+  /**
+   * This object contains the default handlers for the various HTTP error codes.
+   */
+  _defaultErrors:
+  {
+    400: function(metadata, response)
+    {
+      // none of the data in metadata is reliable, so hard-code everything here
+      response.setStatusLine("1.1", 400, "Bad Request");
+      response.setHeader("Content-Type", "text/plain;charset=utf-8", false);
+
+      var body = "Bad request\n";
+      response.bodyOutputStream.write(body, body.length);
+    },
+    403: function(metadata, response)
+    {
+      response.setStatusLine(metadata.httpVersion, 403, "Forbidden");
+      response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+      var body = "<html>\
+                    <head><title>403 Forbidden</title></head>\
+                    <body>\
+                      <h1>403 Forbidden</h1>\
+                    </body>\
+                  </html>";
+      response.bodyOutputStream.write(body, body.length);
+    },
+    404: function(metadata, response)
+    {
+      response.setStatusLine(metadata.httpVersion, 404, "Not Found");
+      response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+      var body = "<html>\
+                    <head><title>404 Not Found</title></head>\
+                    <body>\
+                      <h1>404 Not Found</h1>\
+                      <p>\
+                        <span style='font-family: monospace;'>" +
+                          htmlEscape(metadata.path) +
+                       "</span> was not found.\
+                      </p>\
+                    </body>\
+                  </html>";
+      response.bodyOutputStream.write(body, body.length);
+    },
+    416: function(metadata, response)
+    {
+      response.setStatusLine(metadata.httpVersion,
+                            416,
+                            "Requested Range Not Satisfiable");
+      response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+      var body = "<html>\
+                   <head>\
+                    <title>416 Requested Range Not Satisfiable</title></head>\
+                    <body>\
+                     <h1>416 Requested Range Not Satisfiable</h1>\
+                     <p>The byte range was not valid for the\
+                        requested resource.\
+                     </p>\
+                    </body>\
+                  </html>";
+      response.bodyOutputStream.write(body, body.length);
+    },
+    500: function(metadata, response)
+    {
+      response.setStatusLine(metadata.httpVersion,
+                             500,
+                             "Internal Server Error");
+      response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+      var body = "<html>\
+                    <head><title>500 Internal Server Error</title></head>\
+                    <body>\
+                      <h1>500 Internal Server Error</h1>\
+                      <p>Something's broken in this server and\
+                        needs to be fixed.</p>\
+                    </body>\
+                  </html>";
+      response.bodyOutputStream.write(body, body.length);
+    },
+    501: function(metadata, response)
+    {
+      response.setStatusLine(metadata.httpVersion, 501, "Not Implemented");
+      response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+      var body = "<html>\
+                    <head><title>501 Not Implemented</title></head>\
+                    <body>\
+                      <h1>501 Not Implemented</h1>\
+                      <p>This server is not (yet) Apache.</p>\
+                    </body>\
+                  </html>";
+      response.bodyOutputStream.write(body, body.length);
+    },
+    505: function(metadata, response)
+    {
+      response.setStatusLine("1.1", 505, "HTTP Version Not Supported");
+      response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+      var body = "<html>\
+                    <head><title>505 HTTP Version Not Supported</title></head>\
+                    <body>\
+                      <h1>505 HTTP Version Not Supported</h1>\
+                      <p>This server only supports HTTP/1.0 and HTTP/1.1\
+                        connections.</p>\
+                    </body>\
+                  </html>";
+      response.bodyOutputStream.write(body, body.length);
+    }
+  },
+
+  /**
+   * Contains handlers for the default set of URIs contained in this server.
+   */
+  _defaultPaths:
+  {
+    "/": function(metadata, response)
+    {
+      response.setStatusLine(metadata.httpVersion, 200, "OK");
+      response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+      var body = "<html>\
+                    <head><title>httpd.js</title></head>\
+                    <body>\
+                      <h1>httpd.js</h1>\
+                      <p>If you're seeing this page, httpd.js is up and\
+                        serving requests!  Now set a base path and serve some\
+                        files!</p>\
+                    </body>\
+                  </html>";
+
+      response.bodyOutputStream.write(body, body.length);
+    },
+
+    "/trace": function(metadata, response)
+    {
+      response.setStatusLine(metadata.httpVersion, 200, "OK");
+      response.setHeader("Content-Type", "text/plain;charset=utf-8", false);
+
+      var body = "Request-URI: " +
+                 metadata.scheme + "://" + metadata.host + ":" + metadata.port +
+                 metadata.path + "\n\n";
+      body += "Request (semantically equivalent, slightly reformatted):\n\n";
+      body += metadata.method + " " + metadata.path;
+
+      if (metadata.queryString)
+        body +=  "?" + metadata.queryString;
+        
+      body += " HTTP/" + metadata.httpVersion + "\r\n";
+
+      var headEnum = metadata.headers;
+      while (headEnum.hasMoreElements())
+      {
+        var fieldName = headEnum.getNext()
+                                .QueryInterface(Ci.nsISupportsString)
+                                .data;
+        body += fieldName + ": " + metadata.getHeader(fieldName) + "\r\n";
+      }
+
+      response.bodyOutputStream.write(body, body.length);
+    }
+  }
+};
+
+
+/**
+ * Maps absolute paths to files on the local file system (as nsILocalFiles).
+ */
+function FileMap()
+{
+  /** Hash which will map paths to nsILocalFiles. */
+  this._map = {};
+}
+FileMap.prototype =
+{
+  // PUBLIC API
+
+  /**
+   * Maps key to a clone of the nsILocalFile value if value is non-null;
+   * otherwise, removes any extant mapping for key.
+   *
+   * @param key : string
+   *   string to which a clone of value is mapped
+   * @param value : nsILocalFile
+   *   the file to map to key, or null to remove a mapping
+   */
+  put: function(key, value)
+  {
+    if (value)
+      this._map[key] = value.clone();
+    else
+      delete this._map[key];
+  },
+
+  /**
+   * Returns a clone of the nsILocalFile mapped to key, or null if no such
+   * mapping exists.
+   *
+   * @param key : string
+   *   key to which the returned file maps
+   * @returns nsILocalFile
+   *   a clone of the mapped file, or null if no mapping exists
+   */
+  get: function(key)
+  {
+    var val = this._map[key];
+    return val ? val.clone() : null;
+  }
+};
+
+
+// Response CONSTANTS
+
+// token       = *<any CHAR except CTLs or separators>
+// CHAR        = <any US-ASCII character (0-127)>
+// CTL         = <any US-ASCII control character (0-31) and DEL (127)>
+// separators  = "(" | ")" | "<" | ">" | "@"
+//             | "," | ";" | ":" | "\" | <">
+//             | "/" | "[" | "]" | "?" | "="
+//             | "{" | "}" | SP  | HT
+const IS_TOKEN_ARRAY =
+  [0, 0, 0, 0, 0, 0, 0, 0, //   0
+   0, 0, 0, 0, 0, 0, 0, 0, //   8
+   0, 0, 0, 0, 0, 0, 0, 0, //  16
+   0, 0, 0, 0, 0, 0, 0, 0, //  24
+
+   0, 1, 0, 1, 1, 1, 1, 1, //  32
+   0, 0, 1, 1, 0, 1, 1, 0, //  40
+   1, 1, 1, 1, 1, 1, 1, 1, //  48
+   1, 1, 0, 0, 0, 0, 0, 0, //  56
+
+   0, 1, 1, 1, 1, 1, 1, 1, //  64
+   1, 1, 1, 1, 1, 1, 1, 1, //  72
+   1, 1, 1, 1, 1, 1, 1, 1, //  80
+   1, 1, 1, 0, 0, 0, 1, 1, //  88
+
+   1, 1, 1, 1, 1, 1, 1, 1, //  96
+   1, 1, 1, 1, 1, 1, 1, 1, // 104
+   1, 1, 1, 1, 1, 1, 1, 1, // 112
+   1, 1, 1, 0, 1, 0, 1];   // 120
+
+
+/**
+ * Determines whether the given character code is a CTL.
+ *
+ * @param code : uint
+ *   the character code
+ * @returns boolean
+ *   true if code is a CTL, false otherwise
+ */
+function isCTL(code)
+{
+  return (code >= 0 && code <= 31) || (code == 127);
+}
+
+/**
+ * Represents a response to an HTTP request, encapsulating all details of that
+ * response.  This includes all headers, the HTTP version, status code and
+ * explanation, and the entity itself.
+ *
+ * @param connection : Connection
+ *   the connection over which this response is to be written
+ */
+function Response(connection)
+{
+  /** The connection over which this response will be written. */
+  this._connection = connection;
+
+  /**
+   * The HTTP version of this response; defaults to 1.1 if not set by the
+   * handler.
+   */
+  this._httpVersion = nsHttpVersion.HTTP_1_1;
+
+  /**
+   * The HTTP code of this response; defaults to 200.
+   */
+  this._httpCode = 200;
+
+  /**
+   * The description of the HTTP code in this response; defaults to "OK".
+   */
+  this._httpDescription = "OK";
+
+  /**
+   * An nsIHttpHeaders object in which the headers in this response should be
+   * stored.  This property is null after the status line and headers have been
+   * written to the network, and it may be modified up until it is cleared,
+   * except if this._finished is set first (in which case headers are written
+   * asynchronously in response to a finish() call not preceded by
+   * flushHeaders()).
+   */
+  this._headers = new nsHttpHeaders();
+
+  /**
+   * Set to true when this response is ended (completely constructed if possible
+   * and the connection closed); further actions on this will then fail.
+   */
+  this._ended = false;
+
+  /**
+   * A stream used to hold data written to the body of this response.
+   */
+  this._bodyOutputStream = null;
+
+  /**
+   * A stream containing all data that has been written to the body of this
+   * response so far.  (Async handlers make the data contained in this
+   * unreliable as a way of determining content length in general, but auxiliary
+   * saved information can sometimes be used to guarantee reliability.)
+   */
+  this._bodyInputStream = null;
+
+  /**
+   * A stream copier which copies data to the network.  It is initially null
+   * until replaced with a copier for response headers; when headers have been
+   * fully sent it is replaced with a copier for the response body, remaining
+   * so for the duration of response processing.
+   */
+  this._asyncCopier = null;
+
+  /**
+   * True if this response has been designated as being processed
+   * asynchronously rather than for the duration of a single call to
+   * nsIHttpRequestHandler.handle.
+   */
+  this._processAsync = false;
+
+  /**
+   * True iff finish() has been called on this, signaling that no more changes
+   * to this may be made.
+   */
+  this._finished = false;
+
+  /**
+   * True iff powerSeized() has been called on this, signaling that this
+   * response is to be handled manually by the response handler (which may then
+   * send arbitrary data in response, even non-HTTP responses).
+   */
+  this._powerSeized = false;
+}
+Response.prototype =
+{
+  // PUBLIC CONSTRUCTION API
+
+  //
+  // see nsIHttpResponse.bodyOutputStream
+  //
+  get bodyOutputStream()
+  {
+    if (this._finished)
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+
+    if (!this._bodyOutputStream)
+    {
+      var pipe = new Pipe(true, false, Response.SEGMENT_SIZE, PR_UINT32_MAX,
+                          null);
+      this._bodyOutputStream = pipe.outputStream;
+      this._bodyInputStream = pipe.inputStream;
+      if (this._processAsync || this._powerSeized)
+        this._startAsyncProcessor();
+    }
+
+    return this._bodyOutputStream;
+  },
+
+  //
+  // see nsIHttpResponse.write
+  //
+  write: function(data)
+  {
+    if (this._finished)
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+
+    var dataAsString = String(data);
+    this.bodyOutputStream.write(dataAsString, dataAsString.length);
+  },
+
+  //
+  // see nsIHttpResponse.setStatusLine
+  //
+  setStatusLine: function(httpVersion, code, description)
+  {
+    if (!this._headers || this._finished || this._powerSeized)
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+    this._ensureAlive();
+
+    if (!(code >= 0 && code < 1000))
+      throw Cr.NS_ERROR_INVALID_ARG;
+
+    try
+    {
+      var httpVer;
+      // avoid version construction for the most common cases
+      if (!httpVersion || httpVersion == "1.1")
+        httpVer = nsHttpVersion.HTTP_1_1;
+      else if (httpVersion == "1.0")
+        httpVer = nsHttpVersion.HTTP_1_0;
+      else
+        httpVer = new nsHttpVersion(httpVersion);
+    }
+    catch (e)
+    {
+      throw Cr.NS_ERROR_INVALID_ARG;
+    }
+
+    // Reason-Phrase = *<TEXT, excluding CR, LF>
+    // TEXT          = <any OCTET except CTLs, but including LWS>
+    //
+    // XXX this ends up disallowing octets which aren't Unicode, I think -- not
+    //     much to do if description is IDL'd as string
+    if (!description)
+      description = "";
+    for (var i = 0; i < description.length; i++)
+      if (isCTL(description.charCodeAt(i)) && description.charAt(i) != "\t")
+        throw Cr.NS_ERROR_INVALID_ARG;
+
+    // set the values only after validation to preserve atomicity
+    this._httpDescription = description;
+    this._httpCode = code;
+    this._httpVersion = httpVer;
+  },
+
+  //
+  // see nsIHttpResponse.setHeader
+  //
+  setHeader: function(name, value, merge)
+  {
+    if (!this._headers || this._finished || this._powerSeized)
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+    this._ensureAlive();
+
+    this._headers.setHeader(name, value, merge);
+  },
+
+  //
+  // see nsIHttpResponse.processAsync
+  //
+  processAsync: function()
+  {
+    if (this._finished)
+      throw Cr.NS_ERROR_UNEXPECTED;
+    if (this._powerSeized)
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+    if (this._processAsync)
+      return;
+    this._ensureAlive();
+
+    dumpn("*** processing connection " + this._connection.number + " async");
+    this._processAsync = true;
+
+    /*
+     * Either the bodyOutputStream getter or this method is responsible for
+     * starting the asynchronous processor and catching writes of data to the
+     * response body of async responses as they happen, for the purpose of
+     * forwarding those writes to the actual connection's output stream.
+     * If bodyOutputStream is accessed first, calling this method will create
+     * the processor (when it first is clear that body data is to be written
+     * immediately, not buffered).  If this method is called first, accessing
+     * bodyOutputStream will create the processor.  If only this method is
+     * called, we'll write nothing, neither headers nor the nonexistent body,
+     * until finish() is called.  Since that delay is easily avoided by simply
+     * getting bodyOutputStream or calling write(""), we don't worry about it.
+     */
+    if (this._bodyOutputStream && !this._asyncCopier)
+      this._startAsyncProcessor();
+  },
+
+  //
+  // see nsIHttpResponse.seizePower
+  //
+  seizePower: function()
+  {
+    if (this._processAsync)
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+    if (this._finished)
+      throw Cr.NS_ERROR_UNEXPECTED;
+    if (this._powerSeized)
+      return;
+    this._ensureAlive();
+
+    dumpn("*** forcefully seizing power over connection " +
+          this._connection.number + "...");
+
+    // Purge any already-written data without sending it.  We could as easily
+    // swap out the streams entirely, but that makes it possible to acquire and
+    // unknowingly use a stale reference, so we require there only be one of
+    // each stream ever for any response to avoid this complication.
+    if (this._asyncCopier)
+      this._asyncCopier.cancel(Cr.NS_BINDING_ABORTED);
+    this._asyncCopier = null;
+    if (this._bodyOutputStream)
+    {
+      var input = new BinaryInputStream(this._bodyInputStream);
+      var avail;
+      while ((avail = input.available()) > 0)
+        input.readByteArray(avail);
+    }
+
+    this._powerSeized = true;
+    if (this._bodyOutputStream)
+      this._startAsyncProcessor();
+  },
+
+  //
+  // see nsIHttpResponse.finish
+  //
+  finish: function()
+  {
+    if (!this._processAsync && !this._powerSeized)
+      throw Cr.NS_ERROR_UNEXPECTED;
+    if (this._finished)
+      return;
+
+    dumpn("*** finishing connection " + this._connection.number);
+    this._startAsyncProcessor(); // in case bodyOutputStream was never accessed
+    if (this._bodyOutputStream)
+      this._bodyOutputStream.close();
+    this._finished = true;
+  },
+
+
+  // NSISUPPORTS
+
+  //
+  // see nsISupports.QueryInterface
+  //
+  QueryInterface: function(iid)
+  {
+    if (iid.equals(Ci.nsIHttpResponse) || iid.equals(Ci.nsISupports))
+      return this;
+
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  },
+
+
+  // POST-CONSTRUCTION API (not exposed externally)
+
+  /**
+   * The HTTP version number of this, as a string (e.g. "1.1").
+   */
+  get httpVersion()
+  {
+    this._ensureAlive();
+    return this._httpVersion.toString();
+  },
+
+  /**
+   * The HTTP status code of this response, as a string of three characters per
+   * RFC 2616.
+   */
+  get httpCode()
+  {
+    this._ensureAlive();
+
+    var codeString = (this._httpCode < 10 ? "0" : "") +
+                     (this._httpCode < 100 ? "0" : "") +
+                     this._httpCode;
+    return codeString;
+  },
+
+  /**
+   * The description of the HTTP status code of this response, or "" if none is
+   * set.
+   */
+  get httpDescription()
+  {
+    this._ensureAlive();
+
+    return this._httpDescription;
+  },
+
+  /**
+   * The headers in this response, as an nsHttpHeaders object.
+   */
+  get headers()
+  {
+    this._ensureAlive();
+
+    return this._headers;
+  },
+
+  //
+  // see nsHttpHeaders.getHeader
+  //
+  getHeader: function(name)
+  {
+    this._ensureAlive();
+
+    return this._headers.getHeader(name);
+  },
+
+  /**
+   * Determines whether this response may be abandoned in favor of a newly
+   * constructed response.  A response may be abandoned only if it is not being
+   * sent asynchronously and if raw control over it has not been taken from the
+   * server.
+   *
+   * @returns boolean
+   *   true iff no data has been written to the network
+   */
+  partiallySent: function()
+  {
+    dumpn("*** partiallySent()");
+    return this._processAsync || this._powerSeized;
+  },
+
+  /**
+   * If necessary, kicks off the remaining request processing needed to be done
+   * after a request handler performs its initial work upon this response.
+   */
+  complete: function()
+  {
+    dumpn("*** complete()");
+    if (this._processAsync || this._powerSeized)
+    {
+      NS_ASSERT(this._processAsync ^ this._powerSeized,
+                "can't both send async and relinquish power");
+      return;
+    }
+
+    NS_ASSERT(!this.partiallySent(), "completing a partially-sent response?");
+
+    this._startAsyncProcessor();
+
+    // Now make sure we finish processing this request!
+    if (this._bodyOutputStream)
+      this._bodyOutputStream.close();
+  },
+
+  /**
+   * Abruptly ends processing of this response, usually due to an error in an
+   * incoming request but potentially due to a bad error handler.  Since we
+   * cannot handle the error in the usual way (giving an HTTP error page in
+   * response) because data may already have been sent (or because the response
+   * might be expected to have been generated asynchronously or completely from
+   * scratch by the handler), we stop processing this response and abruptly
+   * close the connection.
+   *
+   * @param e : Error
+   *   the exception which precipitated this abort, or null if no such exception
+   *   was generated
+   */
+  abort: function(e)
+  {
+    dumpn("*** abort(<" + e + ">)");
+
+    // This response will be ended by the processor if one was created.
+    var copier = this._asyncCopier;
+    if (copier)
+    {
+      // We dispatch asynchronously here so that any pending writes of data to
+      // the connection will be deterministically written.  This makes it easier
+      // to specify exact behavior, and it makes observable behavior more
+      // predictable for clients.  Note that the correctness of this depends on
+      // callbacks in response to _waitToReadData in WriteThroughCopier
+      // happening asynchronously with respect to the actual writing of data to
+      // bodyOutputStream, as they currently do; if they happened synchronously,
+      // an event which ran before this one could write more data to the
+      // response body before we get around to canceling the copier.  We have
+      // tests for this in test_seizepower.js, however, and I can't think of a
+      // way to handle both cases without removing bodyOutputStream access and
+      // moving its effective write(data, length) method onto Response, which
+      // would be slower and require more code than this anyway.
+      gThreadManager.currentThread.dispatch({
+        run: function()
+        {
+          dumpn("*** canceling copy asynchronously...");
+          copier.cancel(Cr.NS_ERROR_UNEXPECTED);
+        }
+      }, Ci.nsIThread.DISPATCH_NORMAL);
+    }
+    else
+    {
+      this.end();
+    }
+  },
+
+  /**
+   * Closes this response's network connection, marks the response as finished,
+   * and notifies the server handler that the request is done being processed.
+   */
+  end: function()
+  {
+    NS_ASSERT(!this._ended, "ending this response twice?!?!");
+
+    this._connection.close();
+    if (this._bodyOutputStream)
+      this._bodyOutputStream.close();
+
+    this._finished = true;
+    this._ended = true;
+  },
+
+  // PRIVATE IMPLEMENTATION
+
+  /**
+   * Sends the status line and headers of this response if they haven't been
+   * sent and initiates the process of copying data written to this response's
+   * body to the network.
+   */
+  _startAsyncProcessor: function()
+  {
+    dumpn("*** _startAsyncProcessor()");
+
+    // Handle cases where we're being called a second time.  The former case
+    // happens when this is triggered both by complete() and by processAsync(),
+    // while the latter happens when processAsync() in conjunction with sent
+    // data causes abort() to be called.
+    if (this._asyncCopier || this._ended)
+    {
+      dumpn("*** ignoring second call to _startAsyncProcessor");
+      return;
+    }
+
+    // Send headers if they haven't been sent already and should be sent, then
+    // asynchronously continue to send the body.
+    if (this._headers && !this._powerSeized)
+    {
+      this._sendHeaders();
+      return;
+    }
+
+    this._headers = null;
+    this._sendBody();
+  },
+
+  /**
+   * Signals that all modifications to the response status line and headers are
+   * complete and then sends that data over the network to the client.  Once
+   * this method completes, a different response to the request that resulted
+   * in this response cannot be sent -- the only possible action in case of
+   * error is to abort the response and close the connection.
+   */
+  _sendHeaders: function()
+  {
+    dumpn("*** _sendHeaders()");
+
+    NS_ASSERT(this._headers);
+    NS_ASSERT(!this._powerSeized);
+
+    // request-line
+    var statusLine = "HTTP/" + this.httpVersion + " " +
+                     this.httpCode + " " +
+                     this.httpDescription + "\r\n";
+
+    // header post-processing
+
+    var headers = this._headers;
+    headers.setHeader("Connection", "close", false);
+    headers.setHeader("Server", "httpd.js", false);
+    if (!headers.hasHeader("Date"))
+      headers.setHeader("Date", toDateString(Date.now()), false);
+
+    // Any response not being processed asynchronously must have an associated
+    // Content-Length header for reasons of backwards compatibility with the
+    // initial server, which fully buffered every response before sending it.
+    // Beyond that, however, it's good to do this anyway because otherwise it's
+    // impossible to test behaviors that depend on the presence or absence of a
+    // Content-Length header.
+    if (!this._processAsync)
+    {
+      dumpn("*** non-async response, set Content-Length");
+
+      var bodyStream = this._bodyInputStream;
+      var avail = bodyStream ? bodyStream.available() : 0;
+
+      // XXX assumes stream will always report the full amount of data available
+      headers.setHeader("Content-Length", "" + avail, false);
+    }
+
+
+    // construct and send response
+    dumpn("*** header post-processing completed, sending response head...");
+
+    // request-line
+    var preambleData = [statusLine];
+
+    // headers
+    var headEnum = headers.enumerator;
+    while (headEnum.hasMoreElements())
+    {
+      var fieldName = headEnum.getNext()
+                              .QueryInterface(Ci.nsISupportsString)
+                              .data;
+      var values = headers.getHeaderValues(fieldName);
+      for (var i = 0, sz = values.length; i < sz; i++)
+        preambleData.push(fieldName + ": " + values[i] + "\r\n");
+    }
+
+    // end request-line/headers
+    preambleData.push("\r\n");
+
+    var preamble = preambleData.join("");
+
+    var responseHeadPipe = new Pipe(true, false, 0, PR_UINT32_MAX, null);
+    responseHeadPipe.outputStream.write(preamble, preamble.length);
+
+    var response = this;
+    var copyObserver =
+      {
+        onStartRequest: function(request, cx)
+        {
+          dumpn("*** preamble copying started");
+        },
+
+        onStopRequest: function(request, cx, statusCode)
+        {
+          dumpn("*** preamble copying complete " +
+                "[status=0x" + statusCode.toString(16) + "]");
+
+          if (!Components.isSuccessCode(statusCode))
+          {
+            dumpn("!!! header copying problems: non-success statusCode, " +
+                  "ending response");
+
+            response.end();
+          }
+          else
+          {
+            response._sendBody();
+          }
+        },
+
+        QueryInterface: function(aIID)
+        {
+          if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports))
+            return this;
+
+          throw Cr.NS_ERROR_NO_INTERFACE;
+        }
+      };
+
+    var headerCopier = this._asyncCopier =
+      new WriteThroughCopier(responseHeadPipe.inputStream,
+                             this._connection.output,
+                             copyObserver, null);
+
+    responseHeadPipe.outputStream.close();
+
+    // Forbid setting any more headers or modifying the request line.
+    this._headers = null;
+  },
+
+  /**
+   * Asynchronously writes the body of the response (or the entire response, if
+   * seizePower() has been called) to the network.
+   */
+  _sendBody: function()
+  {
+    dumpn("*** _sendBody");
+
+    NS_ASSERT(!this._headers, "still have headers around but sending body?");
+
+    // If no body data was written, we're done
+    if (!this._bodyInputStream)
+    {
+      dumpn("*** empty body, response finished");
+      this.end();
+      return;
+    }
+
+    var response = this;
+    var copyObserver =
+      {
+        onStartRequest: function(request, context)
+        {
+          dumpn("*** onStartRequest");
+        },
+
+        onStopRequest: function(request, cx, statusCode)
+        {
+          dumpn("*** onStopRequest [status=0x" + statusCode.toString(16) + "]");
+
+          if (statusCode === Cr.NS_BINDING_ABORTED)
+          {
+            dumpn("*** terminating copy observer without ending the response");
+          }
+          else
+          {
+            if (!Components.isSuccessCode(statusCode))
+              dumpn("*** WARNING: non-success statusCode in onStopRequest");
+
+            response.end();
+          }
+        },
+
+        QueryInterface: function(aIID)
+        {
+          if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports))
+            return this;
+
+          throw Cr.NS_ERROR_NO_INTERFACE;
+        }
+      };
+
+    dumpn("*** starting async copier of body data...");
+    this._asyncCopier =
+      new WriteThroughCopier(this._bodyInputStream, this._connection.output,
+                            copyObserver, null);
+  },
+
+  /** Ensures that this hasn't been ended. */
+  _ensureAlive: function()
+  {
+    NS_ASSERT(!this._ended, "not handling response lifetime correctly");
+  }
+};
+
+/**
+ * Size of the segments in the buffer used in storing response data and writing
+ * it to the socket.
+ */
+Response.SEGMENT_SIZE = 8192;
+
+/** Serves double duty in WriteThroughCopier implementation. */
+function notImplemented()
+{
+  throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+}
+
+/** Returns true iff the given exception represents stream closure. */
+function streamClosed(e)
+{
+  return e === Cr.NS_BASE_STREAM_CLOSED ||
+         (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_CLOSED);
+}
+
+/** Returns true iff the given exception represents a blocked stream. */
+function wouldBlock(e)
+{
+  return e === Cr.NS_BASE_STREAM_WOULD_BLOCK ||
+         (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_WOULD_BLOCK);
+}
+
+/**
+ * Copies data from source to sink as it becomes available, when that data can
+ * be written to sink without blocking.
+ *
+ * @param source : nsIAsyncInputStream
+ *   the stream from which data is to be read
+ * @param sink : nsIAsyncOutputStream
+ *   the stream to which data is to be copied
+ * @param observer : nsIRequestObserver
+ *   an observer which will be notified when the copy starts and finishes
+ * @param context : nsISupports
+ *   context passed to observer when notified of start/stop
+ * @throws NS_ERROR_NULL_POINTER
+ *   if source, sink, or observer are null
+ */
+function WriteThroughCopier(source, sink, observer, context)
+{
+  if (!source || !sink || !observer)
+    throw Cr.NS_ERROR_NULL_POINTER;
+
+  /** Stream from which data is being read. */
+  this._source = source;
+
+  /** Stream to which data is being written. */
+  this._sink = sink;
+
+  /** Observer watching this copy. */
+  this._observer = observer;
+
+  /** Context for the observer watching this. */
+  this._context = context;
+
+  /**
+   * True iff this is currently being canceled (cancel has been called, the
+   * callback may not yet have been made).
+   */
+  this._canceled = false;
+
+  /**
+   * False until all data has been read from input and written to output, at
+   * which point this copy is completed and cancel() is asynchronously called.
+   */
+  this._completed = false;
+
+  /** Required by nsIRequest, meaningless. */
+  this.loadFlags = 0;
+  /** Required by nsIRequest, meaningless. */
+  this.loadGroup = null;
+  /** Required by nsIRequest, meaningless. */
+  this.name = "response-body-copy";
+
+  /** Status of this request. */
+  this.status = Cr.NS_OK;
+
+  /** Arrays of byte strings waiting to be written to output. */
+  this._pendingData = [];
+
+  // start copying
+  try
+  {
+    observer.onStartRequest(this, context);
+    this._waitToReadData();
+    this._waitForSinkClosure();
+  }
+  catch (e)
+  {
+    dumpn("!!! error starting copy: " + e +
+          ("lineNumber" in e ? ", line " + e.lineNumber : ""));
+    dumpn(e.stack);
+    this.cancel(Cr.NS_ERROR_UNEXPECTED);
+  }
+}
+WriteThroughCopier.prototype =
+{
+  /* nsISupports implementation */
+
+  QueryInterface: function(iid)
+  {
+    if (iid.equals(Ci.nsIInputStreamCallback) ||
+        iid.equals(Ci.nsIOutputStreamCallback) ||
+        iid.equals(Ci.nsIRequest) ||
+        iid.equals(Ci.nsISupports))
+    {
+      return this;
+    }
+
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  },
+
+
+  // NSIINPUTSTREAMCALLBACK
+
+  /**
+   * Receives a more-data-in-input notification and writes the corresponding
+   * data to the output.
+   *
+   * @param input : nsIAsyncInputStream
+   *   the input stream on whose data we have been waiting
+   */
+  onInputStreamReady: function(input)
+  {
+    if (this._source === null)
+      return;
+
+    dumpn("*** onInputStreamReady");
+
+    //
+    // Ordinarily we'll read a non-zero amount of data from input, queue it up
+    // to be written and then wait for further callbacks.  The complications in
+    // this method are the cases where we deviate from that behavior when errors
+    // occur or when copying is drawing to a finish.
+    //
+    // The edge cases when reading data are:
+    //
+    //   Zero data is read
+    //     If zero data was read, we're at the end of available data, so we can
+    //     should stop reading and move on to writing out what we have (or, if
+    //     we've already done that, onto notifying of completion).
+    //   A stream-closed exception is thrown
+    //     This is effectively a less kind version of zero data being read; the
+    //     only difference is that we notify of completion with that result
+    //     rather than with NS_OK.
+    //   Some other exception is thrown
+    //     This is the least kind result.  We don't know what happened, so we
+    //     act as though the stream closed except that we notify of completion
+    //     with the result NS_ERROR_UNEXPECTED.
+    //
+
+    var bytesWanted = 0, bytesConsumed = -1;
+    try
+    {
+      input = new BinaryInputStream(input);
+
+      bytesWanted = Math.min(input.available(), Response.SEGMENT_SIZE);
+      dumpn("*** input wanted: " + bytesWanted);
+
+      if (bytesWanted > 0)
+      {
+        var data = input.readByteArray(bytesWanted);
+        bytesConsumed = data.length;
+        this._pendingData.push(String.fromCharCode.apply(String, data));
+      }
+
+      dumpn("*** " + bytesConsumed + " bytes read");
+
+      // Handle the zero-data edge case in the same place as all other edge
+      // cases are handled.
+      if (bytesWanted === 0)
+        throw Cr.NS_BASE_STREAM_CLOSED;
+    }
+    catch (e)
+    {
+      if (streamClosed(e))
+      {
+        dumpn("*** input stream closed");
+        e = bytesWanted === 0 ? Cr.NS_OK : Cr.NS_ERROR_UNEXPECTED;
+      }
+      else
+      {
+        dumpn("!!! unexpected error reading from input, canceling: " + e);
+        e = Cr.NS_ERROR_UNEXPECTED;
+      }
+
+      this._doneReadingSource(e);
+      return;
+    }
+
+    var pendingData = this._pendingData;
+
+    NS_ASSERT(bytesConsumed > 0);
+    NS_ASSERT(pendingData.length > 0, "no pending data somehow?");
+    NS_ASSERT(pendingData[pendingData.length - 1].length > 0,
+              "buffered zero bytes of data?");
+
+    NS_ASSERT(this._source !== null);
+
+    // Reading has gone great, and we've gotten data to write now.  What if we
+    // don't have a place to write that data, because output went away just
+    // before this read?  Drop everything on the floor, including new data, and
+    // cancel at this point.
+    if (this._sink === null)
+    {
+      pendingData.length = 0;
+      this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED);
+      return;
+    }
+
+    // Okay, we've read the data, and we know we have a place to write it.  We
+    // need to queue up the data to be written, but *only* if none is queued
+    // already -- if data's already queued, the code that actually writes the
+    // data will make sure to wait on unconsumed pending data.
+    try
+    {
+      if (pendingData.length === 1)
+        this._waitToWriteData();
+    }
+    catch (e)
+    {
+      dumpn("!!! error waiting to write data just read, swallowing and " +
+            "writing only what we already have: " + e);
+      this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED);
+      return;
+    }
+
+    // Whee!  We successfully read some data, and it's successfully queued up to
+    // be written.  All that remains now is to wait for more data to read.
+    try
+    {
+      this._waitToReadData();
+    }
+    catch (e)
+    {
+      dumpn("!!! error waiting to read more data: " + e);
+      this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED);
+    }
+  },
+
+
+  // NSIOUTPUTSTREAMCALLBACK
+
+  /**
+   * Callback when data may be written to the output stream without blocking, or
+   * when the output stream has been closed.
+   *
+   * @param output : nsIAsyncOutputStream
+   *   the output stream on whose writability we've been waiting, also known as
+   *   this._sink
+   */
+  onOutputStreamReady: function(output)
+  {
+    if (this._sink === null)
+      return;
+
+    dumpn("*** onOutputStreamReady");
+
+    var pendingData = this._pendingData;
+    if (pendingData.length === 0)
+    {
+      // There's no pending data to write.  The only way this can happen is if
+      // we're waiting on the output stream's closure, so we can respond to a
+      // copying failure as quickly as possible (rather than waiting for data to
+      // be available to read and then fail to be copied).  Therefore, we must
+      // be done now -- don't bother to attempt to write anything and wrap
+      // things up.
+      dumpn("!!! output stream closed prematurely, ending copy");
+
+      this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED);
+      return;
+    }
+
+
+    NS_ASSERT(pendingData[0].length > 0, "queued up an empty quantum?");
+
+    //
+    // Write out the first pending quantum of data.  The possible errors here
+    // are:
+    //
+    //   The write might fail because we can't write that much data
+    //     Okay, we've written what we can now, so re-queue what's left and
+    //     finish writing it out later.
+    //   The write failed because the stream was closed
+    //     Discard pending data that we can no longer write, stop reading, and
+    //     signal that copying finished.
+    //   Some other error occurred.
+    //     Same as if the stream were closed, but notify with the status
+    //     NS_ERROR_UNEXPECTED so the observer knows something was wonky.
+    //
+
+    try
+    {
+      var quantum = pendingData[0];
+
+      // XXX |quantum| isn't guaranteed to be ASCII, so we're relying on
+      //     undefined behavior!  We're only using this because writeByteArray
+      //     is unusably broken for asynchronous output streams; see bug 532834
+      //     for details.
+      var bytesWritten = output.write(quantum, quantum.length);
+      if (bytesWritten === quantum.length)
+        pendingData.shift();
+      else
+        pendingData[0] = quantum.substring(bytesWritten);
+
+      dumpn("*** wrote " + bytesWritten + " bytes of data");
+    }
+    catch (e)
+    {
+      if (wouldBlock(e))
+      {
+        NS_ASSERT(pendingData.length > 0,
+                  "stream-blocking exception with no data to write?");
+        NS_ASSERT(pendingData[0].length > 0,
+                  "stream-blocking exception with empty quantum?");
+        this._waitToWriteData();
+        return;
+      }
+
+      if (streamClosed(e))
+        dumpn("!!! output stream prematurely closed, signaling error...");
+      else
+        dumpn("!!! unknown error: " + e + ", quantum=" + quantum);
+
+      this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED);
+      return;
+    }
+
+    // The day is ours!  Quantum written, now let's see if we have more data
+    // still to write.
+    try
+    {
+      if (pendingData.length > 0)
+      {
+        this._waitToWriteData();
+        return;
+      }
+    }
+    catch (e)
+    {
+      dumpn("!!! unexpected error waiting to write pending data: " + e);
+      this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED);
+      return;
+    }
+
+    // Okay, we have no more pending data to write -- but might we get more in
+    // the future?
+    if (this._source !== null)
+    {
+      /*
+       * If we might, then wait for the output stream to be closed.  (We wait
+       * only for closure because we have no data to write -- and if we waited
+       * for a specific amount of data, we would get repeatedly notified for no
+       * reason if over time the output stream permitted more and more data to
+       * be written to it without blocking.)
+       */
+       this._waitForSinkClosure();
+    }
+    else
+    {
+      /*
+       * On the other hand, if we can't have more data because the input
+       * stream's gone away, then it's time to notify of copy completion.
+       * Victory!
+       */
+      this._sink = null;
+      this._cancelOrDispatchCancelCallback(Cr.NS_OK);
+    }
+  },
+
+
+  // NSIREQUEST
+
+  /** Returns true if the cancel observer hasn't been notified yet. */
+  isPending: function()
+  {
+    return !this._completed;
+  },
+
+  /** Not implemented, don't use! */
+  suspend: notImplemented,
+  /** Not implemented, don't use! */
+  resume: notImplemented,
+
+  /**
+   * Cancels data reading from input, asynchronously writes out any pending
+   * data, and causes the observer to be notified with the given error code when
+   * all writing has finished.
+   *
+   * @param status : nsresult
+   *   the status to pass to the observer when data copying has been canceled
+   */
+  cancel: function(status)
+  {
+    dumpn("*** cancel(" + status.toString(16) + ")");
+
+    if (this._canceled)
+    {
+      dumpn("*** suppressing a late cancel");
+      return;
+    }
+
+    this._canceled = true;
+    this.status = status;
+
+    // We could be in the middle of absolutely anything at this point.  Both
+    // input and output might still be around, we might have pending data to
+    // write, and in general we know nothing about the state of the world.  We
+    // therefore must assume everything's in progress and take everything to its
+    // final steady state (or so far as it can go before we need to finish
+    // writing out remaining data).
+
+    this._doneReadingSource(status);
+  },
+
+
+  // PRIVATE IMPLEMENTATION
+
+  /**
+   * Stop reading input if we haven't already done so, passing e as the status
+   * when closing the stream, and kick off a copy-completion notice if no more
+   * data remains to be written.
+   *
+   * @param e : nsresult
+   *   the status to be used when closing the input stream
+   */
+  _doneReadingSource: function(e)
+  {
+    dumpn("*** _doneReadingSource(0x" + e.toString(16) + ")");
+
+    this._finishSource(e);
+    if (this._pendingData.length === 0)
+      this._sink = null;
+    else
+      NS_ASSERT(this._sink !== null, "null output?");
+
+    // If we've written out all data read up to this point, then it's time to
+    // signal completion.
+    if (this._sink === null)
+    {
+      NS_ASSERT(this._pendingData.length === 0, "pending data still?");
+      this._cancelOrDispatchCancelCallback(e);
+    }
+  },
+
+  /**
+   * Stop writing output if we haven't already done so, discard any data that
+   * remained to be sent, close off input if it wasn't already closed, and kick
+   * off a copy-completion notice.
+   *
+   * @param e : nsresult
+   *   the status to be used when closing input if it wasn't already closed
+   */
+  _doneWritingToSink: function(e)
+  {
+    dumpn("*** _doneWritingToSink(0x" + e.toString(16) + ")");
+
+    this._pendingData.length = 0;
+    this._sink = null;
+    this._doneReadingSource(e);
+  },
+
+  /**
+   * Completes processing of this copy: either by canceling the copy if it
+   * hasn't already been canceled using the provided status, or by dispatching
+   * the cancel callback event (with the originally provided status, of course)
+   * if it already has been canceled.
+   *
+   * @param status : nsresult
+   *   the status code to use to cancel this, if this hasn't already been
+   *   canceled
+   */
+  _cancelOrDispatchCancelCallback: function(status)
+  {
+    dumpn("*** _cancelOrDispatchCancelCallback(" + status + ")");
+
+    NS_ASSERT(this._source === null, "should have finished input");
+    NS_ASSERT(this._sink === null, "should have finished output");
+    NS_ASSERT(this._pendingData.length === 0, "should have no pending data");
+
+    if (!this._canceled)
+    {
+      this.cancel(status);
+      return;
+    }
+
+    var self = this;
+    var event =
+      {
+        run: function()
+        {
+          dumpn("*** onStopRequest async callback");
+
+          self._completed = true;
+          try
+          {
+            self._observer.onStopRequest(self, self._context, self.status);
+          }
+          catch (e)
+          {
+            NS_ASSERT(false,
+                      "how are we throwing an exception here?  we control " +
+                      "all the callers!  " + e);
+          }
+        }
+      };
+
+    gThreadManager.currentThread.dispatch(event, Ci.nsIThread.DISPATCH_NORMAL);
+  },
+
+  /**
+   * Kicks off another wait for more data to be available from the input stream.
+   */
+  _waitToReadData: function()
+  {
+    dumpn("*** _waitToReadData");
+    this._source.asyncWait(this, 0, Response.SEGMENT_SIZE,
+                           gThreadManager.mainThread);
+  },
+
+  /**
+   * Kicks off another wait until data can be written to the output stream.
+   */
+  _waitToWriteData: function()
+  {
+    dumpn("*** _waitToWriteData");
+
+    var pendingData = this._pendingData;
+    NS_ASSERT(pendingData.length > 0, "no pending data to write?");
+    NS_ASSERT(pendingData[0].length > 0, "buffered an empty write?");
+
+    this._sink.asyncWait(this, 0, pendingData[0].length,
+                         gThreadManager.mainThread);
+  },
+
+  /**
+   * Kicks off a wait for the sink to which data is being copied to be closed.
+   * We wait for stream closure when we don't have any data to be copied, rather
+   * than waiting to write a specific amount of data.  We can't wait to write
+   * data because the sink might be infinitely writable, and if no data appears
+   * in the source for a long time we might have to spin quite a bit waiting to
+   * write, waiting to write again, &c.  Waiting on stream closure instead means
+   * we'll get just one notification if the sink dies.  Note that when data
+   * starts arriving from the sink we'll resume waiting for data to be written,
+   * dropping this closure-only callback entirely.
+   */
+  _waitForSinkClosure: function()
+  {
+    dumpn("*** _waitForSinkClosure");
+
+    this._sink.asyncWait(this, Ci.nsIAsyncOutputStream.WAIT_CLOSURE_ONLY, 0,
+                         gThreadManager.mainThread);
+  },
+
+  /**
+   * Closes input with the given status, if it hasn't already been closed;
+   * otherwise a no-op.
+   *
+   * @param status : nsresult
+   *   status code use to close the source stream if necessary
+   */
+  _finishSource: function(status)
+  {
+    dumpn("*** _finishSource(" + status.toString(16) + ")");
+
+    if (this._source !== null)
+    {
+      this._source.closeWithStatus(status);
+      this._source = null;
+    }
+  }
+};
+
+
+/**
+ * A container for utility functions used with HTTP headers.
+ */
+const headerUtils =
+{
+  /**
+   * Normalizes fieldName (by converting it to lowercase) and ensures it is a
+   * valid header field name (although not necessarily one specified in RFC
+   * 2616).
+   *
+   * @throws NS_ERROR_INVALID_ARG
+   *   if fieldName does not match the field-name production in RFC 2616
+   * @returns string
+   *   fieldName converted to lowercase if it is a valid header, for characters
+   *   where case conversion is possible
+   */
+  normalizeFieldName: function(fieldName)
+  {
+    if (fieldName == "")
+    {
+      dumpn("*** Empty fieldName");
+      throw Cr.NS_ERROR_INVALID_ARG;
+    }
+
+    for (var i = 0, sz = fieldName.length; i < sz; i++)
+    {
+      if (!IS_TOKEN_ARRAY[fieldName.charCodeAt(i)])
+      {
+        dumpn(fieldName + " is not a valid header field name!");
+        throw Cr.NS_ERROR_INVALID_ARG;
+      }
+    }
+
+    return fieldName.toLowerCase();
+  },
+
+  /**
+   * Ensures that fieldValue is a valid header field value (although not
+   * necessarily as specified in RFC 2616 if the corresponding field name is
+   * part of the HTTP protocol), normalizes the value if it is, and
+   * returns the normalized value.
+   *
+   * @param fieldValue : string
+   *   a value to be normalized as an HTTP header field value
+   * @throws NS_ERROR_INVALID_ARG
+   *   if fieldValue does not match the field-value production in RFC 2616
+   * @returns string
+   *   fieldValue as a normalized HTTP header field value
+   */
+  normalizeFieldValue: function(fieldValue)
+  {
+    // field-value    = *( field-content | LWS )
+    // field-content  = <the OCTETs making up the field-value
+    //                  and consisting of either *TEXT or combinations
+    //                  of token, separators, and quoted-string>
+    // TEXT           = <any OCTET except CTLs,
+    //                  but including LWS>
+    // LWS            = [CRLF] 1*( SP | HT )
+    //
+    // quoted-string  = ( <"> *(qdtext | quoted-pair ) <"> )
+    // qdtext         = <any TEXT except <">>
+    // quoted-pair    = "\" CHAR
+    // CHAR           = <any US-ASCII character (octets 0 - 127)>
+
+    // Any LWS that occurs between field-content MAY be replaced with a single
+    // SP before interpreting the field value or forwarding the message
+    // downstream (section 4.2); we replace 1*LWS with a single SP
+    var val = fieldValue.replace(/(?:(?:\r\n)?[ \t]+)+/g, " ");
+
+    // remove leading/trailing LWS (which has been converted to SP)
+    val = val.replace(/^ +/, "").replace(/ +$/, "");
+
+    // that should have taken care of all CTLs, so val should contain no CTLs
+    dumpn("*** Normalized value: '" + val + "'");
+    for (var i = 0, len = val.length; i < len; i++)
+      if (isCTL(val.charCodeAt(i)))
+      {
+        dump("*** Char " + i + " has charcode " + val.charCodeAt(i));
+        throw Cr.NS_ERROR_INVALID_ARG;
+      }
+
+    // XXX disallows quoted-pair where CHAR is a CTL -- will not invalidly
+    //     normalize, however, so this can be construed as a tightening of the
+    //     spec and not entirely as a bug
+    return val;
+  }
+};
+
+
+
+/**
+ * Converts the given string into a string which is safe for use in an HTML
+ * context.
+ *
+ * @param str : string
+ *   the string to make HTML-safe
+ * @returns string
+ *   an HTML-safe version of str
+ */
+function htmlEscape(str)
+{
+  // this is naive, but it'll work
+  var s = "";
+  for (var i = 0; i < str.length; i++)
+    s += "&#" + str.charCodeAt(i) + ";";
+  return s;
+}
+
+
+/**
+ * Constructs an object representing an HTTP version (see section 3.1).
+ *
+ * @param versionString
+ *   a string of the form "#.#", where # is an non-negative decimal integer with
+ *   or without leading zeros
+ * @throws
+ *   if versionString does not specify a valid HTTP version number
+ */
+function nsHttpVersion(versionString)
+{
+  var matches = /^(\d+)\.(\d+)$/.exec(versionString);
+  if (!matches)
+    throw "Not a valid HTTP version!";
+
+  /** The major version number of this, as a number. */
+  this.major = parseInt(matches[1], 10);
+
+  /** The minor version number of this, as a number. */
+  this.minor = parseInt(matches[2], 10);
+
+  if (isNaN(this.major) || isNaN(this.minor) ||
+      this.major < 0    || this.minor < 0)
+    throw "Not a valid HTTP version!";
+}
+nsHttpVersion.prototype =
+{
+  /**
+   * Returns the standard string representation of the HTTP version represented
+   * by this (e.g., "1.1").
+   */
+  toString: function ()
+  {
+    return this.major + "." + this.minor;
+  },
+
+  /**
+   * Returns true if this represents the same HTTP version as otherVersion,
+   * false otherwise.
+   *
+   * @param otherVersion : nsHttpVersion
+   *   the version to compare against this
+   */
+  equals: function (otherVersion)
+  {
+    return this.major == otherVersion.major &&
+           this.minor == otherVersion.minor;
+  },
+
+  /** True if this >= otherVersion, false otherwise. */
+  atLeast: function(otherVersion)
+  {
+    return this.major > otherVersion.major ||
+           (this.major == otherVersion.major &&
+            this.minor >= otherVersion.minor);
+  }
+};
+
+nsHttpVersion.HTTP_1_0 = new nsHttpVersion("1.0");
+nsHttpVersion.HTTP_1_1 = new nsHttpVersion("1.1");
+
+
+/**
+ * An object which stores HTTP headers for a request or response.
+ *
+ * Note that since headers are case-insensitive, this object converts headers to
+ * lowercase before storing them.  This allows the getHeader and hasHeader
+ * methods to work correctly for any case of a header, but it means that the
+ * values returned by .enumerator may not be equal case-sensitively to the
+ * values passed to setHeader when adding headers to this.
+ */
+function nsHttpHeaders()
+{
+  /**
+   * A hash of headers, with header field names as the keys and header field
+   * values as the values.  Header field names are case-insensitive, but upon
+   * insertion here they are converted to lowercase.  Header field values are
+   * normalized upon insertion to contain no leading or trailing whitespace.
+   *
+   * Note also that per RFC 2616, section 4.2, two headers with the same name in
+   * a message may be treated as one header with the same field name and a field
+   * value consisting of the separate field values joined together with a "," in
+   * their original order.  This hash stores multiple headers with the same name
+   * in this manner.
+   */
+  this._headers = {};
+}
+nsHttpHeaders.prototype =
+{
+  /**
+   * Sets the header represented by name and value in this.
+   *
+   * @param name : string
+   *   the header name
+   * @param value : string
+   *   the header value
+   * @throws NS_ERROR_INVALID_ARG
+   *   if name or value is not a valid header component
+   */
+  setHeader: function(fieldName, fieldValue, merge)
+  {
+    var name = headerUtils.normalizeFieldName(fieldName);
+    var value = headerUtils.normalizeFieldValue(fieldValue);
+
+    // The following three headers are stored as arrays because their real-world
+    // syntax prevents joining individual headers into a single header using 
+    // ",".  See also <http://hg.mozilla.org/mozilla-central/diff/9b2a99adc05e/netwerk/protocol/http/src/nsHttpHeaderArray.cpp#l77>
+    if (merge && name in this._headers)
+    {
+      if (name === "www-authenticate" ||
+          name === "proxy-authenticate" ||
+          name === "set-cookie") 
+      {
+        this._headers[name].push(value);
+      }
+      else 
+      {
+        this._headers[name][0] += "," + value;
+        NS_ASSERT(this._headers[name].length === 1,
+            "how'd a non-special header have multiple values?")
+      }
+    }
+    else
+    {
+      this._headers[name] = [value];
+    }
+  },
+
+  /**
+   * Returns the value for the header specified by this.
+   *
+   * @throws NS_ERROR_INVALID_ARG
+   *   if fieldName does not constitute a valid header field name
+   * @throws NS_ERROR_NOT_AVAILABLE
+   *   if the given header does not exist in this
+   * @returns string
+   *   the field value for the given header, possibly with non-semantic changes
+   *   (i.e., leading/trailing whitespace stripped, whitespace runs replaced
+   *   with spaces, etc.) at the option of the implementation; multiple 
+   *   instances of the header will be combined with a comma, except for 
+   *   the three headers noted in the description of getHeaderValues
+   */
+  getHeader: function(fieldName)
+  {
+    return this.getHeaderValues(fieldName).join("\n");
+  },
+
+  /**
+   * Returns the value for the header specified by fieldName as an array.
+   *
+   * @throws NS_ERROR_INVALID_ARG
+   *   if fieldName does not constitute a valid header field name
+   * @throws NS_ERROR_NOT_AVAILABLE
+   *   if the given header does not exist in this
+   * @returns [string]
+   *   an array of all the header values in this for the given
+   *   header name.  Header values will generally be collapsed
+   *   into a single header by joining all header values together
+   *   with commas, but certain headers (Proxy-Authenticate,
+   *   WWW-Authenticate, and Set-Cookie) violate the HTTP spec
+   *   and cannot be collapsed in this manner.  For these headers
+   *   only, the returned array may contain multiple elements if
+   *   that header has been added more than once.
+   */
+  getHeaderValues: function(fieldName)
+  {
+    var name = headerUtils.normalizeFieldName(fieldName);
+
+    if (name in this._headers)
+      return this._headers[name];
+    else
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+  },
+
+  /**
+   * Returns true if a header with the given field name exists in this, false
+   * otherwise.
+   *
+   * @param fieldName : string
+   *   the field name whose existence is to be determined in this
+   * @throws NS_ERROR_INVALID_ARG
+   *   if fieldName does not constitute a valid header field name
+   * @returns boolean
+   *   true if the header's present, false otherwise
+   */
+  hasHeader: function(fieldName)
+  {
+    var name = headerUtils.normalizeFieldName(fieldName);
+    return (name in this._headers);
+  },
+
+  /**
+   * Returns a new enumerator over the field names of the headers in this, as
+   * nsISupportsStrings.  The names returned will be in lowercase, regardless of
+   * how they were input using setHeader (header names are case-insensitive per
+   * RFC 2616).
+   */
+  get enumerator()
+  {
+    var headers = [];
+    for (var i in this._headers)
+    {
+      var supports = new SupportsString();
+      supports.data = i;
+      headers.push(supports);
+    }
+
+    return new nsSimpleEnumerator(headers);
+  }
+};
+
+
+/**
+ * Constructs an nsISimpleEnumerator for the given array of items.
+ *
+ * @param items : Array
+ *   the items, which must all implement nsISupports
+ */
+function nsSimpleEnumerator(items)
+{
+  this._items = items;
+  this._nextIndex = 0;
+}
+nsSimpleEnumerator.prototype =
+{
+  hasMoreElements: function()
+  {
+    return this._nextIndex < this._items.length;
+  },
+  getNext: function()
+  {
+    if (!this.hasMoreElements())
+      throw Cr.NS_ERROR_NOT_AVAILABLE;
+
+    return this._items[this._nextIndex++];
+  },
+  QueryInterface: function(aIID)
+  {
+    if (Ci.nsISimpleEnumerator.equals(aIID) ||
+        Ci.nsISupports.equals(aIID))
+      return this;
+
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  }
+};
+
+
+/**
+ * A representation of the data in an HTTP request.
+ *
+ * @param port : uint
+ *   the port on which the server receiving this request runs
+ */
+function Request(port)
+{
+  /** Method of this request, e.g. GET or POST. */
+  this._method = "";
+
+  /** Path of the requested resource; empty paths are converted to '/'. */
+  this._path = "";
+
+  /** Query string, if any, associated with this request (not including '?'). */
+  this._queryString = "";
+
+  /** Scheme of requested resource, usually http, always lowercase. */
+  this._scheme = "http";
+
+  /** Hostname on which the requested resource resides. */
+  this._host = undefined;
+
+  /** Port number over which the request was received. */
+  this._port = port;
+
+  var bodyPipe = new Pipe(false, false, 0, PR_UINT32_MAX, null);
+
+  /** Stream from which data in this request's body may be read. */
+  this._bodyInputStream = bodyPipe.inputStream;
+
+  /** Stream to which data in this request's body is written. */
+  this._bodyOutputStream = bodyPipe.outputStream;
+
+  /**
+   * The headers in this request.
+   */
+  this._headers = new nsHttpHeaders();
+
+  /**
+   * For the addition of ad-hoc properties and new functionality without having
+   * to change nsIHttpRequest every time; currently lazily created, as its only
+   * use is in directory listings.
+   */
+  this._bag = null;
+}
+Request.prototype =
+{
+  // SERVER METADATA
+
+  //
+  // see nsIHttpRequest.scheme
+  //
+  get scheme()
+  {
+    return this._scheme;
+  },
+
+  //
+  // see nsIHttpRequest.host
+  //
+  get host()
+  {
+    return this._host;
+  },
+
+  //
+  // see nsIHttpRequest.port
+  //
+  get port()
+  {
+    return this._port;
+  },
+
+  // REQUEST LINE
+
+  //
+  // see nsIHttpRequest.method
+  //
+  get method()
+  {
+    return this._method;
+  },
+
+  //
+  // see nsIHttpRequest.httpVersion
+  //
+  get httpVersion()
+  {
+    return this._httpVersion.toString();
+  },
+
+  //
+  // see nsIHttpRequest.path
+  //
+  get path()
+  {
+    return this._path;
+  },
+
+  //
+  // see nsIHttpRequest.queryString
+  //
+  get queryString()
+  {
+    return this._queryString;
+  },
+
+  // HEADERS
+
+  //
+  // see nsIHttpRequest.getHeader
+  //
+  getHeader: function(name)
+  {
+    return this._headers.getHeader(name);
+  },
+
+  //
+  // see nsIHttpRequest.hasHeader
+  //
+  hasHeader: function(name)
+  {
+    return this._headers.hasHeader(name);
+  },
+
+  //
+  // see nsIHttpRequest.headers
+  //
+  get headers()
+  {
+    return this._headers.enumerator;
+  },
+
+  //
+  // see nsIPropertyBag.enumerator
+  //
+  get enumerator()
+  {
+    this._ensurePropertyBag();
+    return this._bag.enumerator;
+  },
+
+  //
+  // see nsIHttpRequest.headers
+  //
+  get bodyInputStream()
+  {
+    return this._bodyInputStream;
+  },
+
+  //
+  // see nsIPropertyBag.getProperty
+  //
+  getProperty: function(name) 
+  {
+    this._ensurePropertyBag();
+    return this._bag.getProperty(name);
+  },
+
+
+  // NSISUPPORTS
+
+  //
+  // see nsISupports.QueryInterface
+  //
+  QueryInterface: function(iid)
+  {
+    if (iid.equals(Ci.nsIHttpRequest) || iid.equals(Ci.nsISupports))
+      return this;
+
+    throw Cr.NS_ERROR_NO_INTERFACE;
+  },
+
+
+  // PRIVATE IMPLEMENTATION
+  
+  /** Ensures a property bag has been created for ad-hoc behaviors. */
+  _ensurePropertyBag: function()
+  {
+    if (!this._bag)
+      this._bag = new WritablePropertyBag();
+  }
+};
+
+
+// XPCOM trappings
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([nsHttpServer]);
+
+/**
+ * Creates a new HTTP server listening for loopback traffic on the given port,
+ * starts it, and runs the server until the server processes a shutdown request,
+ * spinning an event loop so that events posted by the server's socket are
+ * processed.
+ *
+ * This method is primarily intended for use in running this script from within
+ * xpcshell and running a functional HTTP server without having to deal with
+ * non-essential details.
+ *
+ * Note that running multiple servers using variants of this method probably
+ * doesn't work, simply due to how the internal event loop is spun and stopped.
+ *
+ * @note
+ *   This method only works with Mozilla 1.9 (i.e., Firefox 3 or trunk code);
+ *   you should use this server as a component in Mozilla 1.8.
+ * @param port
+ *   the port on which the server will run, or -1 if there exists no preference
+ *   for a specific port; note that attempting to use some values for this
+ *   parameter (particularly those below 1024) may cause this method to throw or
+ *   may result in the server being prematurely shut down
+ * @param basePath
+ *   a local directory from which requests will be served (i.e., if this is
+ *   "/home/jwalden/" then a request to /index.html will load
+ *   /home/jwalden/index.html); if this is omitted, only the default URLs in
+ *   this server implementation will be functional
+ */
+function server(port, basePath)
+{
+  if (basePath)
+  {
+    var lp = Cc["@mozilla.org/file/local;1"]
+               .createInstance(Ci.nsILocalFile);
+    lp.initWithPath(basePath);
+  }
+
+  // if you're running this, you probably want to see debugging info
+  DEBUG = true;
+
+  var srv = new nsHttpServer();
+  if (lp)
+    srv.registerDirectory("/", lp);
+  srv.registerContentType("sjs", SJS_TYPE);
+  srv.identity.setPrimary("http", "localhost", port);
+  srv.start(port);
+
+  var thread = gThreadManager.currentThread;
+  while (!srv.isStopped())
+    thread.processNextEvent(true);
+
+  // get rid of any pending requests
+  while (thread.hasPendingEvents())
+    thread.processNextEvent(true);
+
+  DEBUG = false;
+}
diff --git a/test/tests/data/snapshot/img.gif b/test/tests/data/snapshot/img.gif
new file mode 100644
index 000000000..f191b280c
Binary files /dev/null and b/test/tests/data/snapshot/img.gif differ
diff --git a/test/tests/data/test.html b/test/tests/data/test.html
new file mode 100644
index 000000000..2835ff283
--- /dev/null
+++ b/test/tests/data/test.html
@@ -0,0 +1,8 @@
+<html>
+	<head>
+		<meta charset="utf-8"/>
+	</head>
+	<body>
+		<p>This is a test.</p>
+	</body>
+</html>
diff --git a/test/tests/data/test.txt b/test/tests/data/test.txt
new file mode 100644
index 000000000..6de7b8c69
--- /dev/null
+++ b/test/tests/data/test.txt
@@ -0,0 +1 @@
+This is a test file.
diff --git a/test/tests/itemTest.js b/test/tests/itemTest.js
index 486677187..0981e93a0 100644
--- a/test/tests/itemTest.js
+++ b/test/tests/itemTest.js
@@ -537,6 +537,10 @@ describe("Zotero.Item", function () {
 			file.append(filename);
 			assert.equal(item.getFilePath(), file.path);
 		});
+		
+		it.skip("should get and set a filename for a base-dir-relative file", function* () {
+			
+		})
 	})
 	
 	describe("#attachmentPath", function () {
@@ -608,11 +612,13 @@ describe("Zotero.Item", function () {
 			assert.equal(OS.Path.basename(path), newName)
 			yield OS.File.exists(path);
 			
+			// File should be flagged for upload
+			// DEBUG: Is this necessary?
 			assert.equal(
-				(yield Zotero.Sync.Storage.getSyncState(item.id)),
+				(yield Zotero.Sync.Storage.Local.getSyncState(item.id)),
 				Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD
 			);
-			assert.isNull(yield Zotero.Sync.Storage.getSyncedHash(item.id));
+			assert.isNull(yield Zotero.Sync.Storage.Local.getSyncedHash(item.id));
 		})
 	})
 	
diff --git a/test/tests/storageEngineTest.js b/test/tests/storageEngineTest.js
new file mode 100644
index 000000000..1efc1b476
--- /dev/null
+++ b/test/tests/storageEngineTest.js
@@ -0,0 +1,822 @@
+"use strict";
+
+describe("Zotero.Sync.Storage.Engine", function () {
+	Components.utils.import("resource://zotero-unit/httpd.js");
+	
+	var win;
+	var apiKey = Zotero.Utilities.randomString(24);
+	var port = 16213;
+	var baseURL = `http://localhost:${port}/`;
+	var server;
+	
+	var responses = {};
+	
+	var setup = Zotero.Promise.coroutine(function* (options = {}) {
+		server = sinon.fakeServer.create();
+		server.autoRespond = true;
+		
+		Components.utils.import("resource://zotero/concurrentCaller.js");
+		var caller = new ConcurrentCaller(1);
+		caller.setLogger(msg => Zotero.debug(msg));
+		caller.stopOnError = true;
+		
+		Components.utils.import("resource://zotero/config.js");
+		var client = new Zotero.Sync.APIClient({
+			baseURL,
+			apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
+			apiKey,
+			caller,
+			background: options.background || true
+		});
+		
+		var engine = new Zotero.Sync.Storage.Engine({
+			apiClient: client,
+			libraryID: options.libraryID || Zotero.Libraries.userLibraryID,
+			stopOnError: true
+		});
+		
+		return { engine, client, caller };
+	});
+	
+	function setResponse(response) {
+		setHTTPResponse(server, baseURL, response, responses);
+	}
+	
+	function parseQueryString(str) {
+		var queryStringParams = str.split('&');
+		var params = {};
+		for (let param of queryStringParams) {
+			let [ key, val ] = param.split('=');
+			params[key] = decodeURIComponent(val);
+		}
+		return params;
+	}
+	
+	function assertAPIKey(request) {
+		assert.equal(request.requestHeaders["Zotero-API-Key"], apiKey);
+	}
+	
+	//
+	// Tests
+	//
+	before(function* () {
+	})
+	beforeEach(function* () {
+		Zotero.debug("BEFORE HERE");
+		yield resetDB({
+			thisArg: this,
+			skipBundledFiles: true
+		});
+		Zotero.debug("DONE RESET");
+		win = yield loadZoteroPane();
+		
+		Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
+		
+		this.httpd = new HttpServer();
+		this.httpd.start(port);
+		
+		yield Zotero.Users.setCurrentUserID(1);
+		yield Zotero.Users.setCurrentUsername("testuser");
+		
+		// Set download-on-sync by default
+		Zotero.Sync.Storage.Local.downloadOnSync(
+			Zotero.Libraries.userLibraryID, true
+		);
+		Zotero.debug("DONE BEFORE");
+	})
+	afterEach(function* () {
+		var defer = new Zotero.Promise.defer();
+		this.httpd.stop(() => defer.resolve());
+		yield defer.promise;
+		win.close();
+	})
+	after(function* () {
+		this.timeout(60000);
+		//yield resetDB();
+		win.close();
+	})
+	
+	
+	describe("ZFS", function () {
+		describe("Syncing", function () {
+			it("should skip downloads if no last storage sync time", function* () {
+				var { engine, client, caller } = yield setup();
+				
+				setResponse({
+					method: "GET",
+					url: "users/1/laststoragesync",
+					status: 404
+				});
+				var result = yield engine.start();
+				
+				assert.isFalse(result.localChanges);
+				assert.isFalse(result.remoteChanges);
+				assert.isFalse(result.syncRequired);
+				
+				// Check last sync time
+				assert.isFalse(Zotero.Libraries.userLibrary.lastStorageSync);
+			})
+			
+			it("should skip downloads if unchanged last storage sync time", function* () {
+				var { engine, client, caller } = yield setup();
+				
+				var newStorageSyncTime = Math.round(new Date().getTime() / 1000);
+				var library = Zotero.Libraries.userLibrary;
+				library.lastStorageSync = newStorageSyncTime;
+				yield library.saveTx();
+				setResponse({
+					method: "GET",
+					url: "users/1/laststoragesync",
+					status: 200,
+					text: "" + newStorageSyncTime
+				});
+				var result = yield engine.start();
+				
+				assert.isFalse(result.localChanges);
+				assert.isFalse(result.remoteChanges);
+				assert.isFalse(result.syncRequired);
+				
+				// Check last sync time
+				assert.equal(library.lastStorageSync, newStorageSyncTime);
+			})
+			
+			it("should ignore a remotely missing file", function* () {
+				var { engine, client, caller } = yield setup();
+				
+				var item = new Zotero.Item("attachment");
+				item.attachmentLinkMode = 'imported_file';
+				item.attachmentPath = 'storage:test.txt';
+				yield item.saveTx();
+				yield Zotero.Sync.Storage.Local.setSyncState(
+					item.id, Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD
+				);
+				
+				var newStorageSyncTime = Math.round(new Date().getTime() / 1000);
+				setResponse({
+					method: "GET",
+					url: "users/1/laststoragesync",
+					status: 200,
+					text: "" + newStorageSyncTime
+				});
+				this.httpd.registerPathHandler(
+					`/users/1/items/${item.key}/file`,
+					{
+						handle: function (request, response) {
+							response.setStatusLine(null, 404, null);
+						}
+					}
+				);
+				var result = yield engine.start();
+				
+				assert.isFalse(result.localChanges);
+				assert.isFalse(result.remoteChanges);
+				assert.isFalse(result.syncRequired);
+				
+				// Check last sync time
+				assert.equal(Zotero.Libraries.userLibrary.lastStorageSync, newStorageSyncTime);
+			})
+			
+			it("should handle a remotely failing file", function* () {
+				var { engine, client, caller } = yield setup();
+				
+				var item = new Zotero.Item("attachment");
+				item.attachmentLinkMode = 'imported_file';
+				item.attachmentPath = 'storage:test.txt';
+				yield item.saveTx();
+				yield Zotero.Sync.Storage.Local.setSyncState(
+					item.id, Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD
+				);
+				
+				var newStorageSyncTime = Math.round(new Date().getTime() / 1000);
+				setResponse({
+					method: "GET",
+					url: "users/1/laststoragesync",
+					status: 200,
+					text: "" + newStorageSyncTime
+				});
+				this.httpd.registerPathHandler(
+					`/users/1/items/${item.key}/file`,
+					{
+						handle: function (request, response) {
+							response.setStatusLine(null, 500, null);
+						}
+					}
+				);
+				// TODO: In stopOnError mode, this the promise is rejected.
+				// This should probably test with stopOnError mode turned off instead.
+				var e = yield getPromiseError(engine.start());
+				assert.equal(e.message, Zotero.Sync.Storage.defaultError);
+			})
+			
+			it("should download a missing file", function* () {
+				var { engine, client, caller } = yield setup();
+				
+				var item = new Zotero.Item("attachment");
+				item.attachmentLinkMode = 'imported_file';
+				item.attachmentPath = 'storage:test.txt';
+				// TODO: Test binary data
+				var text = Zotero.Utilities.randomString();
+				yield item.saveTx();
+				yield Zotero.Sync.Storage.Local.setSyncState(
+					item.id, Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD
+				);
+				
+				var mtime = "1441252524905";
+				var md5 = Zotero.Utilities.Internal.md5(text)
+				
+				var newStorageSyncTime = Math.round(new Date().getTime() / 1000);
+				setResponse({
+					method: "GET",
+					url: "users/1/laststoragesync",
+					status: 200,
+					text: "" + newStorageSyncTime
+				});
+				var s3Path = `pretend-s3/${item.key}`;
+				this.httpd.registerPathHandler(
+					`/users/1/items/${item.key}/file`,
+					{
+						handle: function (request, response) {
+							if (!request.hasHeader('Zotero-API-Key')) {
+								response.setStatusLine(null, 403, "Forbidden");
+								return;
+							}
+							var key = request.getHeader('Zotero-API-Key');
+							if (key != apiKey) {
+								response.setStatusLine(null, 403, "Invalid key");
+								return;
+							}
+							response.setStatusLine(null, 302, "Found");
+							response.setHeader("Zotero-File-Modification-Time", mtime, false);
+							response.setHeader("Zotero-File-MD5", md5, false);
+							response.setHeader("Zotero-File-Compressed", "No", false);
+							response.setHeader("Location", baseURL + s3Path, false);
+						}
+					}
+				);
+				this.httpd.registerPathHandler(
+					"/" + s3Path,
+					{
+						handle: function (request, response) {
+							response.setStatusLine(null, 200, "OK");
+							response.write(text);
+						}
+					}
+				);
+				var result = yield engine.start();
+				
+				assert.isTrue(result.localChanges);
+				assert.isFalse(result.remoteChanges);
+				assert.isFalse(result.syncRequired);
+				
+				// Check last sync time
+				assert.equal(Zotero.Libraries.userLibrary.lastStorageSync, newStorageSyncTime);
+				var contents = yield Zotero.File.getContentsAsync(yield item.getFilePathAsync());
+				assert.equal(contents, text);
+			})
+			
+			it("should upload new files", function* () {
+				var { engine, client, caller } = yield setup();
+				
+				// Single file
+				var file1 = getTestDataDirectory();
+				file1.append('test.png');
+				var item1 = yield Zotero.Attachments.importFromFile({ file: file1 });
+				var mtime1 = yield item1.attachmentModificationTime;
+				var hash1 = yield item1.attachmentHash;
+				var path1 = item1.getFilePath();
+				var filename1 = 'test.png';
+				var size1 = (yield OS.File.stat(path1)).size;
+				var contentType1 = 'image/png';
+				var prefix1 = Zotero.Utilities.randomString();
+				var suffix1 = Zotero.Utilities.randomString();
+				var uploadKey1 = Zotero.Utilities.randomString(32, 'abcdef0123456789');
+				
+				// HTML file with auxiliary image
+				var file2 = OS.Path.join(getTestDataDirectory().path, 'snapshot', 'index.html');
+				var parentItem = yield createDataObject('item');
+				var item2 = yield Zotero.Attachments.importSnapshotFromFile({
+					file: file2,
+					url: 'http://example.com/',
+					parentItemID: parentItem.id,
+					title: 'Test',
+					contentType: 'text/html',
+					charset: 'utf-8'
+				});
+				var mtime2 = yield item2.attachmentModificationTime;
+				var hash2 = yield item2.attachmentHash;
+				var path2 = item2.getFilePath();
+				var filename2 = 'index.html';
+				var size2 = (yield OS.File.stat(path2)).size;
+				var contentType2 = 'text/html';
+				var charset2 = 'utf-8';
+				var prefix2 = Zotero.Utilities.randomString();
+				var suffix2 = Zotero.Utilities.randomString();
+				var uploadKey2 = Zotero.Utilities.randomString(32, 'abcdef0123456789');
+				
+				var deferreds = [];
+				
+				setResponse({
+					method: "GET",
+					url: "users/1/laststoragesync",
+					status: 404
+				});
+				// https://github.com/cjohansen/Sinon.JS/issues/607
+				let fixSinonBug = ";charset=utf-8";
+				server.respond(function (req) {
+					// Get upload authorization for single file
+					if (req.method == "POST"
+							&& req.url == `${baseURL}users/1/items/${item1.key}/file`
+							&& req.requestBody.indexOf('upload=') == -1) {
+						assertAPIKey(req);
+						assert.equal(req.requestHeaders["If-None-Match"], "*");
+						assert.equal(
+							req.requestHeaders["Content-Type"],
+							"application/x-www-form-urlencoded" + fixSinonBug
+						);
+						
+						let parts = req.requestBody.split('&');
+						let params = {};
+						for (let part of parts) {
+							let [key, val] = part.split('=');
+							params[key] = decodeURIComponent(val);
+						}
+						assert.equal(params.md5, hash1);
+						assert.equal(params.mtime, mtime1);
+						assert.equal(params.filename, filename1);
+						assert.equal(params.filesize, size1);
+						assert.equal(params.contentType, contentType1);
+						
+						req.respond(
+							200,
+							{
+								"Content-Type": "application/json"
+							},
+							JSON.stringify({
+								url: baseURL + "pretend-s3/1",
+								contentType: contentType1,
+								prefix: prefix1,
+								suffix: suffix1,
+								uploadKey: uploadKey1
+							})
+						);
+					}
+					// Get upload authorization for multi-file zip
+					else if (req.method == "POST"
+							&& req.url == `${baseURL}users/1/items/${item2.key}/file`
+							&& req.requestBody.indexOf('upload=') == -1) {
+						assertAPIKey(req);
+						assert.equal(req.requestHeaders["If-None-Match"], "*");
+						assert.equal(
+							req.requestHeaders["Content-Type"],
+							"application/x-www-form-urlencoded" + fixSinonBug
+						);
+						
+						// Verify ZIP hash
+						let tmpZipPath = OS.Path.join(
+							Zotero.getTempDirectory().path,
+							item2.key + '.zip'
+						);
+						deferreds.push({
+							promise: Zotero.Utilities.Internal.md5Async(tmpZipPath)
+								.then(function (md5) {
+									assert.equal(params.zipMD5, md5);
+								})
+						});
+						
+						let parts = req.requestBody.split('&');
+						let params = {};
+						for (let part of parts) {
+							let [key, val] = part.split('=');
+							params[key] = decodeURIComponent(val);
+						}
+						Zotero.debug(params);
+						assert.equal(params.md5, hash2);
+						assert.notEqual(params.zipMD5, hash2);
+						assert.equal(params.mtime, mtime2);
+						assert.equal(params.filename, filename2);
+						assert.equal(params.zipFilename, item2.key + ".zip");
+						assert.isTrue(parseInt(params.filesize) == params.filesize);
+						assert.equal(params.contentType, contentType2);
+						assert.equal(params.charset, charset2);
+						
+						req.respond(
+							200,
+							{
+								"Content-Type": "application/json"
+							},
+							JSON.stringify({
+								url: baseURL + "pretend-s3/2",
+								contentType: 'application/zip',
+								prefix: prefix2,
+								suffix: suffix2,
+								uploadKey: uploadKey2
+							})
+						);
+					}
+					// Upload single file to S3
+					else if (req.method == "POST" && req.url == baseURL + "pretend-s3/1") {
+						assert.equal(req.requestHeaders["Content-Type"], contentType1 + fixSinonBug);
+						assert.equal(req.requestBody.size, (new Blob([prefix1, File(file1), suffix1]).size));
+						req.respond(201, {}, "");
+					}
+					// Upload multi-file ZIP to S3
+					else if (req.method == "POST" && req.url == baseURL + "pretend-s3/2") {
+						assert.equal(req.requestHeaders["Content-Type"], "application/zip" + fixSinonBug);
+						
+						// Verify uploaded ZIP file
+						let tmpZipPath = OS.Path.join(
+							Zotero.getTempDirectory().path,
+							Zotero.Utilities.randomString() + '.zip'
+						);
+						
+						let deferred = Zotero.Promise.defer();
+						deferreds.push(deferred);
+						var reader = new FileReader();
+						reader.addEventListener("loadend", Zotero.Promise.coroutine(function* () {
+							try {
+								
+								let file = yield OS.File.open(tmpZipPath, {
+									create: true
+								});
+								
+								var contents = new Uint8Array(reader.result);
+								contents = contents.slice(prefix2.length, suffix2.length * -1);
+								yield file.write(contents);
+								yield file.close();
+								
+								var zr = Components.classes["@mozilla.org/libjar/zip-reader;1"]
+									.createInstance(Components.interfaces.nsIZipReader);
+								zr.open(Zotero.File.pathToFile(tmpZipPath));
+								zr.test(null);
+								var entries = zr.findEntries('*');
+								var entryNames = [];
+								while (entries.hasMore()) {
+									entryNames.push(entries.getNext());
+								}
+								assert.equal(entryNames.length, 2);
+								assert.sameMembers(entryNames, ['index.html', 'img.gif']);
+								assert.equal(zr.getEntry('index.html').realSize, size2);
+								assert.equal(zr.getEntry('img.gif').realSize, 42);
+								
+								deferred.resolve();
+							}
+							catch (e) {
+								deferred.reject(e);
+							}
+						}));
+						reader.readAsArrayBuffer(req.requestBody);
+						
+						req.respond(201, {}, "");
+					}
+					// Register single-file upload
+					else if (req.method == "POST"
+							&& req.url == `${baseURL}users/1/items/${item1.key}/file`
+							&& req.requestBody.indexOf('upload=') != -1) {
+						assertAPIKey(req);
+						assert.equal(req.requestHeaders["If-None-Match"], "*");
+						assert.equal(
+							req.requestHeaders["Content-Type"],
+							"application/x-www-form-urlencoded" + fixSinonBug
+						);
+						
+						let parts = req.requestBody.split('&');
+						let params = {};
+						for (let part of parts) {
+							let [key, val] = part.split('=');
+							params[key] = decodeURIComponent(val);
+						}
+						assert.equal(params.upload, uploadKey1);
+						
+						req.respond(
+							204,
+							{
+								"Last-Modified-Version": 10
+							},
+							""
+						);
+					}
+					// Register multi-file upload
+					else if (req.method == "POST"
+							&& req.url == `${baseURL}users/1/items/${item2.key}/file`
+							&& req.requestBody.indexOf('upload=') != -1) {
+						assertAPIKey(req);
+						assert.equal(req.requestHeaders["If-None-Match"], "*");
+						assert.equal(
+							req.requestHeaders["Content-Type"],
+							"application/x-www-form-urlencoded" + fixSinonBug
+						);
+						
+						let parts = req.requestBody.split('&');
+						let params = {};
+						for (let part of parts) {
+							let [key, val] = part.split('=');
+							params[key] = decodeURIComponent(val);
+						}
+						assert.equal(params.upload, uploadKey2);
+						
+						req.respond(
+							204,
+							{
+								"Last-Modified-Version": 15
+							},
+							""
+						);
+					}
+				})
+				var newStorageSyncTime = Math.round(new Date().getTime() / 1000);
+				setResponse({
+					method: "POST",
+					url: "users/1/laststoragesync",
+					status: 200,
+					text: "" + newStorageSyncTime
+				});
+				
+				// TODO: One-step uploads
+				/*// https://github.com/cjohansen/Sinon.JS/issues/607
+				let fixSinonBug = ";charset=utf-8";
+				server.respond(function (req) {
+					if (req.method == "POST" && req.url == `${baseURL}users/1/items/${item.key}/file`) {
+						assert.equal(req.requestHeaders["If-None-Match"], "*");
+						assert.equal(
+							req.requestHeaders["Content-Type"],
+							"application/json" + fixSinonBug
+						);
+						
+						let params = JSON.parse(req.requestBody);
+						assert.equal(params.md5, hash);
+						assert.equal(params.mtime, mtime);
+						assert.equal(params.filename, filename);
+						assert.equal(params.size, size);
+						assert.equal(params.contentType, contentType);
+						
+						req.respond(
+							200,
+							{
+								"Content-Type": "application/json"
+							},
+							JSON.stringify({
+								url: baseURL + "pretend-s3",
+								headers: {
+									"Content-Type": contentType,
+									"Content-MD5": hash,
+									//"Content-Length": params.size, process but don't return
+									//"x-amz-meta-"
+								},
+								uploadKey
+							})
+						);
+					}
+					else if (req.method == "PUT" && req.url == baseURL + "pretend-s3") {
+						assert.equal(req.requestHeaders["Content-Type"], contentType + fixSinonBug);
+						assert.instanceOf(req.requestBody, File);
+						req.respond(201, {}, "");
+					}
+				})*/
+				var result = yield engine.start();
+				
+				yield Zotero.Promise.all(deferreds.map(d => d.promise));
+				
+				assert.isTrue(result.localChanges);
+				assert.isTrue(result.remoteChanges);
+				assert.isFalse(result.syncRequired);
+				
+				// Check local objects
+				assert.equal((yield Zotero.Sync.Storage.Local.getSyncedModificationTime(item1.id)), mtime1);
+				assert.equal((yield Zotero.Sync.Storage.Local.getSyncedHash(item1.id)), hash1);
+				assert.equal(item1.version, 10);
+				assert.equal((yield Zotero.Sync.Storage.Local.getSyncedModificationTime(item2.id)), mtime2);
+				assert.equal((yield Zotero.Sync.Storage.Local.getSyncedHash(item2.id)), hash2);
+				assert.equal(item2.version, 15);
+				
+				// Check last sync time
+				assert.equal(Zotero.Libraries.userLibrary.lastStorageSync, newStorageSyncTime);
+			})
+			
+			it("should update local info for file that already exists on the server", function* () {
+				var { engine, client, caller } = yield setup();
+				
+				var file = getTestDataDirectory();
+				file.append('test.png');
+				var item = yield Zotero.Attachments.importFromFile({ file: file });
+				item.version = 5;
+				yield item.saveTx();
+				var json = yield item.toJSON();
+				yield Zotero.Sync.Data.Local.saveCacheObject('item', item.libraryID, json);
+				
+				var mtime = yield item.attachmentModificationTime;
+				var hash = yield item.attachmentHash;
+				var path = item.getFilePath();
+				var filename = 'test.png';
+				var size = (yield OS.File.stat(path)).size;
+				var contentType = 'image/png';
+				
+				var newVersion = 10;
+				setResponse({
+					method: "POST",
+					url: "users/1/laststoragesync",
+					status: 200,
+					text: "" + (Math.round(new Date().getTime() / 1000) - 50000)
+				});
+				// https://github.com/cjohansen/Sinon.JS/issues/607
+				let fixSinonBug = ";charset=utf-8";
+				server.respond(function (req) {
+					// Get upload authorization for single file
+					if (req.method == "POST"
+							&& req.url == `${baseURL}users/1/items/${item.key}/file`
+							&& req.requestBody.indexOf('upload=') == -1) {
+						assertAPIKey(req);
+						assert.equal(req.requestHeaders["If-None-Match"], "*");
+						assert.equal(
+							req.requestHeaders["Content-Type"],
+							"application/x-www-form-urlencoded" + fixSinonBug
+						);
+						
+						req.respond(
+							200,
+							{
+								"Content-Type": "application/json",
+								"Last-Modified-Version": newVersion
+							},
+							JSON.stringify({
+								exists: 1,
+							})
+						);
+					}
+				})
+				var newStorageSyncTime = Math.round(new Date().getTime() / 1000);
+				setResponse({
+					method: "POST",
+					url: "users/1/laststoragesync",
+					status: 200,
+					text: "" + newStorageSyncTime
+				});
+				
+				// TODO: One-step uploads
+				var result = yield engine.start();
+				
+				assert.isTrue(result.localChanges);
+				assert.isTrue(result.remoteChanges);
+				assert.isFalse(result.syncRequired);
+				
+				// Check local objects
+				assert.equal((yield Zotero.Sync.Storage.Local.getSyncedModificationTime(item.id)), mtime);
+				assert.equal((yield Zotero.Sync.Storage.Local.getSyncedHash(item.id)), hash);
+				assert.equal(item.version, newVersion);
+				
+				// Check last sync time
+				assert.equal(Zotero.Libraries.userLibrary.lastStorageSync, newStorageSyncTime);
+			})
+		})
+		
+		describe("#_processUploadFile()", function () {
+			it("should handle 412 with matching version and hash matching local file", function* () {
+				var { engine, client, caller } = yield setup();
+				var zfs = new Zotero.Sync.Storage.ZFS_Module({
+					apiClient: client
+				})
+				
+				var filePath = OS.Path.join(getTestDataDirectory().path, 'test.png');
+				var item = yield Zotero.Attachments.importFromFile({ file: filePath });
+				item.version = 5;
+				item.synced = true;
+				yield item.saveTx();
+				
+				var itemJSON = yield item.toResponseJSON();
+				
+				// Set saved hash to a different value, which should be overwritten
+				//
+				// We're also testing cases where a hash isn't set for a file (e.g., if the
+				// storage directory was transferred, the mtime doesn't match, but the file was
+				// never downloaded), but there's no difference in behavior
+				var dbHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+				yield Zotero.DB.executeTransaction(function* () {
+					yield Zotero.Sync.Storage.Local.setSyncedHash(item.id, dbHash)
+				});
+				
+				server.respond(function (req) {
+					if (req.method == "POST"
+							&& req.url == `${baseURL}users/1/items/${item.key}/file`
+							&& req.requestBody.indexOf('upload=') == -1
+							&& req.requestHeaders["If-Match"] == dbHash) {
+						req.respond(
+							412,
+							{
+								"Content-Type": "application/json",
+								"Last-Modified-Version": 5
+							},
+							"ETag does not match current version of file"
+						);
+					}
+				})
+				setResponse({
+					method: "GET",
+					url: `users/1/items?format=json&itemKey=${item.key}&includeTrashed=1`,
+					status: 200,
+					text: JSON.stringify([itemJSON])
+				});
+				
+				var result = yield zfs._processUploadFile({
+					name: item.libraryKey
+				});
+				yield assert.eventually.equal(
+					Zotero.Sync.Storage.Local.getSyncedHash(item.id), itemJSON.data.md5
+				);
+				assert.isFalse(result.localChanges);
+				assert.isFalse(result.remoteChanges);
+				assert.isFalse(result.syncRequired);
+				assert.isFalse(result.fileSyncRequired);
+			})
+			
+			it("should handle 412 with matching version and hash not matching local file", function* () {
+				var { engine, client, caller } = yield setup();
+				var zfs = new Zotero.Sync.Storage.ZFS_Module({
+					apiClient: client
+				})
+				
+				var filePath = OS.Path.join(getTestDataDirectory().path, 'test.png');
+				var item = yield Zotero.Attachments.importFromFile({ file: filePath });
+				item.version = 5;
+				item.synced = true;
+				yield item.saveTx();
+				
+				var fileHash = yield item.attachmentHash;
+				var itemJSON = yield item.toResponseJSON();
+				itemJSON.data.md5 = 'aaaaaaaaaaaaaaaaaaaaaaaa'
+				
+				server.respond(function (req) {
+					if (req.method == "POST"
+							&& req.url == `${baseURL}users/1/items/${item.key}/file`
+							&& req.requestBody.indexOf('upload=') == -1
+							&& req.requestHeaders["If-None-Match"] == "*") {
+						req.respond(
+							412,
+							{
+								"Content-Type": "application/json",
+								"Last-Modified-Version": 5
+							},
+							"If-None-Match: * set but file exists"
+						);
+					}
+				})
+				setResponse({
+					method: "GET",
+					url: `users/1/items?format=json&itemKey=${item.key}&includeTrashed=1`,
+					status: 200,
+					text: JSON.stringify([itemJSON])
+				});
+				
+				var result = yield zfs._processUploadFile({
+					name: item.libraryKey
+				});
+				yield assert.eventually.isNull(Zotero.Sync.Storage.Local.getSyncedHash(item.id));
+				yield assert.eventually.equal(
+					Zotero.Sync.Storage.Local.getSyncState(item.id),
+					Zotero.Sync.Storage.SYNC_STATE_IN_CONFLICT
+				);
+				assert.isFalse(result.localChanges);
+				assert.isFalse(result.remoteChanges);
+				assert.isFalse(result.syncRequired);
+				assert.isTrue(result.fileSyncRequired);
+			})
+			
+			it("should handle 412 with greater version", function* () {
+				var { engine, client, caller } = yield setup();
+				var zfs = new Zotero.Sync.Storage.ZFS_Module({
+					apiClient: client
+				})
+				
+				var file = getTestDataDirectory();
+				file.append('test.png');
+				var item = yield Zotero.Attachments.importFromFile({ file });
+				item.version = 5;
+				item.synced = true;
+				yield item.saveTx();
+				
+				server.respond(function (req) {
+					if (req.method == "POST"
+							&& req.url == `${baseURL}users/1/items/${item.key}/file`
+							&& req.requestBody.indexOf('upload=') == -1
+							&& req.requestHeaders["If-None-Match"] == "*") {
+						req.respond(
+							412,
+							{
+								"Content-Type": "application/json",
+								"Last-Modified-Version": 10
+							},
+							"If-None-Match: * set but file exists"
+						);
+					}
+				})
+				
+				var result = yield zfs._processUploadFile({
+					name: item.libraryKey
+				});
+				assert.equal(item.version, 5);
+				assert.equal(item.synced, true);
+				assert.isFalse(result.localChanges);
+				assert.isFalse(result.remoteChanges);
+				assert.isTrue(result.syncRequired);
+			})
+		})
+	})
+})
diff --git a/test/tests/storageLocalTest.js b/test/tests/storageLocalTest.js
new file mode 100644
index 000000000..31694feed
--- /dev/null
+++ b/test/tests/storageLocalTest.js
@@ -0,0 +1,329 @@
+"use strict";
+
+describe("Zotero.Sync.Storage.Local", function () {
+	var win;
+	
+	before(function* () {
+		win = yield loadBrowserWindow();
+	});
+	beforeEach(function* () {
+		yield resetDB({
+			thisArg: this
+		})
+	})
+	after(function () {
+		if (win) {
+			win.close();
+		}
+	});
+	
+	describe("#checkForUpdatedFiles()", function () {
+		it("should flag modified file for upload and return it", function* () {
+			// Create attachment
+			let item = yield importFileAttachment('test.txt')
+			var hash = yield item.attachmentHash;
+			// Set file mtime to the past (without milliseconds, which aren't used on OS X)
+			var mtime = (Math.floor(new Date().getTime() / 1000) * 1000) - 1000;
+			yield OS.File.setDates((yield item.getFilePathAsync()), null, mtime);
+			
+			// Mark as synced, so it will be checked
+			yield Zotero.DB.executeTransaction(function* () {
+				yield Zotero.Sync.Storage.Local.setSyncedHash(item.id, hash);
+				yield Zotero.Sync.Storage.Local.setSyncedModificationTime(item.id, mtime);
+				yield Zotero.Sync.Storage.Local.setSyncState(
+					item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+				);
+			});
+			
+			// Update mtime and contents
+			var path = yield item.getFilePathAsync();
+			yield OS.File.setDates(path);
+			yield Zotero.File.putContentsAsync(path, Zotero.Utilities.randomString());
+			
+			// File should be returned
+			var libraryID = Zotero.Libraries.userLibraryID;
+			var changed = yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID);
+			
+			yield item.eraseTx();
+			
+			assert.equal(changed, true);
+			assert.equal(
+				(yield Zotero.Sync.Storage.Local.getSyncState(item.id)),
+				Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD
+			);
+		})
+		
+		it("should skip a file if mod time hasn't changed", function* () {
+			// Create attachment
+			let item = yield importFileAttachment('test.txt')
+			var hash = yield item.attachmentHash;
+			var mtime = yield item.attachmentModificationTime;
+			
+			// Mark as synced, so it will be checked
+			yield Zotero.DB.executeTransaction(function* () {
+				yield Zotero.Sync.Storage.Local.setSyncedHash(item.id, hash);
+				yield Zotero.Sync.Storage.Local.setSyncedModificationTime(item.id, mtime);
+				yield Zotero.Sync.Storage.Local.setSyncState(
+					item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+				);
+			});
+			
+			var libraryID = Zotero.Libraries.userLibraryID;
+			var changed = yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID);
+			var syncState = yield Zotero.Sync.Storage.Local.getSyncState(item.id);
+			
+			yield item.eraseTx();
+			
+			assert.isFalse(changed);
+			assert.equal(syncState, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
+		})
+		
+		it("should skip a file if mod time has changed but contents haven't", function* () {
+			// Create attachment
+			let item = yield importFileAttachment('test.txt')
+			var hash = yield item.attachmentHash;
+			// Set file mtime to the past (without milliseconds, which aren't used on OS X)
+			var mtime = (Math.floor(new Date().getTime() / 1000) * 1000) - 1000;
+			yield OS.File.setDates((yield item.getFilePathAsync()), null, mtime);
+			
+			// Mark as synced, so it will be checked
+			yield Zotero.DB.executeTransaction(function* () {
+				yield Zotero.Sync.Storage.Local.setSyncedHash(item.id, hash);
+				yield Zotero.Sync.Storage.Local.setSyncedModificationTime(item.id, mtime);
+				yield Zotero.Sync.Storage.Local.setSyncState(
+					item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+				);
+			});
+			
+			// Update mtime, but not contents
+			var path = yield item.getFilePathAsync();
+			yield OS.File.setDates(path);
+			
+			var libraryID = Zotero.Libraries.userLibraryID;
+			var changed = yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID);
+			var syncState = yield Zotero.Sync.Storage.Local.getSyncState(item.id);
+			var syncedModTime = yield Zotero.Sync.Storage.Local.getSyncedModificationTime(item.id);
+			var newModTime = yield item.attachmentModificationTime;
+			
+			yield item.eraseTx();
+			
+			assert.isFalse(changed);
+			assert.equal(syncState, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC);
+			assert.equal(syncedModTime, newModTime);
+		})
+	})
+	
+	describe("#processDownload()", function () {
+		var file1Name = 'index.html';
+		var file1Contents = '<html><body>Test</body></html>';
+		var file2Name = 'test.txt';
+		var file2Contents = 'Test';
+		
+		var createZIP = Zotero.Promise.coroutine(function* (zipFile) {
+			var tmpDir = Zotero.getTempDirectory().path;
+			var zipDir = OS.Path.join(tmpDir, Zotero.Utilities.randomString());
+			yield OS.File.makeDir(zipDir);
+			
+			yield Zotero.File.putContentsAsync(OS.Path.join(zipDir, file1Name), file1Contents);
+			yield Zotero.File.putContentsAsync(OS.Path.join(zipDir, file2Name), file2Contents);
+			
+			yield Zotero.File.zipDirectory(zipDir, zipFile);
+			yield OS.File.removeDir(zipDir);
+		});
+		
+		it("should download and extract a ZIP file into the attachment directory", function* () {
+			var libraryID = Zotero.Libraries.userLibraryID;
+			var parentItem = yield createDataObject('item');
+			var key = Zotero.DataObjectUtilities.generateKey();
+			
+			var tmpDir = Zotero.getTempDirectory().path;
+			var zipFile = OS.Path.join(tmpDir, key + '.tmp');
+			yield createZIP(zipFile);
+			
+			var md5 = Zotero.Utilities.Internal.md5(Zotero.File.pathToFile(zipFile));
+			var mtime = 1445667239000;
+			
+			var json = {
+				key,
+				version: 10,
+				itemType: 'attachment',
+				linkMode: 'imported_url',
+				url: 'https://example.com',
+				filename: file1Name,
+				contentType: 'text/html',
+				charset: 'utf-8',
+				md5,
+				mtime
+			};
+			yield Zotero.Sync.Data.Local.saveCacheObjects(
+				'item', libraryID, [json]
+			);
+			yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
+				libraryID, 'item', { stopOnError: true }
+			);
+			var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
+			yield Zotero.Sync.Storage.Local.processDownload({
+				item,
+				md5,
+				mtime,
+				compressed: true
+			});
+			yield OS.File.remove(zipFile);
+			
+			yield assert.eventually.equal(
+				item.attachmentHash, Zotero.Utilities.Internal.md5(file1Contents)
+			);
+			yield assert.eventually.equal(item.attachmentModificationTime, mtime);
+		})
+	})
+	
+	describe("#_deleteExistingAttachmentFiles()", function () {
+		it("should delete all files", function* () {
+			var item = yield importFileAttachment('test.html');
+			var path = OS.Path.dirname(item.getFilePath());
+			var files = ['a', 'b', 'c', 'd'];
+			for (let file of files) {
+				yield Zotero.File.putContentsAsync(OS.Path.join(path, file), file);
+			}
+			yield Zotero.Sync.Storage.Local._deleteExistingAttachmentFiles(item);
+			for (let file of files) {
+				assert.isFalse(
+					(yield OS.File.exists(OS.Path.join(path, file))),
+					`File '${file}' doesn't exist`
+				);
+			}
+		})
+	})
+	
+	describe("#getConflicts()", function () {
+		it("should return an array of objects for attachments in conflict", function* () {
+			var libraryID = Zotero.Libraries.userLibraryID;
+			
+			var item1 = yield importFileAttachment('test.png');
+			item1.version = 10;
+			yield item1.saveTx();
+			var item2 = yield importFileAttachment('test.txt');
+			var item3 = yield importFileAttachment('test.html');
+			item3.version = 11;
+			yield item3.saveTx();
+			
+			var json1 = yield item1.toJSON();
+			var json3 = yield item3.toJSON();
+			// Change remote mtimes
+			// Round to nearest second because OS X doesn't support ms resolution
+			var now = Math.round(new Date().getTime() / 1000) * 1000;
+			json1.mtime = now - 10000;
+			json3.mtime = now - 20000;
+			yield Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json1, json3]);
+			
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				item1.id, Zotero.Sync.Storage.SYNC_STATE_IN_CONFLICT
+			);
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				item3.id, Zotero.Sync.Storage.SYNC_STATE_IN_CONFLICT
+			);
+			
+			var conflicts = yield Zotero.Sync.Storage.Local.getConflicts(libraryID);
+			assert.lengthOf(conflicts, 2);
+			
+			var item1Conflict = conflicts.find(x => x.left.key == item1.key);
+			assert.equal(
+				item1Conflict.left.dateModified,
+				Zotero.Date.dateToISO(new Date(yield item1.attachmentModificationTime))
+			);
+			assert.equal(
+				item1Conflict.right.dateModified,
+				Zotero.Date.dateToISO(new Date(json1.mtime))
+			);
+			
+			var item3Conflict = conflicts.find(x => x.left.key == item3.key);
+			assert.equal(
+				item3Conflict.left.dateModified,
+				Zotero.Date.dateToISO(new Date(yield item3.attachmentModificationTime))
+			);
+			assert.equal(
+				item3Conflict.right.dateModified,
+				Zotero.Date.dateToISO(new Date(json3.mtime))
+			);
+		})
+	})
+	
+	describe("#resolveConflicts()", function () {
+		it("should show the conflict resolution window on attachment conflicts", function* () {
+			var libraryID = Zotero.Libraries.userLibraryID;
+			
+			var item1 = yield importFileAttachment('test.png');
+			item1.version = 10;
+			yield item1.saveTx();
+			var item2 = yield importFileAttachment('test.txt');
+			var item3 = yield importFileAttachment('test.html');
+			item3.version = 11;
+			yield item3.saveTx();
+			
+			var json1 = yield item1.toJSON();
+			var json3 = yield item3.toJSON();
+			// Change remote mtimes
+			json1.mtime = new Date().getTime() + 10000;
+			json3.mtime = new Date().getTime() - 10000;
+			yield Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json1, json3]);
+			
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				item1.id, Zotero.Sync.Storage.SYNC_STATE_IN_CONFLICT
+			);
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				item3.id, Zotero.Sync.Storage.SYNC_STATE_IN_CONFLICT
+			);
+			
+			var promise = waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
+				var doc = dialog.document;
+				var wizard = doc.documentElement;
+				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
+				
+				// 1 (remote)
+				// Later remote version should be selected
+				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
+				
+				// Check checkbox text
+				assert.equal(
+					doc.getElementById('resolve-all').label,
+					Zotero.getString('sync.conflict.resolveAllRemote')
+				);
+				
+				// Select local object
+				mergeGroup.leftpane.click();
+				assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
+				
+				wizard.getButton('next').click();
+				
+				// 2 (local)
+				// Later local version should be selected
+				assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
+				// Select remote object
+				mergeGroup.rightpane.click();
+				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
+				
+				if (Zotero.isMac) {
+					assert.isTrue(wizard.getButton('next').hidden);
+					assert.isFalse(wizard.getButton('finish').hidden);
+				}
+				else {
+					// TODO
+				}
+				wizard.getButton('finish').click();
+			})
+			yield Zotero.Sync.Storage.Local.resolveConflicts(libraryID);
+			yield promise;
+			
+			yield assert.eventually.equal(
+				Zotero.Sync.Storage.Local.getSyncState(item1.id),
+				Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD
+			);
+			yield assert.eventually.equal(
+				Zotero.Sync.Storage.Local.getSyncState(item3.id),
+				Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD
+			);
+		})
+	})
+	
+	
+})
diff --git a/test/tests/storageRequestTest.js b/test/tests/storageRequestTest.js
new file mode 100644
index 000000000..9ab0b0c2d
--- /dev/null
+++ b/test/tests/storageRequestTest.js
@@ -0,0 +1,22 @@
+"use strict";
+
+describe("Zotero.Sync.Storage.Request", function () {
+	describe("#run()", function () {
+		it("should run a request and wait for it to complete", function* () {
+			var libraryID = Zotero.Libraries.userLibraryID;
+			var count = 0;
+			var request = new Zotero.Sync.Storage.Request({
+				type: 'download',
+				libraryID,
+				name: "1/AAAAAAAA",
+				onStart: Zotero.Promise.coroutine(function* () {
+					yield Zotero.Promise.delay(25);
+					count++;
+					return new Zotero.Sync.Storage.Result;
+				})
+			});
+			var results = yield request.start();
+			assert.equal(count, 1);
+		})
+	})
+})
diff --git a/test/tests/syncEngineTest.js b/test/tests/syncEngineTest.js
index 09d602aed..80ac18970 100644
--- a/test/tests/syncEngineTest.js
+++ b/test/tests/syncEngineTest.js
@@ -19,28 +19,20 @@ describe("Zotero.Sync.Data.Engine", function () {
 		var caller = new ConcurrentCaller(1);
 		caller.setLogger(msg => Zotero.debug(msg));
 		caller.stopOnError = true;
-		caller.onError = function (e) {
-			Zotero.logError(e);
-			if (options.onError) {
-				options.onError(e);
-			}
-			if (e.fatal) {
-				caller.stop();
-				throw e;
-			}
-		};
 		
+		Components.utils.import("resource://zotero/config.js");
 		var client = new Zotero.Sync.APIClient({
-			baseURL: baseURL,
+			baseURL,
 			apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
-			apiKey: apiKey,
-			concurrentCaller: caller,
+			apiKey,
+			caller,
 			background: options.background || true
 		});
 		
 		var engine = new Zotero.Sync.Data.Engine({
 			apiClient: client,
-			libraryID: options.libraryID || Zotero.Libraries.userLibraryID
+			libraryID: options.libraryID || Zotero.Libraries.userLibraryID,
+			stopOnError: true
 		});
 		
 		return { engine, client, caller };
diff --git a/test/tests/syncLocalTest.js b/test/tests/syncLocalTest.js
index 22fec6806..cecee39aa 100644
--- a/test/tests/syncLocalTest.js
+++ b/test/tests/syncLocalTest.js
@@ -4,7 +4,7 @@ describe("Zotero.Sync.Data.Local", function() {
 	describe("#processSyncCacheForObjectType()", function () {
 		var types = Zotero.DataObjectUtilities.getTypes();
 		
-		it("should update local version number if remote version is identical", function* () {
+		it("should update local version number and mark as synced if remote version is identical", function* () {
 			var libraryID = Zotero.Libraries.userLibraryID;
 			
 			for (let type of types) {
@@ -24,11 +24,167 @@ describe("Zotero.Sync.Data.Local", function() {
 				yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
 					libraryID, type, { stopOnError: true }
 				);
-				assert.equal(
-					objectsClass.getByLibraryAndKey(libraryID, obj.key).version, 10
-				);
+				let localObj = objectsClass.getByLibraryAndKey(libraryID, obj.key);
+				assert.equal(localObj.version, 10);
+				assert.isTrue(localObj.synced);
 			}
 		})
+		
+		it("should keep local item changes while applying non-conflicting remote changes", function* () {
+			var libraryID = Zotero.Libraries.userLibraryID;
+			
+			var type = 'item';
+			let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
+			let obj = yield createDataObject(type, { version: 5 });
+			let data = yield obj.toJSON();
+			yield Zotero.Sync.Data.Local.saveCacheObjects(
+				type, libraryID, [data]
+			);
+			
+			// Change local title
+			yield modifyDataObject(obj)
+			var changedTitle = obj.getField('title');
+			
+			// Save remote version to cache without title but with changed place
+			data.key = obj.key;
+			data.version = 10;
+			var changedPlace = data.place = 'New York';
+			let json = {
+				key: obj.key,
+				version: 10,
+				data: data
+			};
+			yield Zotero.Sync.Data.Local.saveCacheObjects(
+				type, libraryID, [json]
+			);
+			
+			yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
+				libraryID, type, { stopOnError: true }
+			);
+			assert.equal(obj.version, 10);
+			assert.equal(obj.getField('title'), changedTitle);
+			assert.equal(obj.getField('place'), changedPlace);
+		})
+		
+		it("should mark new attachment items for download", function* () {
+			var libraryID = Zotero.Libraries.userLibraryID;
+			Zotero.Sync.Storage.Local.setModeForLibrary(libraryID, 'zfs');
+			
+			var key = Zotero.DataObjectUtilities.generateKey();
+			var version = 10;
+			var json = {
+				key,
+				version,
+				data: {
+					key,
+					version,
+					itemType: 'attachment',
+					linkMode: 'imported_file',
+					md5: '57f8a4fda823187b91e1191487b87fe6',
+					mtime: 1442261130615
+				}
+			};
+			
+			yield Zotero.Sync.Data.Local.saveCacheObjects(
+				'item', Zotero.Libraries.userLibraryID, [json]
+			);
+			yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
+				libraryID, 'item', { stopOnError: true }
+			);
+			var id = Zotero.Items.getIDFromLibraryAndKey(libraryID, key);
+			assert.equal(
+				(yield Zotero.Sync.Storage.Local.getSyncState(id)),
+				Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD
+			);
+		})
+		
+		it("should mark updated attachment items for download", function* () {
+			var libraryID = Zotero.Libraries.userLibraryID;
+			Zotero.Sync.Storage.Local.setModeForLibrary(libraryID, 'zfs');
+			
+			var item = yield importFileAttachment('test.png');
+			item.version = 5;
+			item.synced = true;
+			yield item.saveTx();
+			
+			// Set file as synced
+			yield Zotero.DB.executeTransaction(function* () {
+				yield Zotero.Sync.Storage.Local.setSyncedModificationTime(
+					item.id, (yield item.attachmentModificationTime)
+				);
+				yield Zotero.Sync.Storage.Local.setSyncedHash(
+					item.id, (yield item.attachmentHash)
+				);
+				yield Zotero.Sync.Storage.Local.setSyncState(
+					item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+				);
+			});
+			
+			// Simulate download of version with updated attachment
+			var json = yield item.toResponseJSON();
+			json.version = 10;
+			json.data.version = 10;
+			json.data.md5 = '57f8a4fda823187b91e1191487b87fe6';
+			json.data.mtime = new Date().getTime() + 10000;
+			yield Zotero.Sync.Data.Local.saveCacheObjects(
+				'item', Zotero.Libraries.userLibraryID, [json]
+			);
+			
+			yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
+				libraryID, 'item', { stopOnError: true }
+			);
+			
+			assert.equal(
+				(yield Zotero.Sync.Storage.Local.getSyncState(item.id)),
+				Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD
+			);
+		})
+		
+		it("should ignore attachment metadata when resolving metadata conflict", function* () {
+			var libraryID = Zotero.Libraries.userLibraryID;
+			Zotero.Sync.Storage.Local.setModeForLibrary(libraryID, 'zfs');
+			
+			var item = yield importFileAttachment('test.png');
+			item.version = 5;
+			yield item.saveTx();
+			var json = yield item.toResponseJSON();
+			yield Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]);
+			
+			// Set file as synced
+			yield Zotero.DB.executeTransaction(function* () {
+				yield Zotero.Sync.Storage.Local.setSyncedModificationTime(
+					item.id, (yield item.attachmentModificationTime)
+				);
+				yield Zotero.Sync.Storage.Local.setSyncedHash(
+					item.id, (yield item.attachmentHash)
+				);
+				yield Zotero.Sync.Storage.Local.setSyncState(
+					item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC
+				);
+			});
+			
+			// Modify title locally, leaving item unsynced
+			var newTitle = Zotero.Utilities.randomString();
+			item.setField('title', newTitle);
+			yield item.saveTx();
+			
+			// Simulate download of version with original title but updated attachment
+			json.version = 10;
+			json.data.version = 10;
+			json.data.md5 = '57f8a4fda823187b91e1191487b87fe6';
+			json.data.mtime = new Date().getTime() + 10000;
+			yield Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]);
+			
+			yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
+				libraryID, 'item', { stopOnError: true }
+			);
+			
+			assert.equal(item.getField('title'), newTitle);
+			assert.equal(
+				(yield Zotero.Sync.Storage.Local.getSyncState(item.id)),
+				Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD
+			);
+		})
 	})
 	
 	describe("Conflict Resolution", function () {
@@ -232,7 +388,10 @@ describe("Zotero.Sync.Data.Local", function() {
 			jsonData.title = Zotero.Utilities.randomString();
 			yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
 			
+			var windowOpened = false;
 			waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
+				windowOpened = true;
+				
 				var doc = dialog.document;
 				var wizard = doc.documentElement;
 				var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
@@ -240,12 +399,14 @@ describe("Zotero.Sync.Data.Local", function() {
 				// Remote version should be selected by default
 				assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
 				assert.ok(mergeGroup.leftpane.pane.onclick);
+				// Select local deleted version
 				mergeGroup.leftpane.pane.click();
 				wizard.getButton('finish').click();
 			})
 			yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
 				libraryID, type, { stopOnError: true }
 			);
+			assert.isTrue(windowOpened);
 			
 			obj = objectsClass.getByLibraryAndKey(libraryID, key);
 			assert.isFalse(obj);
@@ -825,15 +986,28 @@ describe("Zotero.Sync.Data.Local", function() {
 				assert.sameDeepMembers(
 					result.conflicts,
 					[
-						{
-							field: "place",
-							op: "delete"
-						},
-						{
-							field: "date",
-							op: "add",
-							value: "2015-05-15"
-						}
+						[
+							{
+								field: "place",
+								op: "add",
+								value: "Place"
+							},
+							{
+								field: "place",
+								op: "delete"
+							}
+						],
+						[
+							{
+								field: "date",
+								op: "delete"
+							},
+							{
+								field: "date",
+								op: "add",
+								value: "2015-05-15"
+							}
+						]
 					]
 				);
 			})
@@ -1296,4 +1470,68 @@ describe("Zotero.Sync.Data.Local", function() {
 			})
 		})
 	})
+	
+	
+	describe("#reconcileChangesWithoutCache()", function () {
+		it("should return conflict for conflicting fields", function () {
+			var json1 = {
+				key: "AAAAAAAA",
+				version: 1234,
+				title: "Title 1",
+				pages: 10,
+				dateModified: "2015-05-14 14:12:34"
+			};
+			var json2 = {
+				key: "AAAAAAAA",
+				version: 1235,
+				title: "Title 2",
+				place: "New York",
+				dateModified: "2015-05-14 13:45:12"
+			};
+			var ignoreFields = ['dateAdded', 'dateModified'];
+			var result = Zotero.Sync.Data.Local._reconcileChangesWithoutCache(
+				'item', json1, json2, ignoreFields
+			);
+			assert.lengthOf(result.changes, 0);
+			assert.sameDeepMembers(
+				result.conflicts,
+				[
+					[
+						{
+							field: "title",
+							op: "add",
+							value: "Title 1"
+						},
+						{
+							field: "title",
+							op: "add",
+							value: "Title 2"
+						}
+					],
+					[
+						{
+							field: "pages",
+							op: "add",
+							value: 10
+						},
+						{
+							field: "pages",
+							op: "delete"
+						}
+					],
+					[
+						{
+							field: "place",
+							op: "delete"
+						},
+						{
+							field: "place",
+							op: "add",
+							value: "New York"
+						}
+					]
+				]
+			);
+		})
+	})
 })
diff --git a/test/tests/syncRunnerTest.js b/test/tests/syncRunnerTest.js
index 6f505afad..4e725f618 100644
--- a/test/tests/syncRunnerTest.js
+++ b/test/tests/syncRunnerTest.js
@@ -5,7 +5,7 @@ describe("Zotero.Sync.Runner", function () {
 	
 	var apiKey = Zotero.Utilities.randomString(24);
 	var baseURL = "http://local.zotero/";
-	var userLibraryID, publicationsLibraryID, runner, caller, server, client, stub, spy;
+	var userLibraryID, publicationsLibraryID, runner, caller, server, stub, spy;
 	
 	var responses = {
 		keyInfo: {
@@ -129,15 +129,7 @@ describe("Zotero.Sync.Runner", function () {
 			}
 		};
 		
-		var client = new Zotero.Sync.APIClient({
-			baseURL: baseURL,
-			apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
-			apiKey: apiKey,
-			concurrentCaller: caller,
-			background: options.background || true
-		});
-		
-		return { runner, caller, client };
+		return { runner, caller };
 	})
 	
 	function setResponse(response) {
@@ -160,7 +152,7 @@ describe("Zotero.Sync.Runner", function () {
 		server = sinon.fakeServer.create();
 		server.autoRespond = true;
 		
-		({ runner, caller, client } = yield setup());
+		({ runner, caller } = yield setup());
 		
 		yield Zotero.Users.setCurrentUserID(1);
 		yield Zotero.Users.setCurrentUsername("A");
@@ -180,7 +172,7 @@ describe("Zotero.Sync.Runner", function () {
 		it("should check key access", function* () {
 			spy = sinon.spy(runner, "checkUser");
 			setResponse('keyInfo.fullAccess');
-			var json = yield runner.checkAccess(client);
+			var json = yield runner.checkAccess(runner.getAPIClient());
 			sinon.assert.calledWith(spy, 1, "Username");
 			var compare = {};
 			Object.assign(compare, responses.keyInfo.fullAccess.json);
@@ -216,7 +208,7 @@ describe("Zotero.Sync.Runner", function () {
 			
 			setResponse('userGroups.groupVersions');
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json
+				runner.getAPIClient(), false, responses.keyInfo.fullAccess.json
 			);
 			assert.lengthOf(libraries, 4);
 			assert.sameMembers(
@@ -240,19 +232,25 @@ describe("Zotero.Sync.Runner", function () {
 			
 			setResponse('userGroups.groupVersions');
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json, [userLibraryID]
+				runner.getAPIClient(), false, responses.keyInfo.fullAccess.json, [userLibraryID]
 			);
 			assert.lengthOf(libraries, 1);
 			assert.sameMembers(libraries, [userLibraryID]);
 			
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json, [userLibraryID, publicationsLibraryID]
+				runner.getAPIClient(),
+				false,
+				responses.keyInfo.fullAccess.json,
+				[userLibraryID, publicationsLibraryID]
 			);
 			assert.lengthOf(libraries, 2);
 			assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID]);
 			
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json, [group1.libraryID]
+				runner.getAPIClient(),
+				false,
+				responses.keyInfo.fullAccess.json,
+				[group1.libraryID]
 			);
 			assert.lengthOf(libraries, 1);
 			assert.sameMembers(libraries, [group1.libraryID]);
@@ -277,7 +275,7 @@ describe("Zotero.Sync.Runner", function () {
 			setResponse('groups.ownerGroup');
 			setResponse('groups.memberGroup');
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json
+				runner.getAPIClient(), false, responses.keyInfo.fullAccess.json
 			);
 			assert.lengthOf(libraries, 4);
 			assert.sameMembers(
@@ -318,7 +316,7 @@ describe("Zotero.Sync.Runner", function () {
 			setResponse('groups.ownerGroup');
 			setResponse('groups.memberGroup');
 			var libraries = yield runner.checkLibraries(
-				client,
+				runner.getAPIClient(),
 				false,
 				responses.keyInfo.fullAccess.json,
 				[group1.libraryID, group2.libraryID]
@@ -339,7 +337,7 @@ describe("Zotero.Sync.Runner", function () {
 			setResponse('groups.ownerGroup');
 			setResponse('groups.memberGroup');
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json
+				runner.getAPIClient(), false, responses.keyInfo.fullAccess.json
 			);
 			assert.lengthOf(libraries, 4);
 			var groupData1 = responses.groups.ownerGroup;
@@ -370,7 +368,7 @@ describe("Zotero.Sync.Runner", function () {
 				assert.include(text, group1.name);
 			});
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json
+				runner.getAPIClient(), false, responses.keyInfo.fullAccess.json
 			);
 			assert.lengthOf(libraries, 3);
 			assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID, group2.libraryID]);
@@ -388,7 +386,7 @@ describe("Zotero.Sync.Runner", function () {
 				assert.include(text, group.name);
 			}, "extra1");
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json
+				runner.getAPIClient(), false, responses.keyInfo.fullAccess.json
 			);
 			assert.lengthOf(libraries, 3);
 			assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID, group.libraryID]);
@@ -405,7 +403,7 @@ describe("Zotero.Sync.Runner", function () {
 				assert.include(text, group.name);
 			}, "cancel");
 			var libraries = yield runner.checkLibraries(
-				client, false, responses.keyInfo.fullAccess.json
+				runner.getAPIClient(), false, responses.keyInfo.fullAccess.json
 			);
 			assert.lengthOf(libraries, 0);
 			assert.isTrue(Zotero.Groups.exists(groupData.json.id));
@@ -656,6 +654,11 @@ describe("Zotero.Sync.Runner", function () {
 				Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(2694172)),
 				20
 			);
+			
+			// Last sync time should be within the last second
+			var lastSyncTime = Zotero.Sync.Data.Local.getLastSyncTime();
+			assert.isAbove(lastSyncTime, new Date().getTime() - 1000);
+			assert.isBelow(lastSyncTime, new Date().getTime());
 		})
 	})
 })
diff --git a/test/tests/zoteroPaneTest.js b/test/tests/zoteroPaneTest.js
index 8d93b4e8d..26968d0e4 100644
--- a/test/tests/zoteroPaneTest.js
+++ b/test/tests/zoteroPaneTest.js
@@ -1,3 +1,5 @@
+"use strict";
+
 describe("ZoteroPane", function() {
 	var win, doc, zp;
 	
@@ -90,4 +92,96 @@ describe("ZoteroPane", function() {
 			);
 		})
 	})
+	
+	describe("#viewAttachment", function () {
+		Components.utils.import("resource://zotero-unit/httpd.js");
+		var apiKey = Zotero.Utilities.randomString(24);
+		var port = 16213;
+		var baseURL = `http://localhost:${port}/`;
+		var server;
+		var responses = {};
+		
+		var setup = Zotero.Promise.coroutine(function* (options = {}) {
+			server = sinon.fakeServer.create();
+			server.autoRespond = true;
+		});
+		
+		function setResponse(response) {
+			setHTTPResponse(server, baseURL, response, responses);
+		}
+		
+		before(function () {
+			Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
+			
+			Zotero.Sync.Runner.apiKey = apiKey;
+			Zotero.Sync.Runner.baseURL = baseURL;
+		})
+		beforeEach(function* () {
+			this.httpd = new HttpServer();
+			this.httpd.start(port);
+			
+			yield Zotero.Users.setCurrentUserID(1);
+			yield Zotero.Users.setCurrentUsername("testuser");
+		})
+		afterEach(function* () {
+			var defer = new Zotero.Promise.defer();
+			this.httpd.stop(() => defer.resolve());
+			yield defer.promise;
+		})
+		
+		it("should download an attachment on-demand", function* () {
+			yield setup();
+			Zotero.Sync.Storage.Local.downloadAsNeeded(Zotero.Libraries.userLibraryID, true);
+			
+			var item = new Zotero.Item("attachment");
+			item.attachmentLinkMode = 'imported_file';
+			item.attachmentPath = 'storage:test.txt';
+			// TODO: Test binary data
+			var text = Zotero.Utilities.randomString();
+			yield item.saveTx();
+			yield Zotero.Sync.Storage.Local.setSyncState(
+				item.id, Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD
+			);
+			
+			var mtime = "1441252524000";
+			var md5 = Zotero.Utilities.Internal.md5(text)
+			
+			var newStorageSyncTime = Math.round(new Date().getTime() / 1000);
+			setResponse({
+				method: "GET",
+				url: "users/1/laststoragesync",
+				status: 200,
+				text: "" + newStorageSyncTime
+			});
+			var s3Path = `pretend-s3/${item.key}`;
+			this.httpd.registerPathHandler(
+				`/users/1/items/${item.key}/file`,
+				{
+					handle: function (request, response) {
+						response.setStatusLine(null, 302, "Found");
+						response.setHeader("Zotero-File-Modification-Time", mtime, false);
+						response.setHeader("Zotero-File-MD5", md5, false);
+						response.setHeader("Zotero-File-Compressed", "No", false);
+						response.setHeader("Location", baseURL + s3Path, false);
+					}
+				}
+			);
+			this.httpd.registerPathHandler(
+				"/" + s3Path,
+				{
+					handle: function (request, response) {
+						response.setStatusLine(null, 200, "OK");
+						response.write(text);
+					}
+				}
+			);
+			
+			yield zp.viewAttachment(item.id);
+			
+			assert.equal((yield item.attachmentHash), md5);
+			assert.equal((yield item.attachmentModificationTime), mtime);
+			var path = yield item.getFilePathAsync();
+			assert.equal((yield Zotero.File.getContentsAsync(path)), text);
+		})
+	})
 })