www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

commit 758216638f7aa32a5e92430cc9615e227647c867
parent 80530b9599a63c684e80609c9ce7e3bf50d149d6
Author: Dan Stillman <dstillman@zotero.org>
Date:   Mon, 14 Nov 2011 03:42:06 -0500

On-demand download support

Can choose to download files "at sync time" or "as needed"

On-demand defaults to on, but remains off for existing users

To-do:

- Handling of local and remote file changes on on-demand download
  (currently if a file exists it isn't downloaded, which means a
  remotely modified file won't be redownloaded in on-demand mode)
- Additional control over file downloading and retention

Other changes:

- Overhauled entire file syncing architecture
- Replaced numAttachments column with Note and Attachment columns with
  dynamic icons to indicate status
- Double-clicking a parent with a missing best attachment and on-demand
  downloading off no longer loads the parent URL
- Bugs

Diffstat:
Mchrome/content/zotero/overlay.js | 2+-
Mchrome/content/zotero/preferences/preferences.js | 37+++++++++++++++++++++++++++++--------
Mchrome/content/zotero/preferences/preferences.xul | 49++++++++++++++++++++++++++++++++++---------------
Mchrome/content/zotero/selectItemsDialog.xul | 15++++++++++++---
Mchrome/content/zotero/xpcom/data/dataObjects.js | 10----------
Mchrome/content/zotero/xpcom/data/item.js | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mchrome/content/zotero/xpcom/data/notes.js | 3+++
Mchrome/content/zotero/xpcom/itemTreeView.js | 186++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mchrome/content/zotero/xpcom/schema.js | 1+
Mchrome/content/zotero/xpcom/storage.js | 1706+++++++++++++++++++++----------------------------------------------------------
Achrome/content/zotero/xpcom/storage/eventManager.js | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achrome/content/zotero/xpcom/storage/module.js | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achrome/content/zotero/xpcom/storage/queue.js | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achrome/content/zotero/xpcom/storage/queueManager.js | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achrome/content/zotero/xpcom/storage/request.js | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dchrome/content/zotero/xpcom/storage/session.js | 192-------------------------------------------------------------------------------
Achrome/content/zotero/xpcom/storage/streamListener.js | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mchrome/content/zotero/xpcom/storage/webdav.js | 2979+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mchrome/content/zotero/xpcom/storage/zfs.js | 1792++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mchrome/content/zotero/xpcom/sync.js | 107++++++++++++++++++++++++++++++++++---------------------------------------------
Mchrome/content/zotero/xpcom/zotero.js | 32+++++++++++++++++++++++++++++---
Mchrome/content/zotero/zoteroPane.js | 55++++++++++++++++++++++++++++++++++++++++---------------
Mchrome/content/zotero/zoteroPane.xul | 15++++++++++++---
Mchrome/locale/en-US/zotero/preferences.dtd | 6+++++-
Achrome/skin/default/zotero/attach-small.png | 0
Achrome/skin/default/zotero/bullet_blue.png | 0
Achrome/skin/default/zotero/bullet_blue_empty.png | 0
Achrome/skin/default/zotero/bullet_yellow.png | 0
Mchrome/skin/default/zotero/overlay.css | 5+++++
Mchrome/skin/default/zotero/preferences.css | 9++++-----
Achrome/skin/default/zotero/treeitem-note-small.png | 0
Mcomponents/zotero-service.js | 7++++++-
Mdefaults/preferences/zotero.js | 2++
33 files changed, 4730 insertions(+), 4071 deletions(-)

diff --git a/chrome/content/zotero/overlay.js b/chrome/content/zotero/overlay.js @@ -178,7 +178,7 @@ var ZoteroOverlay = new function() * Hides/displays the Zotero interface */ this.toggleDisplay = function(makeVisible) - { + { if(!Zotero || !Zotero.initialized) { ZoteroPane.displayStartupError(); return; diff --git a/chrome/content/zotero/preferences/preferences.js b/chrome/content/zotero/preferences/preferences.js @@ -198,6 +198,7 @@ function updateStorageSettings(enabled, protocol, skipWarnings) { protocol = oldProtocol; } + var storageSettings = document.getElementById('storage-settings'); var protocolMenu = document.getElementById('storage-protocol'); var settings = document.getElementById('storage-webdav-settings'); var sep = document.getElementById('storage-separator'); @@ -211,7 +212,12 @@ function updateStorageSettings(enabled, protocol, skipWarnings) { sep.hidden = true; } - protocolMenu.disabled = !enabled; + var menulists = storageSettings.getElementsByTagName('menulist'); + for each(var menulist in menulists) { + if (menulist.className == 'storage-personal') { + menulist.disabled = !enabled; + } + } if (!skipWarnings) { // WARN if going between @@ -270,6 +276,21 @@ function updateStorageSettings(enabled, protocol, skipWarnings) { } +function updateStorageSettingsGroups(enabled) { + var storageSettings = document.getElementById('storage-settings'); + var menulists = storageSettings.getElementsByTagName('menulist'); + for each(var menulist in menulists) { + if (menulist.className == 'storage-groups') { + menulist.disabled = !enabled; + } + } + + setTimeout(function () { + updateStorageTerms(); + }, 1) +} + + function updateStorageTerms() { var terms = document.getElementById('storage-terms'); @@ -297,7 +318,11 @@ function verifyStorageServer() { var usernameField = document.getElementById("storage-username"); var passwordField = document.getElementById("storage-password"); - var callback = function (uri, status, error) { + verifyButton.hidden = true; + abortButton.hidden = false; + progressMeter.hidden = false; + + var requestHolder = Zotero.Sync.Storage.checkServer('webdav', function (uri, status, callback) { verifyButton.hidden = false; abortButton.hidden = true; progressMeter.hidden = true; @@ -322,13 +347,9 @@ function verifyStorageServer() { break; } - Zotero.Sync.Storage.checkServerCallback(uri, status, window, false, error); - } + callback(uri, status, window); + }); - verifyButton.hidden = true; - abortButton.hidden = false; - progressMeter.hidden = false; - var requestHolder = Zotero.Sync.Storage.checkServer('webdav', callback); abortButton.onclick = function () { if (requestHolder.request) { requestHolder.request.onreadystatechange = undefined; diff --git a/chrome/content/zotero/preferences/preferences.xul b/chrome/content/zotero/preferences/preferences.xul @@ -165,13 +165,15 @@ To add a new preference: <preference id="pref-storage-scheme" name="extensions.zotero.sync.storage.scheme" type="string"/> <preference id="pref-storage-url" name="extensions.zotero.sync.storage.url" type="string" instantApply="true"/> <preference id="pref-storage-username" name="extensions.zotero.sync.storage.username" type="string" instantApply="true"/> + <preference id="pref-storage-downloadMode-personal" name="extensions.zotero.sync.storage.downloadMode.personal" type="string"/> + <preference id="pref-storage-downloadMode-groups" name="extensions.zotero.sync.storage.downloadMode.groups" type="string"/> <preference id="pref-group-storage-enabled" name="extensions.zotero.sync.storage.groups.enabled" type="bool"/> </preferences> <tabbox> <tabs> - <tab label="Settings"/> - <tab label="Reset"/> + <tab label="&zotero.preferences.settings;"/> + <tab label="&zotero.preferences.sync.reset;"/> </tabs> <tabpanels> @@ -197,6 +199,10 @@ To add a new preference: <textbox id="sync-password" type="password" onchange="Zotero.Sync.Server.password = this.value"/> </row> + <row> + <box/> + <checkbox label="&zotero.preferences.sync.syncAutomatically;" preference="pref-sync-autosync"/> + </row> <!-- <row> <box/> @@ -212,29 +218,23 @@ To add a new preference: <hbox style="width:2em"/> <vbox> + <label class="zotero-text-link" value="&zotero.preferences.sync.about;" href="http://www.zotero.org/support/sync"/> + <separator class="thin"/> <label class="zotero-text-link" value="&zotero.preferences.sync.createAccount;" href="http://zotero.org/user/register"/> <separator class="thin"/> <label class="zotero-text-link" value="&zotero.preferences.sync.lostPassword;" href="http://zotero.org/user/lostpassword"/> </vbox> </hbox> - - <separator class="thin"/> - - <hbox> - <checkbox label="&zotero.preferences.sync.syncAutomatically;" preference="pref-sync-autosync"/> - </hbox> - - <label class="zotero-text-link" style="margin-top:.7em; margin-left: 0" value="&zotero.preferences.sync.about;" href="http://www.zotero.org/support/sync"/> </groupbox> - <groupbox> + <groupbox id="storage-settings"> <caption label="&zotero.preferences.sync.fileSyncing;"/> <!-- My Library --> - <hbox style="margin: 0"> + <hbox> <checkbox label="&zotero.preferences.sync.fileSyncing.myLibrary;" preference="pref-storage-enabled" oncommand="updateStorageSettings(this.checked, null)"/> - <menulist id="storage-protocol" style="margin-left: .5em" preference="pref-storage-protocol" oncommand="updateStorageSettings(null, this.value)"> + <menulist id="storage-protocol" class="storage-personal" style="margin-left: .5em" preference="pref-storage-protocol" oncommand="updateStorageSettings(null, this.value)"> <menupopup> <menuitem label="Zotero" value="zotero"/> <menuitem label="WebDAV" value="webdav"/> @@ -307,16 +307,35 @@ To add a new preference: </stack> + <hbox class="storage-settings-download-options" align="center"> + <label value="&zotero.preferences.sync.fileSyncing.download;"/> + <menulist class="storage-personal" preference="pref-storage-downloadMode-personal" style="margin-left: 0"> + <menupopup> + <menuitem label="&zotero.preferences.sync.fileSyncing.download.onDemand;" value="on-demand"/> + <menuitem label="&zotero.preferences.sync.fileSyncing.download.atSyncTime;" value="on-sync"/> + </menupopup> + </menulist> + </hbox> + <separator id="storage-separator" class="thin"/> <!-- Group Libraries --> <checkbox label="&zotero.preferences.sync.fileSyncing.groups;" - preference="pref-group-storage-enabled" oncommand="setTimeout(function () { updateStorageTerms(); }, 1)"/> + preference="pref-group-storage-enabled" oncommand="updateStorageSettingsGroups(this.checked)"/> + + <hbox class="storage-settings-download-options" align="center"> + <label value="&zotero.preferences.sync.fileSyncing.download;"/> + <menulist class="storage-groups" preference="pref-storage-downloadMode-groups" style="margin-left: 0"> + <menupopup> + <menuitem label="&zotero.preferences.sync.fileSyncing.download.onDemand;" value="on-demand"/> + <menuitem label="&zotero.preferences.sync.fileSyncing.download.atSyncTime;" value="on-sync"/> + </menupopup> + </menulist> + </hbox> <separator class="thin"/> <vbox> - <label class="zotero-text-link" style="margin-top:.3em; margin-left: 0" value="&zotero.preferences.sync.fileSyncing.about;" href="http://zotero.org/support/file_sync"/> <hbox id="storage-terms" style="margin-top: .4em" align="center"> <label>&zotero.preferences.sync.fileSyncing.tos1;</label> <label class="zotero-text-link" href="http://www.digitalscholar.org/z_terms" value="&zotero.preferences.sync.fileSyncing.tos2;"/> diff --git a/chrome/content/zotero/selectItemsDialog.xul b/chrome/content/zotero/selectItemsDialog.xul @@ -147,9 +147,18 @@ flex="1" persist="width ordinal hidden sortActive sortDirection"/> <splitter class="tree-splitter"/> <treecol - id="zotero-items-column-numChildren" - label="&zotero.items.numChildren_column;" - persist="width ordinal hidden sortActive sortDirection"/> + id="zotero-items-column-hasAttachment" + class="treecol-image" + label="&zotero.tabs.attachments.label;" + src="chrome://zotero/skin/attach-small.png" + zotero-persist="width ordinal hidden sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol + id="zotero-items-column-hasNote" + class="treecol-image" + label="&zotero.tabs.notes.label;" + src="chrome://zotero/skin/treeitem-note-small.png" + zotero-persist="width ordinal hidden sortActive sortDirection"/> </treecols> <treechildren/> </tree> diff --git a/chrome/content/zotero/xpcom/data/dataObjects.js b/chrome/content/zotero/xpcom/data/dataObjects.js @@ -366,16 +366,6 @@ Zotero.DataObjects = function (object, objectPlural, id, table) { this.editCheck = function (obj) { if (!Zotero.Sync.Server.updatesInProgress && !Zotero.Sync.Storage.updatesInProgress && !this.isEditable(obj)) { - if (Zotero.Sync.Storage.syncInProgress) { - try { - asfasf(); - } - catch (e) { - Zotero.debug(e); - } - Components.utils.reportError("Storage sync in progress but updatesInProgress not set -- fix?"); - return; - } throw ("Cannot edit " + this._ZDO_object + " in read-only Zotero library"); } } diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js @@ -53,6 +53,9 @@ Zotero.Item.prototype._init = function () { this._dateModified = null; this._firstCreator = null; this._numNotes = null; + this._numNotesTrashed = null; + this._numNotesEmbedded = null; + this._numNotesEmbeddedIncludingTrashed = null; this._numAttachments = null; this._creators = []; @@ -76,7 +79,11 @@ Zotero.Item.prototype._init = function () { this._skipModTimeUpdate = false; this._previousData = null; + this._bestAttachmentState = null; + this._fileExists = null; + this._deleted = null; + this._hasNote = null; this._noteTitle = null; this._noteText = null; this._noteAccessTime = null; @@ -1804,6 +1811,13 @@ Zotero.Item.prototype.save = function() { sql = "DELETE FROM deletedItems WHERE itemID=?"; } Zotero.DB.query(sql, this.id); + + var parent = this.getSource(); + if (parent) { + parent = Zotero.Items.get(parent); + parent.updateNumNotes(); + parent.updateBestAttachmentState(); + } } @@ -1843,6 +1857,13 @@ Zotero.Item.prototype.save = function() { ]; } Zotero.DB.query(sql, bindParams); + + if (this.isAttachment()) { + var parent = this.getSource(); + if (parent) { + Zotero.Items.get(parent).updateNumNotes(); + } + } } @@ -2316,9 +2337,10 @@ Zotero.Item.prototype.updateNote = function(text) { * Returns number of child notes of item * * @param {Boolean} includeTrashed Include trashed child items in count + * @param {Boolean} includeEmbedded Include notes embedded in attachments * @return {Integer} */ -Zotero.Item.prototype.numNotes = function(includeTrashed) { +Zotero.Item.prototype.numNotes = function(includeTrashed, includeEmbedded) { if (this.isNote()) { throw ("numNotes() cannot be called on items of type 'note'"); } @@ -2327,14 +2349,38 @@ Zotero.Item.prototype.numNotes = function(includeTrashed) { return 0; } - var deleted = 0; - if (includeTrashed) { + if (includeTrashed && this._numNotesTrashed === null) { var sql = "SELECT COUNT(*) FROM itemNotes WHERE sourceItemID=? AND " + "itemID IN (SELECT itemID FROM deletedItems)"; - deleted = parseInt(Zotero.DB.valueQuery(sql, this.id)); + this._numNotesTrashed = parseInt(Zotero.DB.valueQuery(sql, this.id)); + } + var embedded = 0; + if (includeEmbedded) { + if ((includeTrashed ? this._numNotesEmbeddedIncludingTrashed : this._numNotesEmbedded) === null) { + var sql = "SELECT COUNT(*) FROM itemAttachments IA JOIN itemNotes USING (itemID) " + + "WHERE IA.sourceItemID=? AND note!='' AND note!=?"; + if (!includeTrashed) { + sql += " AND itemID NOT IN (SELECT itemID FROM deletedItems)"; + } + var embedded = parseInt(Zotero.DB.valueQuery(sql, [this.id, Zotero.Notes.defaultNote])); + if (includeTrashed) { + this._numNotesEmbeddedIncludingTrashed = embedded; + } + else { + this._numNotesEmbedded = embedded; + } + } } - return this._numNotes + deleted; + return this._numNotes + this._numNotesTrashed + + (includeTrashed ? this._numNotesEmbeddedIncludingTrashed : this._numNotesEmbedded); +} + + +Zotero.Item.prototype.updateNumNotes = function () { + this._numNotesTrashed = null; + this._numNotesEmbedded = null; + this._numNotesEmbeddedIncludingTrashed = null; } @@ -2367,9 +2413,31 @@ Zotero.Item.prototype.getNoteTitle = function() { } +Zotero.Item.prototype.hasNote = function () { + if (!this.isNote() && !this.isAttachment()) { + throw new Error("hasNote() can only be called on notes and attachments"); + } + + if (this._hasNote !== null) { + return this._hasNote; + } + + if (!this.id) { + return false; + } + + var sql = "SELECT COUNT(*) FROM itemNotes WHERE itemID=? " + + "AND note!='' AND note!=?"; + var hasNote = !!Zotero.DB.valueQuery(sql, [this.id, Zotero.Notes.defaultNote]); + + this._hasNote = hasNote; + return hasNote; +} + + /** -* Get the text of an item note -**/ + * Get the text of an item note + **/ Zotero.Item.prototype.getNote = function() { if (!this.isNote() && !this.isAttachment()) { throw ("getNote() can only be called on notes and attachments"); @@ -2393,11 +2461,11 @@ Zotero.Item.prototype.getNote = function() { if (note) { if (!note.substr(0, 36).match(/^<div class="zotero-note znv[0-9]+">/)) { note = Zotero.Utilities.htmlSpecialChars(note); - note = '<div class="zotero-note znv1"><p>' + note = Zotero.Notes.notePrefix + '<p>' + note.replace(/\n/g, '</p><p>') .replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;') .replace(/ /g, '&nbsp;&nbsp;') - + '</p></div>'; + + '</p>' + Zotero.Notes.noteSuffix; note = note.replace(/<p>\s*<\/p>/g, '<p>&nbsp;</p>'); var sql = "UPDATE itemNotes SET note=? WHERE itemID=?"; Zotero.DB.query(sql, [note, this.id]); @@ -2410,7 +2478,6 @@ Zotero.Item.prototype.getNote = function() { } this._noteText = note ? note : ''; - return this._noteText; } @@ -2441,6 +2508,7 @@ Zotero.Item.prototype.setNote = function(text) { this._previousData = this.serialize(); } + this._hasNote = text !== ''; this._noteText = text; this._noteTitle = Zotero.Notes.noteToTitle(text); this._changedNote = true; @@ -2547,6 +2615,14 @@ Zotero.Item.prototype.isWebAttachment = function() { } +Zotero.Item.prototype.isFileAttachment = function() { + if (!this.isAttachment()) { + return false; + } + return this.attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_URL; +} + + /** * Returns number of child attachments of item * @@ -2594,6 +2670,18 @@ Zotero.Item.prototype.getFile = function(row, skipExistsCheck) { }; } + // Update file existence state of this item + // and best attachment state of parent item + var self = this; + var updateAttachmentStates = function (exists) { + self._fileExists = exists; + + if (self.isTopLevelItem()) { + return; + } + Zotero.Items.get(self.getSource()).updateBestAttachmentState(); + }; + // No associated files for linked URLs if (row.linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { return false; @@ -2601,6 +2689,7 @@ Zotero.Item.prototype.getFile = function(row, skipExistsCheck) { if (!row.path) { Zotero.debug("Attachment path is empty", 2); + updateAttachmentStates(false); return false; } @@ -2648,6 +2737,7 @@ Zotero.Item.prototype.getFile = function(row, skipExistsCheck) { } catch (e) { Zotero.debug('Invalid persistent descriptor', 2); + updateAttachmentStates(false); return false; } } @@ -2674,6 +2764,7 @@ Zotero.Item.prototype.getFile = function(row, skipExistsCheck) { } catch (e) { Zotero.debug('Invalid relative descriptor', 2); + updateAttachmentStates(false); return false; } } @@ -2681,9 +2772,11 @@ Zotero.Item.prototype.getFile = function(row, skipExistsCheck) { if (!skipExistsCheck && !file.exists()) { Zotero.debug("Attachment file '" + file.path + "' not found", 2); + updateAttachmentStates(false); return false; } + updateAttachmentStates(true); return file; } @@ -2706,6 +2799,19 @@ Zotero.Item.prototype.getFilename = function () { } +/** + * Cached check for file existence, used for items view + * + * This is updated only initially and on subsequent getFile() calls. + */ +Zotero.Item.prototype.__defineGetter__('fileExists', function () { + if (this._fileExists === null) { + this.getFile(); + } + return this._fileExists; +}); + + /* * Rename file associated with an attachment * @@ -3304,7 +3410,7 @@ Zotero.Item.prototype.getBestSnapshot = function () { } -/* +/** * Looks for attachment in the following order: oldest PDF attachment matching parent URL, * oldest non-PDF attachment matching parent URL, oldest PDF attachment not matching URL, * old non-PDF attachment not matching URL @@ -3315,15 +3421,36 @@ Zotero.Item.prototype.getBestAttachment = function() { if (!this.isRegularItem()) { throw ("getBestAttachment() can only be called on regular items"); } - return this.getBestAttachments()[0]; + var attachments = this.getBestAttachments(); + return attachments ? attachments[0] : false; } -/* +/** + * Returned cached state of best attachment for use in items view + * + * @return {Integer} 0 (none), 1 (present), -1 (missing) + */ +Zotero.Item.prototype.getBestAttachmentState = function () { + if (this._bestAttachmentState !== null) { + return this._bestAttachmentState; + } + var itemID = this.getBestAttachment(); + this._bestAttachmentState = itemID ? (Zotero.Items.get(itemID).fileExists ? 1 : -1) : 0; + return this._bestAttachmentState; +} + + +Zotero.Item.prototype.updateBestAttachmentState = function () { + this._bestAttachmentState = null; +} + + +/** * Looks for attachment in the following order: oldest PDF attachment matching parent URL, * oldest PDF attachment not matching parent URL, oldest non-PDF attachment matching parent URL, * old non-PDF attachment not matching parent URL * - * @return {Array} itemIDs for attachments + * @return {Array|FALSE} itemIDs for attachments, or FALSE if none */ Zotero.Item.prototype.getBestAttachments = function() { if (!this.isRegularItem()) { diff --git a/chrome/content/zotero/xpcom/data/notes.js b/chrome/content/zotero/xpcom/data/notes.js @@ -28,6 +28,9 @@ Zotero.Notes = new function() { this.noteToTitle = noteToTitle; this.__defineGetter__("MAX_TITLE_LENGTH", function() { return 80; }); + this.__defineGetter__("defaultNote", function () '<div class="zotero-note znv1"></div>'); + this.__defineGetter__("notePrefix", function () '<div class="zotero-note znv1">'); + this.__defineGetter__("noteSuffix", function () '</div>'); /** * Return first line (or first MAX_LENGTH characters) of note content diff --git a/chrome/content/zotero/xpcom/itemTreeView.js b/chrome/content/zotero/xpcom/itemTreeView.js @@ -245,8 +245,14 @@ Zotero.ItemTreeView.prototype._refreshGenerator = function() for (var i=0; i<visibleFields.length; i++) { var field = visibleFields[i]; - if (field == 'year') { - field = 'date'; + switch (field) { + case 'hasAttachment': + case 'hasNote': + continue; + + case 'year': + field = 'date'; + break; } if (cacheFields.indexOf(field) == -1) { cacheFields = cacheFields.concat(field); @@ -724,13 +730,9 @@ Zotero.ItemTreeView.prototype.getCellText = function(row, column) var val; - if(column.id == "zotero-items-column-numChildren") - { - var c = obj.numChildren(this._itemGroup.isTrash()); - // Don't display '0' - if(c && parseInt(c) > 0) { - val = c; - } + // Image only + if (column.id === "zotero-items-column-hasAttachment" || column.id === "zotero-items-column-hasNote") { + return; } else if(column.id == "zotero-items-column-type") { @@ -791,6 +793,47 @@ Zotero.ItemTreeView.prototype.getImageSrc = function(row, col) { return this._getItemAtRow(row).ref.getImageSrc(); } + else if (col.id == 'zotero-items-column-hasAttachment') { + if (this._itemGroup.isTrash()) return false; + + var treerow = this._getItemAtRow(row); + if (treerow.level === 0) { + if (treerow.ref.isRegularItem()) { + switch (treerow.ref.getBestAttachmentState()) { + case 1: + return "chrome://zotero/skin/bullet_blue.png"; + + case -1: + return "chrome://zotero/skin/bullet_blue_empty.png"; + + default: + return ""; + } + } + } + + if (treerow.ref.isFileAttachment()) { + if (treerow.ref.fileExists) { + return "chrome://zotero/skin/bullet_blue.png"; + } + else { + return "chrome://zotero/skin/bullet_blue_empty.png"; + } + } + } + else if (col.id == 'zotero-items-column-hasNote') { + if (this._itemGroup.isTrash()) return false; + + var treerow = this._getItemAtRow(row); + if (treerow.level === 0 && treerow.ref.isRegularItem() && treerow.ref.numNotes(false, true)) { + return "chrome://zotero/skin/bullet_yellow.png"; + } + else if (treerow.ref.isAttachment()) { + if (treerow.ref.hasNote()) { + return "chrome://zotero/skin/bullet_yellow.png"; + } + } + } } Zotero.ItemTreeView.prototype.isContainer = function(row) @@ -805,13 +848,16 @@ Zotero.ItemTreeView.prototype.isContainerOpen = function(row) Zotero.ItemTreeView.prototype.isContainerEmpty = function(row) { - if(this._sourcesOnly) { + if (this._sourcesOnly) { return true; - } else { - var includeTrashed = this._itemGroup.isTrash(); - return (this._getItemAtRow(row).numNotes(includeTrashed) == 0 - && this._getItemAtRow(row).numAttachments(includeTrashed) == 0); } + + var item = this._getItemAtRow(row).ref; + if (!item.isRegularItem()) { + return false; + } + var includeTrashed = this._itemGroup.isTrash(); + return item.numNotes(includeTrashed) === 0 && item.numAttachments(includeTrashed) == 0; } Zotero.ItemTreeView.prototype.getLevel = function(row) @@ -1010,25 +1056,51 @@ Zotero.ItemTreeView.prototype.sort = function(itemID) // Get the display field for a row (which might be a placeholder title) var getField; - if (columnField == 'title') { - getField = function (row) { - var field; - var type = row.ref.itemTypeID; - switch (type) { - case 8: // letter - case 10: // interview - case 17: // case - field = row.ref.getDisplayTitle(); - break; - - default: - field = row.getField(columnField, unformatted); - } - // Ignore some leading and trailing characters when sorting - return Zotero.Items.getSortTitle(field); - } - } else { - getField = function(row) row.getField(columnField, unformatted); + switch (columnField) { + case 'title': + getField = function (row) { + var field; + var type = row.ref.itemTypeID; + switch (type) { + case 8: // letter + case 10: // interview + case 17: // case + field = row.ref.getDisplayTitle(); + break; + + default: + field = row.getField(columnField, unformatted); + } + // Ignore some leading and trailing characters when sorting + return Zotero.Items.getSortTitle(field); + }; + break; + + case 'hasAttachment': + getField = function (row) { + if (!row.ref.isRegularItem()) { + return 0; + } + var state = row.ref.getBestAttachmentState(); + // Make sort order present, missing, empty when ascending + if (state === -1) { + state = 2; + } + return state * -1; + }; + break; + + case 'hasNote': + getField = function (row) { + if (!row.ref.isRegularItem()) { + return 0; + } + return row.ref.numNotes(false, true) ? 1 : 0; + }; + break; + + default: + getField = function (row) row.getField(columnField, unformatted); } var includeTrashed = this._itemGroup.isTrash(); @@ -1074,13 +1146,6 @@ Zotero.ItemTreeView.prototype.sort = function(itemID) } break; - case 'numChildren': - cmp = b.numChildren(includeTrashed) - a.numChildren(includeTrashed); - if (cmp) { - return cmp; - } - break; - default: if (fieldA == undefined) { fieldA = getField(a); @@ -2613,17 +2678,16 @@ Zotero.ItemTreeView.prototype.onDragExit = function (event) { Zotero.ItemTreeView.prototype.isSeparator = function(row) { return false; } Zotero.ItemTreeView.prototype.getRowProperties = function(row, prop) { - if (!this.selection.isSelected(row)) { - return; - } - - var itemID = this._getItemAtRow(row).ref.id; + var treeRow = this._getItemAtRow(row); + var itemID = treeRow.ref.id; // Set background color for selected items with colored tags - if (color = Zotero.Tags.getItemColor(itemID)) { - var aServ = Components.classes["@mozilla.org/atom-service;1"]. - getService(Components.interfaces.nsIAtomService); - prop.AppendElement(aServ.getAtom("color" + color.substr(1))); + if (this.selection.isSelected(row)) { + if (color = Zotero.Tags.getItemColor(itemID)) { + var aServ = Components.classes["@mozilla.org/atom-service;1"]. + getService(Components.interfaces.nsIAtomService); + prop.AppendElement(aServ.getAtom("color" + color.substr(1))); + } } } Zotero.ItemTreeView.prototype.getColumnProperties = function(col, prop) { } @@ -2663,27 +2727,3 @@ Zotero.ItemTreeView.TreeRow.prototype.getField = function(field, unformatted) { return this.ref.getField(field, unformatted, true); } - -Zotero.ItemTreeView.TreeRow.prototype.numChildren = function(includeTrashed) -{ - if(this.ref.isRegularItem()) - return this.ref.numChildren(includeTrashed); - else - return 0; -} - -Zotero.ItemTreeView.TreeRow.prototype.numNotes = function(includeTrashed) -{ - if(this.ref.isRegularItem()) - return this.ref.numNotes(includeTrashed); - else - return 0; -} - -Zotero.ItemTreeView.TreeRow.prototype.numAttachments = function(includeTrashed) -{ - if(this.ref.isRegularItem()) - return this.ref.numAttachments(includeTrashed); - else - return 0; -} diff --git a/chrome/content/zotero/xpcom/schema.js b/chrome/content/zotero/xpcom/schema.js @@ -225,6 +225,7 @@ Zotero.Schema = new function(){ finally { Zotero.UnresponsiveScriptIndicator.enable(); } + return up1 || up2 || up3 || up4; } diff --git a/chrome/content/zotero/xpcom/storage.js b/chrome/content/zotero/xpcom/storage.js @@ -81,120 +81,133 @@ Zotero.Sync.Storage = new function () { var _syncInProgress; var _updatesInProgress; var _changesMade; + var _resyncOnFinish; - var _session; - - var _callbacks = { - onSuccess: function () {}, - onSkip: function () {}, - onStop: function () {}, - onError: function () {}, - onWarning: function () {} - }; // // Public methods // - this.sync = function (module, callbacks) { - for (var func in callbacks) { - _callbacks[func] = callbacks[func]; - } - - _session = new Zotero.Sync.Storage.Session(module, { - onChangesMade: function () { - _changesMade = true; - }, - onError: _error - }); + this.sync = function (moduleName, observer) { + var module = getModuleFromName(moduleName); - if (!_session.enabled) { - Zotero.debug(_session.name + " file sync is not enabled"); - _callbacks.onSkip(); - return; - } - if (!_session.initFromPrefs()) { - Zotero.debug(_session.name + " module not initialized"); - _callbacks.onSkip(); - return; + if (!observer) { + throw new Error("Observer not provided"); } + registerDefaultObserver(moduleName); + Zotero.Sync.Storage.EventManager.registerObserver(observer, true, moduleName); - if (!_session.active) { - Zotero.debug(_session.name + " file sync is not active"); + if (!module.active) { + if (!module.enabled) { + Zotero.debug(module.name + " file sync is not enabled"); + Zotero.Sync.Storage.EventManager.skip(); + return; + } - var callback = function (uri, status) { - var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] - .getService(Components.interfaces.nsIWindowMediator); - var lastWin = wm.getMostRecentWindow("navigator:browser"); - - var success = _session.checkServerCallback(uri, status, lastWin, true); - if (success) { - Zotero.debug(_session.name + " file sync is successfully set up"); - Zotero.Sync.Storage.sync(module, callbacks); - } - else { - Zotero.debug(_session.name + " verification failed"); + Zotero.debug(module.name + " file sync is not active"); + + // Try to verify server now if it hasn't been + if (!module.verified) { + module.checkServer(function (uri, status) { + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var lastWin = wm.getMostRecentWindow("navigator:browser"); - var e = new Zotero.Error( - Zotero.getString('sync.storage.error.verificationFailed', _session.name), - 0, - { - dialogButtonText: Zotero.getString('sync.openSyncPreferences'), - dialogButtonCallback: function () { - var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] - .getService(Components.interfaces.nsIWindowMediator); - var lastWin = wm.getMostRecentWindow("navigator:browser"); - lastWin.ZoteroPane.openPreferences('zotero-prefpane-sync'); + var success = module.checkServerCallback(uri, status, lastWin, true); + if (success) { + Zotero.debug(module.name + " file sync is successfully set up"); + Zotero.Sync.Storage.sync(module.name); + } + else { + Zotero.debug(module.name + " verification failed"); + + var e = new Zotero.Error( + Zotero.getString('sync.storage.error.verificationFailed', module.name), + 0, + { + dialogButtonText: Zotero.getString('sync.openSyncPreferences'), + dialogButtonCallback: function () { + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var lastWin = wm.getMostRecentWindow("navigator:browser"); + lastWin.ZoteroPane.openPreferences('zotero-prefpane-sync'); + } } - } - ); - _callbacks.onError(e); - } + ); + Zotero.Sync.Storage.EventManager.error(e, true); + } + }); } - _session.checkServer(callback); + return; + } + + if ((!module.includeUserFiles || !Zotero.Sync.Storage.downloadOnSync()) + && (!module.includeGroupFiles || !Zotero.Sync.Storage.downloadOnSync('groups'))) { + Zotero.debug("No libraries are enabled for on-sync downloading"); + Zotero.Sync.Storage.EventManager.skip(); return; } if (_syncInProgress) { - _error("File sync operation already in progress"); + Zotero.Sync.Storage.EventManager.error( + "File sync operation already in progress" + ); } - Zotero.debug("Beginning " + _session.name + " file sync"); + Zotero.debug("Beginning " + module.name + " file sync"); _syncInProgress = true; _changesMade = false; try { - Zotero.Sync.Storage.checkForUpdatedFiles(null, null, _session.includeUserFiles, _session.includeGroupFiles); + Zotero.Sync.Storage.checkForUpdatedFiles( + null, + null, + module.includeUserFiles && Zotero.Sync.Storage.downloadOnSync(), + module.includeGroupFiles && Zotero.Sync.Storage.downloadOnSync('groups') + ); } catch (e) { - _syncInProgress = false; - throw (e); + Zotero.Sync.Storage.EventManager.error(e); } - var lastSyncCheckCallback = function (lastSyncTime) { - var downloadFiles = true; + var self = this; + + module.getLastSyncTime(function (lastSyncTime) { + // Register the observers again to make sure they're active when we + // start the queues. (They'll only be registered once.) Observers are + // cleared when all queues finish, so without this another sync + // process (e.g., on-demand download) could finish and clear all + // observers while getLastSyncTime() is running. + registerDefaultObserver(moduleName); + Zotero.Sync.Storage.EventManager.registerObserver(observer, true, moduleName); + + var download = true; var sql = "SELECT COUNT(*) FROM itemAttachments WHERE syncState=?"; var force = !!Zotero.DB.valueQuery(sql, Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD); if (!force && lastSyncTime) { - var sql = "SELECT version FROM version WHERE schema='storage_" + module + "'"; + var sql = "SELECT version FROM version WHERE schema='storage_" + moduleName + "'"; var version = Zotero.DB.valueQuery(sql); if (version == lastSyncTime) { - Zotero.debug("Last " + _session.name + " sync time hasn't changed -- skipping file download step"); - downloadFiles = false; + Zotero.debug("Last " + module.name + " sync time hasn't changed -- skipping file download step"); + download = false; } } - var activeDown = downloadFiles ? Zotero.Sync.Storage.downloadFiles() : false; - var activeUp = Zotero.Sync.Storage.uploadFiles(); + try { + var activeDown = download ? _downloadFiles(module) : false; + var activeUp = _uploadFiles(module); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } + if (!activeDown && !activeUp) { - _syncInProgress = false; - _callbacks.onSkip(); + Zotero.Sync.Storage.EventManager.skip(); + return; } - }; - - _session.getLastSyncTime(lastSyncCheckCallback); + }); } @@ -221,8 +234,10 @@ Zotero.Sync.Storage = new function () { break; default: - _error("Invalid sync state '" + syncState - + "' in Zotero.Sync.Storage.setSyncState()"); + Zotero.Sync.Storage.EventManager.error( + "Invalid sync state '" + syncState + + "' in Zotero.Sync.Storage.setSyncState()" + ); } var sql = "UPDATE itemAttachments SET syncState=? WHERE itemID=?"; @@ -239,8 +254,9 @@ Zotero.Sync.Storage = new function () { var sql = "SELECT storageModTime FROM itemAttachments WHERE itemID=?"; var mtime = Zotero.DB.valueQuery(sql, itemID); if (mtime === false) { - _error("Item " + itemID - + " not found in Zotero.Sync.Storage.getSyncedModificationTime()"); + Zotero.Sync.Storage.EventManager.error( + "Item " + itemID + " not found in Zotero.Sync.Storage.getSyncedModificationTime()" + ); } return mtime; } @@ -283,8 +299,9 @@ Zotero.Sync.Storage = new function () { var sql = "SELECT storageHash FROM itemAttachments WHERE itemID=?"; var hash = Zotero.DB.valueQuery(sql, itemID); if (hash === false) { - _error("Item " + itemID - + " not found in Zotero.Sync.Storage.getSyncedHash()"); + Zotero.Sync.Storage.EventManager.error( + "Item " + itemID + " not found in Zotero.Sync.Storage.getSyncedHash()" + ); } return hash; } @@ -354,6 +371,37 @@ Zotero.Sync.Storage = new function () { /** + * @param {NULL|Integer|'groups'} [libraryID] + */ + this.downloadAsNeeded = function (libraryID) { + // Personal library + if (libraryID == null) { + 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 {NULL|Integer|'groups'} [libraryID] + */ + this.downloadOnSync = function (libraryID) { + // Personal library + if (libraryID == null) { + 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 as 0 for uploading * and any that are missing as 1 for downloading * @@ -373,24 +421,22 @@ Zotero.Sync.Storage = new function () { * FALSE otherwise */ this.checkForUpdatedFiles = function (itemIDs, itemModTimes, includeUserFiles, includeGroupFiles) { - var funcName = "Zotero.Sync.Storage.checkForUpdatedFiles()"; - Zotero.debug("Checking for locally changed attachment files"); // check for current ops? if (itemIDs) { if (includeUserFiles || includeGroupFiles) { - _error("includeUserFiles and includeGroupFiles are not allowed when itemIDs is set in " + funcName); + throw new Error("includeUserFiles and includeGroupFiles are not allowed when itemIDs"); } } else { if (!includeUserFiles && !includeGroupFiles) { - _error("At least one of includeUserFiles or includeGroupFiles must be set in " + funcName); + return false; } } if (itemModTimes && !itemIDs) { - _error("itemModTimes can only be set if itemIDs is an array in " + funcName); + throw new Error("itemModTimes can only be set if itemIDs is an array"); } var changed = false; @@ -569,47 +615,95 @@ Zotero.Sync.Storage = new function () { /** - * Starts download of all attachments marked for download + * Download a single file * - * @return {Boolean} + * If no queue is active, start one. Otherwise, add to existing queue. */ - this.downloadFiles = function () { - if (!_syncInProgress) { - _syncInProgress = true; - } + this.downloadFile = function (item, requestCallbacks) { + var itemID = item.id; + var module = getModuleFromLibrary(item.libraryID); - var downloadFileIDs = _getFilesToDownload(_session.includeUserFiles, _session.includeGroupFiles); - if (!downloadFileIDs) { - Zotero.debug("No files to download"); + if (!module || !module.active) { + Zotero.debug("File syncing is not active for item's library -- skipping download"); return false; } - // Check for active operations? - var queue = Zotero.Sync.Storage.QueueManager.get('download'); - if (queue.isRunning()) { - throw ("Download queue already running in " - + "Zotero.Sync.Storage.downloadFiles()"); + if (!item.isImportedAttachment()) { + throw new Error("Not an imported attachment"); } - queue.reset(); - for each(var itemID in downloadFileIDs) { - var item = Zotero.Items.get(itemID); - if (Zotero.Sync.Storage.getSyncState(itemID) != - Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD - && this.isFileModified(itemID)) { - Zotero.debug("File for attachment " + itemID + " has been modified"); - this.setSyncState(itemID, this.SYNC_STATE_TO_UPLOAD); - continue; + if (item.getFile()) { + Zotero.debug("File already exists -- replacing"); + } + + var setup = function () { + Zotero.Sync.Storage.EventManager.registerObserver({ + onSuccess: function () _syncInProgress = false, + + onSkip: function () _syncInProgress = false, + + onStop: function () _syncInProgress = false, + + onError: function (e) { + Zotero.Sync.Runner.setSyncIcon('error', e); + error(e); + requestCallbacks.onStop(); + } + }, false, "downloadFile"); + + try { + var queue = Zotero.Sync.Storage.QueueManager.get('download'); + + var isRunning = queue.isRunning(); + if (!isRunning) { + _syncInProgress = true; + + // Reset the sync icon + Zotero.Sync.Runner.setSyncIcon(); + } + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); } - var request = new Zotero.Sync.Storage.Request( - item.libraryID + '/' + item.key, function (request) { _session.downloadFile(request); } - ); - queue.addRequest(request); - } + return isRunning; + }; + + var run = function () { + // We have to perform setup again at the same time that we add the + // request, because otherwise a sync process could complete while + // cacheCredentials() is running and clear the event handlers. + var isRunning = setup(); + + try { + var queue = Zotero.Sync.Storage.QueueManager.get('download'); + + if (!requestCallbacks) { + requestCallbacks = {}; + } + var onStart = function (request) { + module.downloadFile(request); + }; + requestCallbacks.onStart = requestCallbacks.onStart + ? [onStart, requestCallbacks.onStart] + : onStart; + + var request = new Zotero.Sync.Storage.Request( + item.libraryID + '/' + item.key, requestCallbacks + ); + + queue.addRequest(request, isRunning); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } + }; + + setup(); + module.cacheCredentials(function () { + run(); + }); - // Start downloads - queue.start(); return true; } @@ -625,19 +719,19 @@ Zotero.Sync.Storage = new function () { var funcName = "Zotero.Sync.Storage.processDownload()"; if (!data) { - _error("|data| not set in " + funcName); + Zotero.Sync.Storage.EventManager.error("|data| not set in " + funcName); } if (!data.item) { - _error("|data.item| not set in " + funcName); + Zotero.Sync.Storage.EventManager.error("|data.item| not set in " + funcName); } if (!data.syncModTime) { - _error("|data.syncModTime| not set in " + funcName); + Zotero.Sync.Storage.EventManager.error("|data.syncModTime| not set in " + funcName); } if (!data.compressed && !data.syncHash) { - _error("|data.syncHash| is required if |data.compressed| is false in " + funcName); + Zotero.Sync.Storage.EventManager.error("|data.syncHash| is required if |data.compressed| is false in " + funcName); } var item = data.item; @@ -705,7 +799,7 @@ Zotero.Sync.Storage = new function () { // 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); - Zotero.Sync.Storage.resyncOnFinish = true; + _resyncOnFinish = true; } else { file.lastModifiedTime = syncModTime; @@ -727,99 +821,52 @@ Zotero.Sync.Storage = new function () { } - /** - * Start upload of all attachments marked for upload - * - * @return {Boolean} - */ - this.uploadFiles = function () { - if (!_syncInProgress) { - _syncInProgress = true; - } - - var uploadFileIDs = _getFilesToUpload(_session.includeUserFiles, _session.includeGroupFiles); - if (!uploadFileIDs) { - Zotero.debug("No files to upload"); - return false; - } - - // Check for active operations? - var queue = Zotero.Sync.Storage.QueueManager.get('upload'); - if (queue.isRunning()) { - throw ("Upload queue already running in " - + "Zotero.Sync.Storage.uploadFiles()"); - } - queue.reset(); - - Zotero.debug(uploadFileIDs.length + " file(s) to upload"); - - for each(var itemID in uploadFileIDs) { - var item = Zotero.Items.get(itemID); - - var request = new Zotero.Sync.Storage.Request( - item.libraryID + '/' + item.key, function (request) { _session.uploadFile(request); } - ); - request.progressMax = Zotero.Attachments.getTotalFileSize(item, true); - queue.addRequest(request); - } - - // Start uploads - queue.start(); - return true; - } - - - this.checkServer = function (module, callback) { - _session = new Zotero.Sync.Storage.Session( - module, - { - onError: function (e) { - Zotero.debug(e, 1); - callback(null, null, e); - throw (e); - } + this.checkServer = function (moduleName, callback) { + var module = getModuleFromName(moduleName); + Zotero.Sync.Storage.EventManager.registerObserver({ + onSuccess: function () {}, + onError: function (e) { + Zotero.debug(e, 1); + callback(null, null, e); + throw (e); } - ); - _session.initFromPrefs(); - return _session.checkServer(callback); - } - - - this.checkServerCallback = function (uri, status, window, skipSuccessMessage, e) { - return _session.checkServerCallback(uri, status, window, skipSuccessMessage, e); + }); + return module.checkServer(function (uri, status) { + callback(uri, status, module.checkServerCallback); + }); } - this.purgeDeletedStorageFiles = function (module, callback) { - var session = new Zotero.Sync.Storage.Session(module, { onError: _error }); - if (!this.active) { - return; - } - if (!session.initFromPrefs()) { + this.purgeDeletedStorageFiles = function (moduleName, callback) { + var module = getModuleFromName(moduleName); + if (!module.active) { return; } - session.purgeDeletedStorageFiles(callback); + Zotero.Sync.Storage.EventManager.registerObserver({ + onError: function (e) { + error(e); + } + }); + module.purgeDeletedStorageFiles(callback); } - this.purgeOrphanedStorageFiles = function (module, callback) { - var session = new Zotero.Sync.Storage.Session(module, { onError: _error }); - if (!this.active) { - return; - } - if (!session.initFromPrefs()) { + this.purgeOrphanedStorageFiles = function (moduleName, callback) { + var module = getModuleFromName(moduleName); + if (!module.active) { return; } - session.purgeOrphanedStorageFiles(callback); + Zotero.Sync.Storage.EventManager.registerObserver({ + onError: function (e) { + error(e); + } + }); + module.purgeOrphanedStorageFiles(callback); } - this.isActive = function (module) { - var session = new Zotero.Sync.Storage.Session(module, { onError: _error }); - if (!session.initFromPrefs()) { - return; - } - return session.active; + this.isActive = function (moduleName) { + return getModuleFromName(moduleName).active; } @@ -871,6 +918,135 @@ Zotero.Sync.Storage = new function () { // // Private methods // + function getModuleFromName(moduleName) { + return new Zotero.Sync.Storage.Module(moduleName); + } + + + function getModuleFromLibrary(libraryID) { + if (libraryID === undefined) { + throw new Error("libraryID not provided"); + } + + // Personal library + if (libraryID === null) { + if (!Zotero.Prefs.get('sync.storage.enabled')) { + Zotero.debug('disabled'); + return false; + } + + var protocol = Zotero.Prefs.get('sync.storage.protocol'); + switch (protocol) { + case 'zotero': + return getModuleFromName('ZFS'); + + case 'webdav': + return getModuleFromName('WebDAV'); + + default: + throw new Error("Invalid storage protocol '" + protocol + "'"); + } + } + + // Group library + else { + if (!Zotero.Prefs.get('sync.storage.groups.enabled')) { + return false; + } + + return getModuleFromName('ZFS'); + } + } + + + /** + * Starts download of all attachments marked for download + * + * @return {Boolean} + */ + function _downloadFiles(module) { + if (!_syncInProgress) { + _syncInProgress = true; + } + + var downloadFileIDs = _getFilesToDownload( + module.includeUserFiles && Zotero.Sync.Storage.downloadOnSync(), + module.includeGroupFiles && Zotero.Sync.Storage.downloadOnSync('groups') + ); + if (!downloadFileIDs) { + Zotero.debug("No files to download"); + return false; + } + + // Check for active operations? + + var queue = Zotero.Sync.Storage.QueueManager.get('download'); + + for each(var itemID in downloadFileIDs) { + var item = Zotero.Items.get(itemID); + if (Zotero.Sync.Storage.getSyncState(itemID) != + Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD + && Zotero.Sync.Storage.isFileModified(itemID)) { + Zotero.debug("File for attachment " + itemID + " has been modified"); + Zotero.Sync.Storage.setSyncState(itemID, this.SYNC_STATE_TO_UPLOAD); + continue; + } + + var request = new Zotero.Sync.Storage.Request( + item.libraryID + '/' + item.key, + { + onStart: function (request) { + module.downloadFile(request); + } + } + ); + queue.addRequest(request); + } + + return true; + } + + + /** + * Start upload of all attachments marked for upload + * + * @return {Boolean} + */ + function _uploadFiles(module) { + if (!_syncInProgress) { + _syncInProgress = true; + } + + var uploadFileIDs = _getFilesToUpload(module.includeUserFiles, module.includeGroupFiles); + if (!uploadFileIDs) { + Zotero.debug("No files to upload"); + return false; + } + + // Check for active operations? + var queue = Zotero.Sync.Storage.QueueManager.get('upload'); + + Zotero.debug(uploadFileIDs.length + " file(s) to upload"); + + for each(var itemID in uploadFileIDs) { + var item = Zotero.Items.get(itemID); + + var request = new Zotero.Sync.Storage.Request( + item.libraryID + '/' + item.key, + { + onStart: function (request) { + module.uploadFile(request); + } + } + ); + request.progressMax = Zotero.Attachments.getTotalFileSize(item, true); + queue.addRequest(request); + } + + return true; + } + + function _processDownload(item) { var funcName = "Zotero.Sync.Storage._processDownload()"; @@ -1401,8 +1577,10 @@ Zotero.Sync.Storage = new function () { */ function _getFilesToDownload(includeUserFiles, includeGroupFiles) { if (!includeUserFiles && !includeGroupFiles) { - _error("At least one of includeUserFiles or includeGroupFiles must be set " - + "in Zotero.Sync.Storage._getFilesToDownload()"); + Zotero.Sync.Storage.EventManager.error( + "At least one of includeUserFiles or includeGroupFiles must be set " + + "in Zotero.Sync.Storage._getFilesToDownload()" + ); } var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) " @@ -1432,8 +1610,10 @@ Zotero.Sync.Storage = new function () { */ function _getFilesToUpload(includeUserFiles, includeGroupFiles) { if (!includeUserFiles && !includeGroupFiles) { - _error("At least one of includeUserFiles or includeGroupFiles must be set " - + "in Zotero.Sync.Storage._getFilesToUpload()"); + Zotero.Sync.Storage.EventManager.error( + "At least one of includeUserFiles or includeGroupFiles must be set " + + "in Zotero.Sync.Storage._getFilesToUpload()" + ); } var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) " @@ -1474,63 +1654,67 @@ Zotero.Sync.Storage = new function () { } - this.finish = function (cancelled, skipSuccessFile) { - if (!_syncInProgress) { - throw ("Sync not in progress in Zotero.Sync.Storage.finish()"); - } - - // Upload success file when done - if (!this.resyncOnFinish && !skipSuccessFile) { - // If we finished successfully and didn't upload any files, save the - // last sync time locally rather than setting a new one on the server, - // since we don't want other clients to check for new files - var uploadQueue = Zotero.Sync.Storage.QueueManager.get('upload'); - var useLastSyncTime = !cancelled && uploadQueue.totalRequests == 0; + function registerDefaultObserver(moduleName) { + var finish = function (cancelled, skipSuccessFile) { + // Upload success file when done + if (!_resyncOnFinish && !skipSuccessFile) { + // If we finished successfully and didn't upload any files, save the + // last sync time locally rather than setting a new one on the server, + // since we don't want other clients to check for new files + var uploadQueue = Zotero.Sync.Storage.QueueManager.get('upload', true); + var useLastSyncTime = !uploadQueue || (!cancelled && uploadQueue.lastTotalRequests == 0); + + getModuleFromName(moduleName).setLastSyncTime(function () { + finish(cancelled, true); + }, useLastSyncTime); + return false; + } - _session.setLastSyncTime(function () { - Zotero.Sync.Storage.finish(cancelled, true); - }, useLastSyncTime); - return; - } - - Zotero.debug(_session.name + " sync is complete"); - _syncInProgress = false; - - if (this.resyncOnFinish) { - Zotero.debug("Force-resyncing items in conflict"); - this.resyncOnFinish = false; - this.sync(_session.module, _callbacks); - return; - } - - _session = null; - - if (!_changesMade) { - Zotero.debug("No changes made during storage sync"); - } - - if (cancelled) { - _callbacks.onStop(); - return; - } - - if (!_changesMade) { - _callbacks.onSkip(); - return; - } + Zotero.debug(moduleName + " sync is complete"); + + _syncInProgress = false; + + if (_resyncOnFinish) { + Zotero.debug("Force-resyncing items in conflict"); + _resyncOnFinish = false; + Zotero.Sync.Storage.sync(moduleName); + return false; + } + + if (cancelled) { + Zotero.Sync.Storage.EventManager.stop(); + } + else if (!_changesMade) { + Zotero.debug("No changes made during storage sync"); + Zotero.Sync.Storage.EventManager.skip(); + } + else { + Zotero.Sync.Storage.EventManager.success(); + } + + return true; + }; - _callbacks.onSuccess(); + Zotero.Sync.Storage.EventManager.registerObserver({ + onSuccess: function () finish(), + + onSkip: function () function () { + _syncInProgress = false + }, + + onStop: function () finish(true), + + onError: function (e) error(e), + + onChangesMade: function () _changesMade = true + }, false, "default"); } - // - // Stop requests, log error, and - // - function _error(e) { + function error(e) { if (_syncInProgress) { Zotero.Sync.Storage.QueueManager.cancel(true); _syncInProgress = false; - _session = null; } Zotero.DB.rollbackAllTransactions(); @@ -1542,12 +1726,7 @@ Zotero.Sync.Storage = new function () { e = Zotero.Sync.Storage.defaultError; } - // If we get a quota error, log and continue - if (e.error && e.error == Zotero.Error.ERROR_ZFS_OVER_QUOTA && _callbacks.onWarning) { - _callbacks.onWarning(e); - _callbacks.onSuccess(); - } - else if (e.error && e.error == Zotero.Error.ERROR_ZFS_FILE_EDITING_DENIED) { + if (e.error && e.error == Zotero.Error.ERROR_ZFS_FILE_EDITING_DENIED) { setTimeout(function () { var group = Zotero.Groups.get(e.data.groupID); @@ -1575,806 +1754,9 @@ Zotero.Sync.Storage = new function () { return; } }, 1); - _callbacks.onError(e); - } - else if (_callbacks.onError) { - _callbacks.onError(e); - } - else { - throw (e); - } - } -} - - - - -Zotero.Sync.Storage.QueueManager = new function () { - var _queues = {}; - var _conflicts = []; - - - /** - * Retrieving a queue, creating a new one if necessary - * - * @param {String} queueName - */ - this.get = function (queueName) { - // Initialize the queue if it doesn't exist yet - if (!_queues[queueName]) { - var queue = new Zotero.Sync.Storage.Queue(queueName); - 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[queueName] = queue; - } - - return _queues[queueName]; - } - - - /** - * 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) { - this._cancelled = true; - if (skipStorageFinish) { - this._skipStorageFinish = true; - } - for each(var queue in _queues) { - if (!queue.isFinished() && !queue.isStopping()) { - queue.stop(); - } - } - _conflicts = []; - } - - - /** - * Tell the storage system that we're finished - */ - this.finish = function () { - if (_conflicts.length) { - var data = _reconcileConflicts(); - if (data) { - _processMergeData(data); - } - _conflicts = []; - } - - if (this._skipStorageFinish) { - this._cancelled = false; - this._skipStorageFinish = false; - return; - } - - Zotero.Sync.Storage.finish(this._cancelled); - this._cancelled = false; - } - - - /** - * 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.isFinished() || (!queue.isRunning() && !queue.isStopping())) { - continue; - } - allFinished = false; - activeRequests += queue.activeRequests; - } - if (activeRequests == 0) { - this.updateProgressMeters(0); - if (allFinished) { - this.finish(); - } - return; - } - - // Percentage - var percentageSum = 0; - var numQueues = 0; - for each(var queue in _queues) { - percentageSum += queue.percentage; - numQueues++; - } - var percentage = Math.round(percentageSum / numQueues); - //Zotero.debug("Total percentage is " + percentage); - - // Remaining KB - var downloadStatus = _queues.download ? - _getQueueStatus(_queues.download) : 0; - var uploadStatus = _queues.upload ? - _getQueueStatus(_queues.upload) : 0; - - this.updateProgressMeters( - activeRequests, percentage, downloadStatus, uploadStatus - ); - } - - - /** - * Cycle through windows, updating progress meters with new values - */ - this.updateProgressMeters = function (activeRequests, percentage, downloadStatus, uploadStatus) { - 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; - - // - // TODO: Move to overlay.js? - // - 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 tooltip = doc. - getElementById("zotero-tb-sync-progress-tooltip-progress"); - tooltip.setAttribute("value", percentage + "%"); - - var tooltip = doc. - getElementById("zotero-tb-sync-progress-tooltip-downloads"); - tooltip.setAttribute("value", downloadStatus); - - var tooltip = doc. - getElementById("zotero-tb-sync-progress-tooltip-uploads"); - tooltip.setAttribute("value", uploadStatus); - } - } - - - this.addConflict = function (requestName, localData, remoteData) { - Zotero.debug('==========='); - Zotero.debug(localData); - Zotero.debug(remoteData); - - _conflicts.push({ - name: requestName, - localData: localData, - remoteData: remoteData - }); - } - - - /** - * 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') - } - - var kbRemaining = Zotero.getString( - 'sync.storage.kbRemaining', - Zotero.Utilities.numberFormat(remaining / 1024, 0) - ); - var totalRequests = queue.totalRequests; - var filesRemaining = Zotero.getString( - 'sync.storage.filesRemaining', - [totalRequests - unfinishedRequests, totalRequests] - ); - var status = Zotero.localeJoin([kbRemaining, '(' + filesRemaining + ')']); - return status; - } - - - function _reconcileConflicts() { - var objectPairs = []; - for each(var conflict in _conflicts) { - var item = Zotero.Sync.Storage.getItemFromRequestName(conflict.name); - var item1 = item.clone(false, false, true); - item1.setField('dateModified', - Zotero.Date.dateToSQL(new Date(conflict.localData.modTime), true)); - var item2 = 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; - } - - Zotero.Sync.Storage.resyncOnFinish = true; - - 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 - ); - } - } - } -} - - - -/** - * Queue for storage sync transfer requests - * - * @param {String} name Queue name (e.g., 'download' or 'upload') - */ -Zotero.Sync.Storage.Queue = function (name) { - Zotero.debug("Initializing " + name + " queue"); - - // - // Public properties - // - this.name = name; - this.__defineGetter__('Name', function () { - return this.name[0].toUpperCase() + this.name.substr(1); - }); - this.maxConcurrentRequests = 1; - - this.__defineGetter__('running', function () _running); - this.__defineGetter__('stopping', function () _stopping); - this.activeRequests = 0; - this.__defineGetter__('finishedRequests', function () { - return _finishedReqs; - }); - this.__defineSetter__('finishedRequests', function (val) { - Zotero.debug("Finished requests: " + val); - Zotero.debug("Total requests: " + this.totalRequests); - - _finishedReqs = val; - - if (val == 0) { - return; - } - - // Last request - if (val == this.totalRequests) { - Zotero.debug(this.Name + " queue is done"); - // DEBUG info - Zotero.debug("Active requests: " + this.activeRequests); - if (this._errors) { - Zotero.debug("Errors:"); - Zotero.debug(this._errors); - } - - if (this.activeRequests) { - throw (this.Name + " queue can't be finished if there " - + "are active requests in Zotero.Sync.Storage.finishedRequests"); - } - - this._running = false; - this._stopping = false; - this._finished = true; - return; - } - - if (this.isStopping() || this.isFinished()) { - return; - } - this.advance(); - }); - this.totalRequests = 0; - - this.__defineGetter__('unfinishedRequests', function () { - return this.totalRequests - this.finishedRequests; - }); - this.__defineGetter__('queuedRequests', function () { - return this.unfinishedRequests - this.activeRequests; - }); - this.__defineGetter__('remaining', function () { - var remaining = 0; - for each(var request in this._requests) { - remaining += request.remaining; - } - return remaining; - }); - this.__defineGetter__('percentage', function () { - if (this.totalRequests == 0) { - return 0; - } - - var completedRequests = 0; - for each(var request in this._requests) { - completedRequests += request.percentage / 100; - } - return Math.round((completedRequests / this.totalRequests) * 100); - }); - - - // - // Private properties - // - this._requests = {}; - this._running = false; - this._errors = []; - this._stopping = false; - this._finished = false; - - var _finishedReqs = 0; -} - - -Zotero.Sync.Storage.Queue.prototype.isRunning = function () { - return this._running; -} - -Zotero.Sync.Storage.Queue.prototype.isStopping = function () { - return this._stopping; -} - -Zotero.Sync.Storage.Queue.prototype.isFinished = function () { - return this._finished; -} - -/** - * Add a request to this queue - * - * @param {Zotero.Sync.Storage.Request} request - */ -Zotero.Sync.Storage.Queue.prototype.addRequest = function (request) { - if (this.isRunning()) { - throw ("Can't add request after queue started"); - } - if (this.isFinished()) { - throw ("Can't add request after queue finished"); - } - - request.queue = this; - var name = request.name; - Zotero.debug("Queuing " + this.name + " request '" + name + "'"); - - if (this._requests[name]) { - throw (this.name + " request '" + name + "' already exists in " - + "Zotero.Sync.Storage.Queue.addRequest()"); - } - - this._requests[name] = request; - this.totalRequests++; -} - - -/** - * Starts this queue - */ -Zotero.Sync.Storage.Queue.prototype.start = function () { - if (this._running) { - throw (this.Name + " queue is already running in " - + "Zotero.Sync.Storage.Queue.start()"); - } - - if (!this.queuedRequests) { - Zotero.debug("No requests to start in " + this.name + " queue"); - return; - } - - this._running = true; - this.advance(); -} - - -Zotero.Sync.Storage.Queue.prototype.logError = function (msg) { - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - // TODO: necessary? - this._errors.push(msg); -} - - -/** - * Start another request in this queue if there's an available slot - */ -Zotero.Sync.Storage.Queue.prototype.advance = function () { - if (this._stopping) { - Zotero.debug(this.Name + " queue is being stopped in " - + "Zotero.Sync.Storage.Queue.advance()", 2); - return; - } - if (this._finished) { - Zotero.debug(this.Name + " queue already finished " - + "Zotero.Sync.Storage.Queue.advance()", 2); - return; - } - - if (!this.queuedRequests) { - Zotero.debug("No remaining requests in " + this.name + " queue (" - + this.activeRequests + " active, " - + this.finishedRequests + " finished)"); - return; - } - - if (this.activeRequests >= this.maxConcurrentRequests) { - Zotero.debug(this.Name + " queue is busy (" - + this.activeRequests + "/" + this.maxConcurrentRequests + ")"); - return; - } - - for each(var request in this._requests) { - if (!request.isRunning() && !request.isFinished()) { - request.start(); - - var self = this; - - // Wait a second and then try starting another - setTimeout(function () { - if (self.isStopping() || self.isFinished()) { - return; - } - self.advance(); - }, 1000); - return; - } - } -} - - -Zotero.Sync.Storage.Queue.prototype.updateProgress = function () { - Zotero.Sync.Storage.QueueManager.updateProgress(); -} - - -/** - * Stops all requests in this queue - */ -Zotero.Sync.Storage.Queue.prototype.stop = function () { - if (this._stopping) { - Zotero.debug("Already stopping " + this.name + " queue"); - return; - } - if (this._finished) { - Zotero.debug(this.Name + " queue is already finished"); - return; - } - - // 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(); - } - } -} - - -/** - * Clears queue state data - */ -Zotero.Sync.Storage.Queue.prototype.reset = function () { - Zotero.debug("Resetting " + this.name + " queue"); - - if (this._running) { - throw ("Can't reset running queue in Zotero.Sync.Storage.Queue.reset()"); - } - if (this._stopping) { - throw ("Can't reset stopping queue in Zotero.Sync.Storage.Queue.reset()"); - } - - this._finished = false; - this._requests = {}; - this._errors = []; - this.activeRequests = 0; - this.finishedRequests = 0; - this.totalRequests = 0; -} - - - - -/** - * Updates multiplier applied to estimated sizes - * - * Also updates progress meter - */ - /* -function _updateSizeMultiplier(mult) { - var previousMult = _requestSizeMultiplier; - _requestSizeMultiplier = mult; - for (var queue in _requests) { - for (var name in _requests[queue]) { - var r = _requests[queue][name]; - if (r.progressMax > 0 || !r.size) { - continue; - } - // Remove previous estimated size and add new one - _totalProgressMax[queue] += Math.round(r.size * previousMult) * -1 - + Math.round(r.size * mult); } } - _updateProgressMeter(); } -*/ - - - -/** - * Transfer request for storage sync - * - * @param {String} name Identifier for request (e.g., "[libraryID]/[key]") - * @param {Function} onStart Callback when request is started - */ -Zotero.Sync.Storage.Request = function (name, onStart) { - Zotero.debug("Initializing request '" + name + "'"); - - this.name = name; - this.channel = null; - this.queue = null; - this.progress = 0; - this.progressMax = 0; - - this._running = false; - this._onStart = onStart; - this._percentage = 0; - this._remaining = null; - this._finished = false; -} - - -Zotero.Sync.Storage.Request.prototype.__defineGetter__('percentage', function () { - if (this.progressMax == 0) { - return 0; - } - - var percentage = Math.round((this.progress / this.progressMax) * 100); - if (percentage < this._percentage) { - Zotero.debug(percentage + " is less than last percentage of " - + this._percentage + " for request '" + this.name + "'", 2); - Zotero.debug(this.progress); - Zotero.debug(this.progressMax); - percentage = this._percentage; - } - else if (percentage > 100) { - Zotero.debug(percentage + " is greater than 100 for " - + this.name + " request", 2); - Zotero.debug(this.progress); - Zotero.debug(this.progressMax); - percentage = 100; - } - else { - this._percentage = percentage; - } - //Zotero.debug("Request '" + this.name + "' percentage is " + percentage); - return percentage; -}); - - -Zotero.Sync.Storage.Request.prototype.__defineGetter__('remaining', function () { - if (!this.progressMax) { - //Zotero.debug("Remaining not yet available for request '" + this.name + "'"); - return 0; - } - - var remaining = this.progressMax - this.progress; - if (this._remaining === null) { - this._remaining = remaining; - } - else if (remaining > this._remaining) { - Zotero.debug(remaining + " is greater than the last remaining amount of " - + this._remaining + " for request " + this.name); - remaining = this._remaining; - } - else if (remaining < 0) { - Zotero.debug(remaining + " is less than 0 for request " + this.name); - } - else { - this._remaining = remaining; - } - //Zotero.debug("Request '" + this.name + "' remaining is " + remaining); - return remaining; -}); - - -Zotero.Sync.Storage.Request.prototype.setChannel = function (channel) { - this.channel = channel; -} - - -Zotero.Sync.Storage.Request.prototype.start = function () { - if (!this.queue) { - throw ("Request '" + this.name + "' must be added to a queue before starting"); - } - - if (this._running) { - throw ("Request '" + this.name + "' already running in " - + "Zotero.Sync.Storage.Request.start()"); - } - - Zotero.debug("Starting " + this.queue.name + " request '" + this.name + "'"); - this._running = true; - this.queue.activeRequests++; - this._onStart(this); -} - - -Zotero.Sync.Storage.Request.prototype.isRunning = function () { - return this._running; -} - - -Zotero.Sync.Storage.Request.prototype.isFinished = function () { - return this._finished; -} - - -/** - * Update counters for given request - * - * Also updates progress meter - * - * @param {Integer} progress Progress so far - * (usually bytes transferred) - * @param {Integer} progressMax Max progress value for this request - * (usually total bytes) - */ -Zotero.Sync.Storage.Request.prototype.onProgress = function (channel, progress, progressMax) { - if (!this._running) { - Zotero.debug("Trying to update finished request " + this.name + " in " - + "Zotero.Sync.Storage.Request.onProgress() " - + "(" + progress + "/" + progressMax + ")", 2); - 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) { - Zotero.debug("Invalid progress for request '" - + this.name + "' (" + progress + " < " + this.progress + ")"); - return; - } - - if (progressMax != this.progressMax) { - Zotero.debug("progressMax has changed from " + this.progressMax - + " to " + progressMax + " for request '" + this.name + "'", 2); - } - - this.progress = progress; - this.progressMax = progressMax; - this.queue.updateProgress(); -} - - -Zotero.Sync.Storage.Request.prototype.error = function (msg) { - msg = typeof msg == 'object' ? msg.message : msg; - - this.queue.logError(msg); - - // DEBUG: ever need to stop channel? - this.finish(); -} - - -/** - * Stop the request's underlying network request, if there is one - */ -Zotero.Sync.Storage.Request.prototype.stop = function () { - var finishNow = false; - try { - // If upload already finished, finish() will never be called otherwise - if (this.channel) { - this.channel.QueryInterface(Components.interfaces.nsIHttpChannel); - // Throws error if request not finished - this.channel.requestSucceeded; - Zotero.debug("Channel is no longer running for request " + this.name); - Zotero.debug(this.channel.requestSucceeded); - finishNow = true; - } - } - catch (e) {} - - if (!this._running || !this.channel || finishNow) { - this.finish(); - return; - } - - Zotero.debug("Stopping request '" + this.name + "'"); - this.channel.cancel(0x804b0002); // NS_BINDING_ABORTED -} - - -/** - * Mark request as finished and notify queue that it's done - */ -Zotero.Sync.Storage.Request.prototype.finish = function () { - if (this._finished) { - throw ("Request '" + this.name + "' is already finished"); - } - - Zotero.debug("Finishing " + this.queue.name + " request '" + this.name + "'"); - - this._finished = true; - var active = this._running; - this._running = false; - - if (active) { - this.queue.activeRequests--; - } - // mechanism for failures? - this.queue.finishedRequests++; - this.queue.updateProgress(); -} - - /** @@ -2429,215 +1811,3 @@ Zotero.Sync.Storage.ZipWriterObserver.prototype = { this._callback(this._data); } } - - -/** - * Stream listener that can handle both download and upload requests - * - * Possible properties of data object: - * - onStart: f(request) - * - onProgress: f(request, progress, progressMax) - * - onStop: f(request, status, response, data) - * - onCancel: f(request, status, data) - * - streams: array of streams to close on completion - * - Other values to pass to onStop() - */ -Zotero.Sync.Storage.StreamListener = function (data) { - this._data = data; -} - -Zotero.Sync.Storage.StreamListener.prototype = { - _channel: null, - - // nsIProgressEventSink - onProgress: function (request, context, progress, progressMax) { - // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=451991 - // (fixed in Fx3.1) - if (progress > progressMax) { - progress = progressMax; - } - //Zotero.debug("onProgress with " + progress + "/" + progressMax); - this._onProgress(request, progress, progressMax); - }, - - onStatus: function (request, context, status, statusArg) { - //Zotero.debug('onStatus'); - }, - - // nsIRequestObserver - // Note: For uploads, this isn't called until data is done uploading - onStartRequest: function (request, context) { - Zotero.debug('onStartRequest'); - this._response = ""; - - this._onStart(request); - }, - - onStopRequest: function (request, context, status) { - Zotero.debug('onStopRequest'); - - switch (status) { - case 0: - case 0x804b0002: // NS_BINDING_ABORTED - this._onDone(request, status); - break; - - default: - throw ("Unexpected request status " + status - + " in Zotero.Sync.Storage.StreamListener.onStopRequest()"); - } - }, - - // nsIWebProgressListener - onProgressChange: function (wp, request, curSelfProgress, - maxSelfProgress, curTotalProgress, maxTotalProgress) { - //Zotero.debug("onProgressChange with " + curTotalProgress + "/" + maxTotalProgress); - - // onProgress gets called too, so this isn't necessary - //this._onProgress(request, curTotalProgress, maxTotalProgress); - }, - - onStateChange: function (wp, request, stateFlags, status) { - Zotero.debug("onStateChange"); - - 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._onDone(request, status); - } - }, - - onStatusChange: function (progress, request, status, message) { - Zotero.debug("onStatusChange with '" + message + "'"); - }, - onLocationChange: function () {}, - onSecurityChange: function () {}, - - // nsIStreamListener - onDataAvailable: function (request, context, stream, sourceOffset, length) { - Zotero.debug('onDataAvailable'); - var scriptableInputStream = - Components.classes["@mozilla.org/scriptableinputstream;1"] - .createInstance(Components.interfaces.nsIScriptableInputStream); - scriptableInputStream.init(stream); - - this._response += scriptableInputStream.read(length); - }, - - // nsIChannelEventSink - onChannelRedirect: function (oldChannel, newChannel, flags) { - Zotero.debug('onChannelRedirect'); - - // 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); - }, - - // nsIHttpEventSink - onRedirect: function (oldChannel, newChannel) { - Zotero.debug('onRedirect'); - }, - - - // - // Private methods - // - _onStart: function (request) { - //Zotero.debug('Starting request'); - if (this._data && this._data.onStart) { - var data = this._getPassData(); - this._data.onStart(request, data); - } - }, - - _onProgress: function (request, progress, progressMax) { - if (this._data && this._data.onProgress) { - this._data.onProgress(request, progress, progressMax); - } - }, - - _onDone: function (request, status) { - var cancelled = status == 0x804b0002; // NS_BINDING_ABORTED - - if (!cancelled && request instanceof Components.interfaces.nsIHttpChannel) { - request.QueryInterface(Components.interfaces.nsIHttpChannel); - status = request.responseStatus; - request.QueryInterface(Components.interfaces.nsIRequest); - } - - if (this._data.streams) { - for each(var stream in this._data.streams) { - stream.close(); - } - } - - var data = this._getPassData(); - - if (cancelled) { - if (this._data.onCancel) { - this._data.onCancel(request, status, data); - } - } - else { - if (this._data.onStop) { - this._data.onStop(request, status, this._response, data); - } - } - - 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 { - return this.QueryInterface(iid); - } - catch (e) { - throw Components.results.NS_NOINTERFACE; - } - }, - - QueryInterface: function(iid) { - if (iid.equals(Components.interfaces.nsISupports) || - iid.equals(Components.interfaces.nsIInterfaceRequestor) || - iid.equals(Components.interfaces.nsIChannelEventSink) || - iid.equals(Components.interfaces.nsIProgressEventSink) || - iid.equals(Components.interfaces.nsIHttpEventSink) || - iid.equals(Components.interfaces.nsIStreamListener) || - iid.equals(Components.interfaces.nsIWebProgressListener)) { - return this; - } - throw Components.results.NS_NOINTERFACE; - }, - - _safeSpec: function (uri) { - return uri.scheme + '://' + uri.username + ':********@' - + uri.hostPort + uri.path - }, -}; - diff --git a/chrome/content/zotero/xpcom/storage/eventManager.js b/chrome/content/zotero/xpcom/storage/eventManager.js @@ -0,0 +1,143 @@ +/* + ***** 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.EventManager = (function () { + var _observers = []; + + function call(handler, data, clear) { + Zotero.debug("Calling storage sync " + handler + " handlers"); + + var observers = _observers; + var cont = true; + var handled = false; + + if (clear) { + Zotero.Sync.Storage.EventManager.clear(); + } + + // Process most recently assigned observers first + for (var i = observers.length - 1; i >= 0; i--) { + let observer = observers[i].observer; + let j = i; + if (observer[handler]) { + handled = true; + if (observers[i].async) { + setTimeout(function () { + Zotero.debug("Calling " + handler + " handler " + j); + var cont = observer[handler](data); + if (cont === false) { + throw new Error("Cannot cancel events from async observer"); + } + }, 0); + } + else { + Zotero.debug("Calling " + handler + " handler " + j); + var cont = observer[handler](data); + // If handler returns explicit false, cancel further events + if (cont === false) { + break; + } + } + } + } + + if (!handled && data) { + var msg = "Unhandled storage sync event: " + data; + Zotero.debug(msg, 1); + if (handler == 'onError') { + throw new Error(msg); + } + else { + Components.utils.reportError(msg); + } + } + + // Throw errors to stop execution + if (handler == 'onError') { + if (!data) { + throw new Error("Data not provided for error"); + } + + if (cont !== false) { + throw (data); + } + } + } + + return { + registerObserver: function (observer, async, id) { + var pos = -1; + + if (id) { + for (var i = 0, len = _observers.length; i < len; i++) { + var o = _observers[i]; + if (o.id === id && o.async == async) { + pos = o; + break; + } + } + } + + if (pos == -1) { + Zotero.debug("Registering storage sync event observer '" + id + "'"); + _observers.push({ + observer: observer, + async: !!async, + id: id + }); + } + else { + Zotero.debug("Replacing storage sync event observer '" + id + "'"); + _observers[pos] = { + observer: observer, + async: !!async, + id: id + }; + } + }, + + success: function () call('onSuccess', false, true), + skip: function (clear) call('onSkip', false, true), + stop: function () call('onStop', false, true), + error: function (e) call('onError', e, true), + + warning: function (e) call('onWarning', e), + changesMade: function () call('onChangesMade'), + + clear: function () { + var queues = Zotero.Sync.Storage.QueueManager.getAll(); + for each(var queue in queues) { + if (queue.isRunning()) { + Zotero.debug(queue[0].toUpperCase() + queue.substr(1) + + " queue not empty -- not clearing storage sync event observers"); + return; + } + } + + Zotero.debug("Clearing storage sync event observers"); + _observers = []; + } + }; +}()); diff --git a/chrome/content/zotero/xpcom/storage/module.js b/chrome/content/zotero/xpcom/storage/module.js @@ -0,0 +1,198 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2009 Center for History and New Media + George Mason University, Fairfax, Virginia, USA + http://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see <http://www.gnu.org/licenses/>. + + ***** END LICENSE BLOCK ***** +*/ + + +Zotero.Sync.Storage.Module = function (moduleName) { + switch (moduleName) { + case 'ZFS': + this._module = Zotero.Sync.Storage.Module.ZFS; + break; + + case 'WebDAV': + this._module = Zotero.Sync.Storage.Module.WebDAV; + break; + + default: + throw ("Invalid storage session module '" + moduleName + "'"); + } +}; + +Zotero.Sync.Storage.Module.prototype.__defineGetter__('name', function () this._module.name); +Zotero.Sync.Storage.Module.prototype.__defineGetter__('includeUserFiles', function () this._module.includeUserFiles); +Zotero.Sync.Storage.Module.prototype.__defineGetter__('includeGroupFiles', function () this._module.includeGroupFiles); + +Zotero.Sync.Storage.Module.prototype.__defineGetter__('enabled', function () { + try { + return this._module.enabled; + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +}); + +Zotero.Sync.Storage.Module.prototype.__defineGetter__('verified', function () { + try { + return this._module.verified; + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +}); + +Zotero.Sync.Storage.Module.prototype.__defineGetter__('active', function () { + try { + return this._module.enabled && this._module.initFromPrefs() && this._module.verified; + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +}); + +Zotero.Sync.Storage.Module.prototype.__defineGetter__('username', function () { + try { + return this._module.username; + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +}); + +Zotero.Sync.Storage.Module.prototype.__defineGetter__('password', function () { + try { + return this._module.password; + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +}); + +Zotero.Sync.Storage.Module.prototype.__defineSetter__('password', function (val) { + try { + this._module.password = val; + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +}); + + +Zotero.Sync.Storage.Module.prototype.init = function () { + try { + return this._module.init(); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.initFromPrefs = function () { + try { + return this._module.initFromPrefs(); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.downloadFile = function (request) { + try { + this._module.downloadFile(request); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.uploadFile = function (request) { + try { + this._module.uploadFile(request); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.getLastSyncTime = function (callback) { + try { + this._module.getLastSyncTime(callback); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.setLastSyncTime = function (callback, useLastSyncTime) { + try { + this._module.setLastSyncTime(callback, useLastSyncTime); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.checkServer = function (callback) { + try { + return this._module.checkServer(callback); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.checkServerCallback = function (uri, status, window, skipSuccessMessage) { + try { + return this._module.checkServerCallback(uri, status, authRequired, window, skipSuccessMessage); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.cacheCredentials = function (callback) { + try { + return this._module.cacheCredentials(callback); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.purgeDeletedStorageFiles = function (callback) { + try { + this._module.purgeDeletedStorageFiles(callback); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} + +Zotero.Sync.Storage.Module.prototype.purgeOrphanedStorageFiles = function (callback) { + try { + this._module.purgeOrphanedStorageFiles(callback); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } +} diff --git a/chrome/content/zotero/xpcom/storage/queue.js b/chrome/content/zotero/xpcom/storage/queue.js @@ -0,0 +1,258 @@ +/* + ***** 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} name Queue name (e.g., 'download' or 'upload') + */ +Zotero.Sync.Storage.Queue = function (name) { + Zotero.debug("Initializing " + name + " queue"); + + // Public properties + this.name = name; + this.maxConcurrentRequests = 1; + this.activeRequests = 0; + this.totalRequests = 0; + + // Private properties + this._requests = {}; + this._highPriority = []; + this._running = false; + this._stopping = false; + this._finishedReqs = 0; + this._lastTotalRequests = 0; +} + +Zotero.Sync.Storage.Queue.prototype.__defineGetter__('Name', function () { + return this.name[0].toUpperCase() + this.name.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__('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.Name + " queue is done"); + + // DEBUG info + Zotero.debug("Active requests: " + this.activeRequests); + + if (this.activeRequests) { + throw new Error(this.Name + " queue can't be done if there are active requests"); + } + + this._running = false; + this._stopping = false; + this._requests = {}; + this._highPriority = []; + this._finishedReqs = 0; + this._lastTotalRequests = this.totalRequests; + this.totalRequests = 0; + + return; + } + + if (this._stopping) { + return; + } + this.advance(); +}); + +Zotero.Sync.Storage.Queue.prototype.__defineGetter__('lastTotalRequests', function () { + return this._lastTotalRequests; +}); + +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; + } + + var completedRequests = 0; + for each(var request in this._requests) { + completedRequests += request.percentage / 100; + } + return Math.round((completedRequests / this.totalRequests) * 100); +}); + + +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) { + request.queue = this; + var name = request.name; + Zotero.debug("Queuing " + this.name + " request '" + name + "'"); + + 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); + } + + this.advance(); +} + + +/** + * Start another request in this queue if there's an available slot + */ +Zotero.Sync.Storage.Queue.prototype.advance = function () { + this._running = true; + + if (this._stopping) { + Zotero.debug(this.Name + " queue is being stopped in " + + "Zotero.Sync.Storage.Queue.advance()", 2); + return; + } + + if (!this.queuedRequests) { + Zotero.debug("No remaining requests in " + this.name + " queue (" + + this.activeRequests + " active, " + + this.finishedRequests + " finished)"); + return; + } + + if (this.activeRequests >= this.maxConcurrentRequests) { + Zotero.debug(this.Name + " queue is busy (" + + this.activeRequests + "/" + this.maxConcurrentRequests + ")"); + return; + } + + // Start the first unprocessed request + + // Try the high-priority queue first + var name, request; + while (name = this._highPriority.shift()) { + request = this._requests[name]; + if (!request.isRunning() && !request.isFinished()) { + request.start(); + this.advance(); + return; + } + } + + // And then others + for each(request in this._requests) { + if (!request.isRunning() && !request.isFinished()) { + request.start(); + this.advance(); + return; + } + } +} + + +Zotero.Sync.Storage.Queue.prototype.updateProgress = function () { + Zotero.Sync.Storage.QueueManager.updateProgress(); +} + + +Zotero.Sync.Storage.Queue.prototype.error = function (e) { + Zotero.Sync.Storage.EventManager.error(e); +} + + +/** + * Stops all requests in this queue + */ +Zotero.Sync.Storage.Queue.prototype.stop = function () { + if (!this._running) { + Zotero.debug(this.Name + " queue is not running"); + return; + } + if (this._stopping) { + Zotero.debug("Already stopping " + this.name + " queue"); + return; + } + + // 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(); + } + } +} diff --git a/chrome/content/zotero/xpcom/storage/queueManager.js b/chrome/content/zotero/xpcom/storage/queueManager.js @@ -0,0 +1,314 @@ +/* + ***** 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 _conflicts = []; + var _cancelled = false; + + + /** + * Retrieving a queue, creating a new one if necessary + * + * @param {String} queueName + */ + this.get = function (queueName, noInit) { + // Initialize the queue if it doesn't exist yet + if (!_queues[queueName]) { + if (noInit) { + return false; + } + var queue = new Zotero.Sync.Storage.Queue(queueName); + 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[queueName] = queue; + } + + return _queues[queueName]; + } + + + this.getAll = function () { + var queues = []; + for each(var queue in _queues) { + queues.push(queue); + } + return queues; + }; + + + /** + * 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"); + _cancelled = true; + for each(var queue in _queues) { + if (queue.isRunning() && !queue.isStopping()) { + queue.stop(); + } + } + } + + + this.finish = function () { + Zotero.debug("All storage queues are finished"); + + if (!_cancelled && _conflicts.length) { + var data = _reconcileConflicts(); + if (data) { + _processMergeData(data); + } + } + + try { + if (_cancelled) { + Zotero.Sync.Storage.EventManager.stop(); + } + else { + Zotero.Sync.Storage.EventManager.success(); + } + } + finally { + _cancelled = false; + _conflicts = []; + } + } + + + /** + * 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) { + this.updateProgressMeters(0); + if (allFinished) { + this.finish(); + } + return; + } + + // Percentage + var percentageSum = 0; + var numQueues = 0; + for each(var queue in _queues) { + percentageSum += queue.percentage; + numQueues++; + } + var percentage = Math.round(percentageSum / numQueues); + //Zotero.debug("Total percentage is " + percentage); + + // Remaining KB + var downloadStatus = _queues.download ? + _getQueueStatus(_queues.download) : 0; + var uploadStatus = _queues.upload ? + _getQueueStatus(_queues.upload) : 0; + + this.updateProgressMeters( + activeRequests, percentage, downloadStatus, uploadStatus + ); + } + + + /** + * Cycle through windows, updating progress meters with new values + */ + this.updateProgressMeters = function (activeRequests, percentage, downloadStatus, uploadStatus) { + 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; + + // + // TODO: Move to overlay.js? + // + 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 tooltip = doc. + getElementById("zotero-tb-sync-progress-tooltip-progress"); + tooltip.setAttribute("value", percentage + "%"); + + var tooltip = doc. + getElementById("zotero-tb-sync-progress-tooltip-downloads"); + tooltip.setAttribute("value", downloadStatus); + + var tooltip = doc. + getElementById("zotero-tb-sync-progress-tooltip-uploads"); + tooltip.setAttribute("value", uploadStatus); + } + } + + + this.addConflict = function (requestName, localData, remoteData) { + Zotero.debug('==========='); + Zotero.debug(localData); + Zotero.debug(remoteData); + + _conflicts.push({ + name: requestName, + localData: localData, + remoteData: remoteData + }); + } + + + /** + * 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') + } + + var kbRemaining = Zotero.getString( + 'sync.storage.kbRemaining', + Zotero.Utilities.numberFormat(remaining / 1024, 0) + ); + var totalRequests = queue.totalRequests; + var filesRemaining = Zotero.getString( + 'sync.storage.filesRemaining', + [totalRequests - unfinishedRequests, totalRequests] + ); + var status = Zotero.localeJoin([kbRemaining, '(' + filesRemaining + ')']); + return status; + } + + + function _reconcileConflicts() { + var objectPairs = []; + for each(var conflict in _conflicts) { + var item = Zotero.Sync.Storage.getItemFromRequestName(conflict.name); + var item1 = item.clone(false, false, true); + item1.setField('dateModified', + Zotero.Date.dateToSQL(new Date(conflict.localData.modTime), true)); + var item2 = 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; + } + + Zotero.Sync.Storage.resyncOnFinish = true; + + 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/request.js b/chrome/content/zotero/xpcom/storage/request.js @@ -0,0 +1,288 @@ +/* + ***** 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 ***** +*/ + + +/** + * 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 {Function} onStop Callback to run when request stops + */ +Zotero.Sync.Storage.Request = function (name, callbacks) { + Zotero.debug("Initializing request '" + name + "'"); + + this.callbacks = ['onStart', 'onProgress', 'onStop']; + + this.name = name; + this.channel = null; + this.queue = null; + this.progress = 0; + this.progressMax = 0; + + this._running = false; + this._percentage = 0; + this._remaining = null; + this._finished = 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 + "'"); + } + } +} + + +/** + * Add callbacks from another request to this request + */ +Zotero.Sync.Storage.Request.prototype.importCallbacks = function (request) { + for each(var name in this.callbacks) { + name = '_' + name; + if (request[name]) { + // If no handlers for this event, add them all + if (!this[name]) { + this[name] = request[name]; + continue; + } + // Otherwise add functions that don't already exist + var add = true; + for each(var newFunc in request[name]) { + for each(var currentFunc in this[name]) { + if (newFunc.toString() === currentFunc.toString()) { + Zotero.debug("Callback already exists in request -- not importing"); + add = false; + break; + } + } + if (add) { + this[name].push(newFunc); + } + } + } + } +} + + +Zotero.Sync.Storage.Request.prototype.__defineGetter__('percentage', function () { + if (this.progressMax == 0) { + return 0; + } + + var percentage = Math.round((this.progress / this.progressMax) * 100); + if (percentage < this._percentage) { + Zotero.debug(percentage + " is less than last percentage of " + + this._percentage + " for request '" + this.name + "'", 2); + Zotero.debug(this.progress); + Zotero.debug(this.progressMax); + percentage = this._percentage; + } + else if (percentage > 100) { + Zotero.debug(percentage + " is greater than 100 for " + + this.name + " request", 2); + Zotero.debug(this.progress); + Zotero.debug(this.progressMax); + percentage = 100; + } + else { + this._percentage = percentage; + } + //Zotero.debug("Request '" + this.name + "' percentage is " + percentage); + return percentage; +}); + + +Zotero.Sync.Storage.Request.prototype.__defineGetter__('remaining', function () { + if (!this.progressMax) { + //Zotero.debug("Remaining not yet available for request '" + this.name + "'"); + return 0; + } + + var remaining = this.progressMax - this.progress; + if (this._remaining === null) { + this._remaining = remaining; + } + else if (remaining > this._remaining) { + Zotero.debug(remaining + " is greater than the last remaining amount of " + + this._remaining + " for request " + this.name); + remaining = this._remaining; + } + else if (remaining < 0) { + Zotero.debug(remaining + " is less than 0 for request " + this.name); + } + else { + this._remaining = remaining; + } + //Zotero.debug("Request '" + this.name + "' remaining is " + remaining); + return remaining; +}); + + +Zotero.Sync.Storage.Request.prototype.setChannel = function (channel) { + this.channel = channel; +} + + +Zotero.Sync.Storage.Request.prototype.start = function () { + if (!this.queue) { + throw ("Request '" + this.name + "' must be added to a queue before starting"); + } + + if (this._running) { + throw ("Request '" + this.name + "' already running in " + + "Zotero.Sync.Storage.Request.start()"); + } + + Zotero.debug("Starting " + this.queue.name + " request '" + this.name + "'"); + this._running = true; + this.queue.activeRequests++; + if (this._onStart) { + for each(var f in this._onStart) { + f(this); + } + } +} + + +Zotero.Sync.Storage.Request.prototype.isRunning = function () { + return this._running; +} + + +Zotero.Sync.Storage.Request.prototype.isFinished = function () { + return this._finished; +} + + +/** + * Update counters for given request + * + * Also updates progress meter + * + * @param {Integer} progress Progress so far + * (usually bytes transferred) + * @param {Integer} progressMax Max progress value for this request + * (usually total bytes) + */ +Zotero.Sync.Storage.Request.prototype.onProgress = function (channel, progress, progressMax) { + if (!this._running) { + Zotero.debug("Trying to update finished request " + this.name + " in " + + "Zotero.Sync.Storage.Request.onProgress() " + + "(" + progress + "/" + progressMax + ")", 2); + 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) { + Zotero.debug("Invalid progress for request '" + + this.name + "' (" + progress + " < " + this.progress + ")"); + return; + } + + if (progressMax != this.progressMax) { + Zotero.debug("progressMax has changed from " + this.progressMax + + " to " + progressMax + " for request '" + this.name + "'", 2); + } + + this.progress = progress; + this.progressMax = progressMax; + this.queue.updateProgress(); + + if (this.onProgress) { + for each(var f in this._onProgress) { + f(progress, progressMax); + } + } +} + + +Zotero.Sync.Storage.Request.prototype.error = function (e) { + this.queue.error(e); +} + + +/** + * Stop the request's underlying network request, if there is one + */ +Zotero.Sync.Storage.Request.prototype.stop = function () { + var finishNow = false; + try { + // If upload already finished, finish() will never be called otherwise + if (this.channel) { + this.channel.QueryInterface(Components.interfaces.nsIHttpChannel); + // Throws error if request not finished + this.channel.requestSucceeded; + Zotero.debug("Channel is no longer running for request " + this.name); + Zotero.debug(this.channel.requestSucceeded); + finishNow = true; + } + } + catch (e) {} + + if (!this._running || !this.channel || finishNow) { + this.finish(); + return; + } + + Zotero.debug("Stopping request '" + this.name + "'"); + this.channel.cancel(0x804b0002); // NS_BINDING_ABORTED +} + + +/** + * Mark request as finished and notify queue that it's done + */ +Zotero.Sync.Storage.Request.prototype.finish = function () { + if (this._finished) { + throw ("Request '" + this.name + "' is already finished"); + } + + Zotero.debug("Finishing " + this.queue.name + " request '" + this.name + "'"); + this._finished = true; + var active = this._running; + this._running = false; + + if (active) { + this.queue.activeRequests--; + } + // mechanism for failures? + this.queue.finishedRequests++; + this.queue.updateProgress(); + + if (this._onStop) { + for each(var f in this._onStop) { + f(); + } + } +} diff --git a/chrome/content/zotero/xpcom/storage/session.js b/chrome/content/zotero/xpcom/storage/session.js @@ -1,192 +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.Session = function (module, callbacks) { - switch (module) { - case 'webdav': - this._session = new Zotero.Sync.Storage.Session.WebDAV(callbacks); - break; - - case 'zfs': - this._session = new Zotero.Sync.Storage.Session.ZFS(callbacks); - break; - - default: - throw ("Invalid storage session module '" + module + "'"); - } - - this.module = module; - this.onError = callbacks.onError; -} - -Zotero.Sync.Storage.Session.prototype.__defineGetter__('name', function () this._session.name); -Zotero.Sync.Storage.Session.prototype.__defineGetter__('includeUserFiles', function () this._session.includeUserFiles); -Zotero.Sync.Storage.Session.prototype.__defineGetter__('includeGroupFiles', function () this._session.includeGroupFiles); - -Zotero.Sync.Storage.Session.prototype.__defineGetter__('enabled', function () { - try { - return this._session.enabled; - } - catch (e) { - this.onError(e); - } -}); - -Zotero.Sync.Storage.Session.prototype.__defineGetter__('verified', function () { - try { - return this._session.verified; - } - catch (e) { - this.onError(e); - } -}); - -Zotero.Sync.Storage.Session.prototype.__defineGetter__('active', function () { - try { - return this._session.active; - } - catch (e) { - this.onError(e); - } -}); - -Zotero.Sync.Storage.Session.prototype.__defineGetter__('username', function () { - try { - return this._session.username; - } - catch (e) { - this.onError(e); - } -}); - -Zotero.Sync.Storage.Session.prototype.__defineGetter__('password', function () { - try { - return this._session.password; - } - catch (e) { - this.onError(e); - } -}); - -Zotero.Sync.Storage.Session.prototype.__defineSetter__('password', function (val) { - try { - this._session.password = val; - } - catch (e) { - this.onError(e); - } -}); - - -Zotero.Sync.Storage.Session.prototype.init = function () { - try { - return this._session.init(); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.initFromPrefs = function () { - try { - return this._session.initFromPrefs(); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.downloadFile = function (request) { - try { - this._session.downloadFile(request); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.uploadFile = function (request) { - try { - this._session.uploadFile(request); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.getLastSyncTime = function (callback) { - try { - this._session.getLastSyncTime(callback); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.setLastSyncTime = function (callback, useLastSyncTime) { - try { - this._session.setLastSyncTime(callback, useLastSyncTime); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.checkServer = function (callback) { - try { - return this._session.checkServer(callback); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.checkServerCallback = function (uri, status, authRequired, window, skipSuccessMessage, error) { - try { - return this._session.checkServerCallback(uri, status, authRequired, window, skipSuccessMessage, error); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.purgeDeletedStorageFiles = function (callback) { - try { - this._session.purgeDeletedStorageFiles(callback); - } - catch (e) { - this.onError(e); - } -} - -Zotero.Sync.Storage.Session.prototype.purgeOrphanedStorageFiles = function (callback) { - try { - this._session.purgeOrphanedStorageFiles(callback); - } - catch (e) { - this.onError(e); - } -} diff --git a/chrome/content/zotero/xpcom/storage/streamListener.js b/chrome/content/zotero/xpcom/storage/streamListener.js @@ -0,0 +1,235 @@ +/* + ***** 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 ***** +*/ + + +/** + * Stream listener that can handle both download and upload requests + * + * Possible properties of data object: + * - onStart: f(request) + * - onProgress: f(request, progress, progressMax) + * - onStop: f(request, status, response, data) + * - onCancel: f(request, status, data) + * - streams: array of streams to close on completion + * - Other values to pass to onStop() + */ +Zotero.Sync.Storage.StreamListener = function (data) { + this._data = data; +} + +Zotero.Sync.Storage.StreamListener.prototype = { + _channel: null, + + // nsIProgressEventSink + onProgress: function (request, context, progress, progressMax) { + // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=451991 + // (fixed in Fx3.1) + if (progress > progressMax) { + progress = progressMax; + } + //Zotero.debug("onProgress with " + progress + "/" + progressMax); + this._onProgress(request, progress, progressMax); + }, + + onStatus: function (request, context, status, statusArg) { + //Zotero.debug('onStatus'); + }, + + // nsIRequestObserver + // Note: For uploads, this isn't called until data is done uploading + onStartRequest: function (request, context) { + Zotero.debug('onStartRequest'); + this._response = ""; + + this._onStart(request); + }, + + onStopRequest: function (request, context, status) { + Zotero.debug('onStopRequest'); + + switch (status) { + case 0: + case 0x804b0002: // NS_BINDING_ABORTED + this._onStop(request, status); + break; + + default: + throw ("Unexpected request status " + status + + " in Zotero.Sync.Storage.StreamListener.onStopRequest()"); + } + }, + + // nsIWebProgressListener + onProgressChange: function (wp, request, curSelfProgress, + maxSelfProgress, curTotalProgress, maxTotalProgress) { + //Zotero.debug("onProgressChange with " + curTotalProgress + "/" + maxTotalProgress); + + // onProgress gets called too, so this isn't necessary + //this._onProgress(request, curTotalProgress, maxTotalProgress); + }, + + onStateChange: function (wp, request, stateFlags, status) { + Zotero.debug("onStateChange"); + + 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); + } + }, + + onStatusChange: function (progress, request, status, message) { + Zotero.debug("onStatusChange with '" + message + "'"); + }, + onLocationChange: function () {}, + onSecurityChange: function () {}, + + // nsIStreamListener + onDataAvailable: function (request, context, stream, sourceOffset, length) { + Zotero.debug('onDataAvailable'); + var scriptableInputStream = + Components.classes["@mozilla.org/scriptableinputstream;1"] + .createInstance(Components.interfaces.nsIScriptableInputStream); + scriptableInputStream.init(stream); + + this._response += scriptableInputStream.read(length); + }, + + // nsIChannelEventSink + onChannelRedirect: function (oldChannel, newChannel, flags) { + Zotero.debug('onChannelRedirect'); + + // 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); + }, + + // nsIHttpEventSink + onRedirect: function (oldChannel, newChannel) { + Zotero.debug('onRedirect'); + }, + + + // + // Private methods + // + _onStart: function (request) { + //Zotero.debug('Starting request'); + if (this._data && this._data.onStart) { + var data = this._getPassData(); + this._data.onStart(request, data); + } + }, + + _onProgress: function (request, progress, progressMax) { + if (this._data && this._data.onProgress) { + this._data.onProgress(request, progress, progressMax); + } + }, + + _onStop: function (request, status) { + var cancelled = status == 0x804b0002; // NS_BINDING_ABORTED + + if (!cancelled && request instanceof Components.interfaces.nsIHttpChannel) { + request.QueryInterface(Components.interfaces.nsIHttpChannel); + status = request.responseStatus; + request.QueryInterface(Components.interfaces.nsIRequest); + } + + if (this._data.streams) { + for each(var stream in this._data.streams) { + stream.close(); + } + } + + var data = this._getPassData(); + + if (cancelled) { + if (this._data.onCancel) { + this._data.onCancel(request, status, data); + } + } + else { + if (this._data.onStop) { + this._data.onStop(request, status, this._response, data); + } + } + + 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 { + return this.QueryInterface(iid); + } + catch (e) { + throw Components.results.NS_NOINTERFACE; + } + }, + + QueryInterface: function(iid) { + if (iid.equals(Components.interfaces.nsISupports) || + iid.equals(Components.interfaces.nsIInterfaceRequestor) || + iid.equals(Components.interfaces.nsIChannelEventSink) || + iid.equals(Components.interfaces.nsIProgressEventSink) || + iid.equals(Components.interfaces.nsIHttpEventSink) || + iid.equals(Components.interfaces.nsIStreamListener) || + iid.equals(Components.interfaces.nsIWebProgressListener)) { + return this; + } + throw Components.results.NS_NOINTERFACE; + }, + + _safeSpec: function (uri) { + return uri.scheme + '://' + uri.username + ':********@' + + uri.hostPort + uri.path + }, +}; diff --git a/chrome/content/zotero/xpcom/storage/webdav.js b/chrome/content/zotero/xpcom/storage/webdav.js @@ -24,996 +24,378 @@ */ -Zotero.Sync.Storage.Session.WebDAV = function (callbacks) { - this.onChangesMade = callbacks.onChangesMade ? callbacks.onChangesMade : function () {}; - this.onError = callbacks.onError ? function (e) { - if (!e) { - e = Zotero.Sync.Storage.Session.WebDAV.prototype.defaultError; - } - callbacks.onError(e); - } : function () {}; - - this._parentURI; - this._rootURI; - this._cachedCredentials = false; -} - -Zotero.Sync.Storage.Session.WebDAV.prototype.name = "WebDAV"; - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('includeUserFiles', function () { - return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'webdav'; -}); - -Zotero.Sync.Storage.Session.WebDAV.prototype.includeGroupItems = false; - -// TEMP -// TODO: localize -Zotero.Sync.Storage.Session.WebDAV.prototype.defaultError = "A WebDAV file sync error occurred. Please try syncing again.\n\nIf you receive this message repeatedly, check your WebDAV server settings in the Sync pane of the Zotero preferences."; -Zotero.Sync.Storage.Session.WebDAV.prototype.defaultErrorRestart = "A WebDAV file sync error occurred. Please restart Firefox and try syncing again.\n\nIf you receive this message repeatedly, check your WebDAV server settings in the Sync pane of the Zotero preferences."; - - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('enabled', function () { - return this.includeUserFiles; -}); - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('verified', function () { - return Zotero.Prefs.get("sync.storage.verified"); -}); - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('active', function () { - return this.enabled && this.verified; -}); - -Zotero.Sync.Storage.Session.WebDAV.prototype._loginManagerHost = 'chrome://zotero'; -Zotero.Sync.Storage.Session.WebDAV.prototype._loginManagerURL = 'Zotero Storage Server'; - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('username', function () { - return Zotero.Prefs.get('sync.storage.username'); -}); - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('password', function () { - var username = this.username; - - if (!username) { - Zotero.debug('Username not set before getting Zotero.Sync.Storage.Session.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({}, this._loginManagerHost, this._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 ''; -}); - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineSetter__('password', function (password) { - var username = this.username; - if (!username) { - Zotero.debug('Username not set before setting Zotero.Sync.Server.Session.WebDAV.password'); - return; - } - - this._cachedCredentials = false; - - 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; - } - - 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); - } -}); - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('rootURI', function () { - if (!this._rootURI) { - throw ("Root URI not initialized in Zotero.Sync.Storage.Session.WebDAV.rootURI"); - } - return this._rootURI.clone(); -}); - -Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('parentURI', function () { - if (!this._parentURI) { - throw ("Parent URI not initialized in Zotero.Sync.Storage.Session.WebDAV.parentURI"); - } - return this._parentURI.clone(); -}); - - -Zotero.Sync.Storage.Session.WebDAV.prototype.init = function (url, dir, username, password) { - if (!url) { - var msg = "WebDAV URL not provided"; - Zotero.debug(msg); - throw ({ - message: msg, - name: "Z_ERROR_NO_URL", - filename: "webdav.js", - toString: function () { return this.message; } - }); - } - - if (username && !password) { - var msg = "WebDAV password not provided"; - Zotero.debug(msg); - throw ({ - message: msg, - name: "Z_ERROR_NO_PASSWORD", - filename: "webdav.js", - toString: function () { return this.message; } - }); - } - - var ios = Components.classes["@mozilla.org/network/io-service;1"]. - getService(Components.interfaces.nsIIOService); - try { - var uri = ios.newURI(url, null, null); - if (username) { - uri.username = username; - uri.password = password; - } - } - catch (e) { - Zotero.debug(e); - Components.utils.reportError(e); - return false; - } - if (!uri.spec.match(/\/$/)) { - uri.spec += "/"; - } - this._parentURI = uri; +Zotero.Sync.Storage.Module.WebDAV = (function () { + // TEMP + // TODO: localize + var _defaultError = "A WebDAV file sync error occurred. Please try syncing again.\n\nIf you receive this message repeatedly, check your WebDAV server settings in the Sync pane of the Zotero preferences."; + var _defaultErrorRestart = "A WebDAV file sync error occurred. Please restart Firefox and try syncing again.\n\nIf you receive this message repeatedly, check your WebDAV server settings in the Sync pane of the Zotero preferences."; - var uri = uri.clone(); - uri.spec += "zotero/"; - this._rootURI = uri; - return true; -} - - -Zotero.Sync.Storage.Session.WebDAV.prototype.initFromPrefs = function () { - var scheme = Zotero.Prefs.get('sync.storage.scheme'); - switch (scheme) { - case 'http': - case 'https': - break; - - default: - throw ("Invalid WebDAV scheme '" + scheme - + "' in Zotero.Sync.Storage.Session.WebDAV.rootURI"); - } - - var url = Zotero.Prefs.get('sync.storage.url'); - if (!url) { - return false; - } + var _parentURI; + var _rootURI; + var _cachedCredentials = false; - url = scheme + '://' + url; - var dir = "zotero"; - var username = this.username; - var password = this.password; + var _loginManagerHost = 'chrome://zotero'; + var _loginManagerURL = 'Zotero Storage Server'; - return this.init(url, dir, username, password); -} - - -/** - * Get mod time of file on storage server - * - * @param {Zotero.Item} item - * @param {Function} callback Callback f(item, mdate) - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._getStorageModificationTime = function (item, callback) { - var uri = this._getItemPropertyURI(item); - - var self = this; - - Zotero.HTTP.doGet(uri, function (req) { - self._checkResponse(req, self); - - var funcName = "Zotero.Sync.Storage.WebDAV_getStorageModificationTime()"; - - // mod_speling can return 300s for 404s with base name matches - if (req.status == 404 || req.status == 300) { - callback(item, false); - return; - } - else if (req.status != 200) { - Zotero.debug(req.responseText); - self.onError("Unexpected status code " + req.status + " in " + funcName); - return; - } - - Zotero.debug(req.responseText); + // + // Private methods + // + /** + * Get mod time of file on storage server + * + * @param {Zotero.Item} item + * @param {Function} callback Callback f(item, mdate) + */ + function getStorageModificationTime(item, callback) { + var uri = getItemPropertyURI(item); - // No modification time set - if (!req.responseText) { - callback(item, false); - return; - } - - try { - var xml = new XML(req.responseText); - } - catch (e) { - Zotero.debug(e); - var xml = null; - } - - if (xml) { - Zotero.debug(xml.children().length()); - } - - if (xml && xml.children().length()) { - // TODO: other stuff, but this makes us forward-compatible - mtime = xml.mtime.toString(); - var seconds = false; - } - else { - mtime = req.responseText; - var 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; + Zotero.HTTP.doGet(uri, function (req) { + checkResponse(req); + + var funcName = "Zotero.Sync.Storage.WebDAV.getStorageModificationTime()"; + + // mod_speling can return 300s for 404s with base name matches + if (req.status == 404 || req.status == 300) { + callback(item, false); + return; } - else { - invalid = true; + else if (req.status != 200) { + Zotero.debug(req.responseText); + Zotero.Sync.Storage.EventManager.error( + "Unexpected status code " + req.status + " in " + funcName + ); } - } - 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); - self._deleteStorageFiles([item.key + ".prop"], null, self); - self.onError(); - return; - } - - var mdate = new Date(parseInt(mtime)); - callback(item, mdate); - }); -} - - -/** - * Set mod time of file on storage server - * - * @param {Zotero.Item} item - * @param {Function} callback Callback f(item, props) - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._setStorageModificationTime = function (item, callback) { - var uri = this._getItemPropertyURI(item); - - var mtime = item.attachmentModificationTime; - var hash = item.attachmentHash; - - var prop = <properties version="1"> - <mtime>{mtime}</mtime> - <hash>{hash}</hash> - </properties>; - - Zotero.HTTP.WebDAV.doPut(uri, prop.toXMLString(), function (req) { - switch (req.status) { - case 200: - case 201: - case 204: - break; - default: - Zotero.debug(req.responseText); - throw ("Unexpected status code " + req.status + " in " - + "Zotero.Sync.Storage._setStorageModificationTime()"); - } - callback(item, { mtime: mtime, hash: hash }); - }); -} - - - -/** - * Begin download process for individual file - * - * @param {Zotero.Sync.Storage.Request} [request] - */ -Zotero.Sync.Storage.Session.WebDAV.prototype.downloadFile = function (request) { - var funcName = "Zotero.Sync.Storage.Session.WebDAV.downloadFile()"; - - var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); - if (!item) { - throw ("Item '" + request.name + "' not found in " + funcName); - } - - var self = this; - - // Retrieve modification time from server to store locally afterwards - this._getStorageModificationTime(item, function (item, mdate) { - if (!request.isRunning()) { - Zotero.debug("Download request '" + request.name - + "' is no longer running after getting mod time"); - return; - } - - if (!mdate) { - Zotero.debug("Remote file not found for item " + Zotero.Items.getLibraryKeyHash(item)); - request.finish(); - return; - } - - try { - var syncModTime = mdate.getTime(); + Zotero.debug(req.responseText); - // Skip download if local file exists and matches mod time - var file = item.getFile(); - if (file && file.exists() && syncModTime == file.lastModifiedTime) { - Zotero.debug("File mod time matches remote file -- skipping download"); - - Zotero.DB.beginTransaction(); - var syncState = Zotero.Sync.Storage.getSyncState(item.id); - var updateItem = syncState != 1; - Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); - Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); - Zotero.DB.commitTransaction(); - self.onChangesMade(); - request.finish(); + // No modification time set + if (!req.responseText) { + callback(item, false); return; } - var uri = self._getItemURI(item); - var destFile = Zotero.getTempDirectory(); - destFile.append(item.key + '.zip.tmp'); - if (destFile.exists()) { - destFile.remove(false); + try { + var xml = new XML(req.responseText); + } + catch (e) { + Zotero.debug(e); + var xml = null; } - var listener = new Zotero.Sync.Storage.StreamListener( - { - onStart: function (request, data) { - if (data.request.isFinished()) { - Zotero.debug("Download request " + data.request.name - + " stopped before download started -- closing channel"); - request.cancel(0x804b0002); // NS_BINDING_ABORTED - return; - } - }, - onProgress: function (a, b, c) { - request.onProgress(a, b, c) - }, - onStop: function (request, status, response, data) { - if (status == 404) { - var msg = "Remote ZIP file not found for item " + item.key; - Zotero.debug(msg, 2); - Components.utils.reportError(msg); - - // Delete the orphaned prop file - self._deleteStorageFiles([item.key + ".prop"], null, self); - - data.request.finish(); - return; - } - else if (status != 200) { - var msg = "Unexpected status code " + status - + " for request " + data.request.name + " in Zotero.Sync.Storage.Session.WebDAV.downloadFile()"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - self.onError(); - 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"); - return; - } - - Zotero.debug("Finished download of " + destFile.path); - - try { - Zotero.Sync.Storage.processDownload(data); - data.request.finish(); - } - catch (e) { - self.onError(e); - } - }, - request: request, - item: item, - compressed: true, - syncModTime: syncModTime - } - ); + if (xml) { + Zotero.debug(xml.children().length()); + } - // Don't display password in console - var disp = uri.clone(); - if (disp.password) { - disp.password = '********'; + if (xml && xml.children().length()) { + // TODO: other stuff, but this makes us forward-compatible + mtime = xml.mtime.toString(); + var seconds = false; } - Zotero.debug('Saving ' + disp.spec + ' with saveURI()'); - const nsIWBP = Components.interfaces.nsIWebBrowserPersist; - var wbp = Components - .classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"] - .createInstance(nsIWBP); - wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE; - wbp.progressListener = listener; - wbp.saveURI(uri, null, null, null, null, destFile); - } - catch (e) { - request.error(e); - } - }); -} - - -Zotero.Sync.Storage.Session.WebDAV.prototype.uploadFile = function (request) { - var self = this; - Zotero.Sync.Storage.createUploadFile(request, function (data) { self._processUploadFile(data); }); -} - -/** - * Upload the generated ZIP file to the server - * - * @param {Object} Object with 'request' property - * @return {void} - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._processUploadFile = function (data) { - /* - _updateSizeMultiplier( - (100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100 - ); - */ - var request = data.request; - var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); - - var self = this; - - this._getStorageModificationTime(item, function (item, mdate) { - try { - if (!request.isRunning()) { - Zotero.debug("Upload request '" + request.name - + "' is no longer running after getting mod time"); - return; + else { + mtime = req.responseText; + var seconds = true; } - // 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(); - self.onChangesMade(); - request.finish(); - return; - } - - var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id); - if (smtime != mtime) { - var localData = { modTime: fmtime }; - var remoteData = { modTime: mtime }; - Zotero.Sync.Storage.QueueManager.addConflict( - request.name, localData, remoteData - ); - Zotero.debug("Conflict -- last synced file mod time " - + "does not match time on storage server" - + " (" + smtime + " != " + mtime + ")"); - request.finish(); - return; - } + 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 { - Zotero.debug("Remote file not found for item " + item.id); + invalid = true; } } + else if (!mtime.match(/^[0-9]{1,13}$/)) { + invalid = true; + } - 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 = self._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 listener = new Zotero.Sync.Storage.StreamListener( - { - onProgress: function (a, b, c) { - request.onProgress(a, b, c); - }, - onStop: function (httpRequest, status, response, data) { self._onUploadComplete(httpRequest, status, response,data); }, - onCancel: function (httpRequest, status, data) { self._onUploadCancel(httpRequest, status, data); }, - request: request, - item: item, - streams: [fis, bis] - } - ); - channel.notificationCallbacks = listener; - - var dispURI = uri.clone(); - if (dispURI.password) { - dispURI.password = '********'; + // 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); + deleteStorageFiles([item.key + ".prop"]); + Zotero.Sync.Storage.EventManager.error(_defaultError); } - Zotero.debug("HTTP PUT of " + file.leafName + " to " + dispURI.spec); - channel.asyncOpen(listener, null); - } - catch (e) { - self.onError(e); - } - }); -} - - -Zotero.Sync.Storage.Session.WebDAV.prototype._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: - this.onError(Zotero.localeJoin([ - Zotero.getString('sync.storage.error.fileUploadFailed'), - Zotero.getString('sync.storage.error.checkFileSyncSettings') - ])); - return; - - case 507: - this.onError(Zotero.getString('sync.storage.error.webdav.insufficientSpace')); - return; - - default: - this.onError("Unexpected file upload status " + status - + " in Zotero.Sync.Storage.WebDAV._onUploadComplete()"); - return; + var mdate = new Date(parseInt(mtime)); + callback(item, mdate); + }); } - var self = this; - this._setStorageModificationTime(item, function (item, props) { - if (!request.isRunning()) { - Zotero.debug("Upload request '" + request.name - + "' is no longer running after getting mod time"); - return; - } - - Zotero.DB.beginTransaction(); + /** + * Set mod time of file on storage server + * + * @param {Zotero.Item} item + * @param {Function} callback Callback f(item, props) + */ + function setStorageModificationTime(item, callback) { + var uri = getItemPropertyURI(item); - 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); + var mtime = item.attachmentModificationTime; + var hash = item.attachmentHash; - Zotero.DB.commitTransaction(); + var prop = <properties version="1"> + <mtime>{mtime}</mtime> + <hash>{hash}</hash> + </properties>; - try { - var file = Zotero.getTempDirectory(); - file.append(item.key + '.zip'); - file.remove(false); - } - catch (e) { - Components.utils.reportError(e); - } + Zotero.HTTP.WebDAV.doPut(uri, prop.toXMLString(), function (req) { + switch (req.status) { + case 200: + case 201: + case 204: + break; + + default: + Zotero.debug(req.responseText); + throw new Error("Unexpected status code " + req.status); + } + callback(item, { mtime: mtime, hash: hash }); + }); - self.onChangesMade(); - request.finish(); - }); -} - - -Zotero.Sync.Storage.Session.WebDAV.prototype._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); - } - - request.finish(); -} - - -Zotero.Sync.Storage.Session.WebDAV.prototype.getLastSyncTime = function (callback) { - // Cache the credentials at the root URI - if (!this._cachedCredentials) { - var self = this; - Zotero.HTTP.doOptions(this.rootURI, function (req) { - self._checkResponse(req, self); + /** + * 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); - if (req.status != 200) { - var msg = "Unexpected status code " + req.status + " for OPTIONS request " - + "in Zotero.Sync.Storage.Session.WebDAV.getLastSyncTime()"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - self.onError(Zotero.Sync.Storage.Session.WebDAV.prototype.defaultErrorRestart); - return; - } - self._cachedCredentials = true; - self.getLastSyncTime(callback); - }); - return; - } - - try { - var uri = this.rootURI; - var successFileURI = uri.clone(); - successFileURI.spec += "lastsync"; - Zotero.HTTP.doGet(successFileURI, function (req) { - var ts = undefined; - try { - if (req.responseText) { - Zotero.debug(req.responseText); - } - Zotero.debug(req.status); - - if (req.status == 403) { - Zotero.debug("Clearing WebDAV authentication credentials", 2); - self._cachedCredentials = false; - } - - if (req.status != 200 && req.status != 404) { - var msg = "Unexpected status code " + req.status + " for HEAD request " - + "in Zotero.Sync.Storage.Session.WebDAV.getLastSyncTime()"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - self.onError(); - return; - } - - if (req.status == 200) { - var lastModified = req.getResponseHeader("Last-Modified"); - var date = new Date(lastModified); - Zotero.debug("Last successful storage sync was " + date); - ts = Zotero.Date.toUnixTimestamp(date); + getStorageModificationTime(item, function (item, mdate) { + try { + if (!request.isRunning()) { + Zotero.debug("Upload request '" + request.name + + "' is no longer running after getting mod time"); + return; + } + + // 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(); + onChangesMade(); + request.finish(); + return; + } + + var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id); + if (smtime != mtime) { + var localData = { modTime: fmtime }; + var remoteData = { modTime: mtime }; + Zotero.Sync.Storage.QueueManager.addConflict( + request.name, localData, remoteData + ); + Zotero.debug("Conflict -- last synced file mod time " + + "does not match time on storage server" + + " (" + smtime + " != " + mtime + ")"); + request.finish(); + return; + } + } + 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 listener = new Zotero.Sync.Storage.StreamListener( + { + onProgress: function (a, b, c) { + request.onProgress(a, b, c); + }, + onStop: function (httpRequest, status, response, data) { onUploadComplete(httpRequest, status, response,data); }, + onCancel: function (httpRequest, status, data) { onUploadCancel(httpRequest, status, data); }, + 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); } - else { - ts = null; + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); } - } - finally { - callback(ts); - } - }); - return; - } - catch (e) { - Zotero.debug(e); - Components.utils.reportError(e); - callback(); - return; - } -} - - -Zotero.Sync.Storage.Session.WebDAV.prototype.setLastSyncTime = function (callback) { - try { - var uri = this.rootURI; - var successFileURI = uri.clone(); - successFileURI.spec += "lastsync"; + }); + } - var self = this; - Zotero.HTTP.WebDAV.doPut(successFileURI, "1", function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); + function onUploadComplete(httpRequest, status, response, data) { + var request = data.request; + var item = data.item; + var url = httpRequest.name; - switch (req.status) { + Zotero.debug("Upload of attachment " + item.key + + " finished with status code " + status); + + switch (status) { case 200: case 201: case 204: - self.getLastSyncTime(function (ts) { - if (ts) { - var sql = "REPLACE INTO version VALUES ('storage_webdav', ?)"; - Zotero.DB.query(sql, { int: ts }); - } - if (callback) { - callback(); - } - }); - return; - } - - var msg = "Unexpected error code " + req.status + " uploading storage success file"; - Zotero.debug(msg, 2); - Components.utils.reportError(msg); - if (callback) { - callback(); + break; + + case 403: + case 500: + Zotero.Sync.Storage.EventManager.error( + Zotero.getString('sync.storage.error.fileUploadFailed') + + " " + Zotero.getString('sync.storage.error.checkFileSyncSettings') + ); + + case 507: + Zotero.Sync.Storage.EventManager.error( + Zotero.getString('sync.storage.error.webdav.insufficientSpace') + ); + + default: + Zotero.Sync.Storage.EventManager.error( + "Unexpected file upload status " + status + + " in Zotero.Sync.Storage.WebDAV.onUploadComplete()" + ); } - }); - } - catch (e) { - Zotero.debug(e); - Components.utils.reportError(e); - if (callback) { - callback(); - } - return; - } -} - - -/** - * @param {Function} callback Function to pass URI and result value to - * @param {Object} errorCallbacks - */ -Zotero.Sync.Storage.Session.WebDAV.prototype.checkServer = function (callback) { - try { - var parentURI = this.parentURI; - var uri = this.rootURI; - } - catch (e) { - switch (e.name) { - case 'Z_ERROR_NO_URL': - callback(null, Zotero.Sync.Storage.ERROR_NO_URL); - return; - case 'Z_ERROR_NO_PASSWORD': - callback(null, Zotero.Sync.Storage.ERROR_NO_PASSWORD); - return; + setStorageModificationTime(item, function (item, props) { + if (!request.isRunning()) { + Zotero.debug("Upload request '" + request.name + + "' is no longer running after getting mod time"); + return; + } - default: - Zotero.debug(e); - Components.utils.reportError(e); - callback(null, Zotero.Sync.Storage.ERROR_UNKNOWN); - return; - } - } - - var requestHolder = { request: null }; - - var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; - var D = new Namespace("D", "DAV:"); - var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"'; - - var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); - requestXML.D::prop = ''; - // IIS 5.1 requires at least one property in PROPFIND - requestXML.D::prop.D::getcontentlength = ''; - - var xmlstr = prolog + requestXML.toXMLString(); - - var self = this; - - // Test whether URL is WebDAV-enabled - var request = Zotero.HTTP.doOptions(uri, function (req) { - // Timeout - if (req.status == 0) { - self._checkResponse(req, self); - - callback(uri, Zotero.Sync.Storage.ERROR_UNREACHABLE); - return; + 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); + } + + onChangesMade(); + request.finish(); + }); } - Zotero.debug(req.getAllResponseHeaders()); - Zotero.debug(req.responseText); - Zotero.debug(req.status); - switch (req.status) { - case 400: - callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST); - return; + function onUploadCancel(httpRequest, status, data) { + var request = data.request; + var item = data.item; - case 401: - callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); - return; + Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status); - case 403: - callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); - return; + try { + var file = Zotero.getTempDirectory(); + file.append(item.key + '.zip'); + file.remove(false); + } + catch (e) { + Components.utils.reportError(e); + } - case 500: - callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); - return; - } - - var dav = req.getResponseHeader("DAV"); - if (dav == null) { - callback(uri, Zotero.Sync.Storage.ERROR_NOT_DAV); - return; + request.finish(); } - - // Get the Authorization header used in case we need to do a request - // on the parent below - var channelAuthorization = Zotero.HTTP.getChannelAuthorization(req.channel); - - var headers = { Depth: 0 }; - - // Test whether Zotero directory exists - Zotero.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) { + } + + + /** + * Create a Zotero directory on the storage server + */ + function createServerDirectory(callback) { + var uri = Zotero.Sync.Storage.Module.WebDAV.rootURI; + Zotero.HTTP.WebDAV.doMkCol(uri, function (req) { Zotero.debug(req.responseText); Zotero.debug(req.status); switch (req.status) { - case 207: - // Test if Zotero directory is writable - var testFileURI = uri.clone(); - testFileURI.spec += "zotero-test-file"; - Zotero.HTTP.WebDAV.doPut(testFileURI, "1", function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - - switch (req.status) { - case 200: - case 201: - case 204: - Zotero.HTTP.doGet( - testFileURI, - function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - - switch (req.status) { - case 200: - // Delete test file - Zotero.HTTP.WebDAV.doDelete( - testFileURI, - function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - - switch (req.status) { - case 200: // IIS 5.1 and Sakai return 200 - case 204: - callback( - uri, - Zotero.Sync.Storage.SUCCESS - ); - return; - - case 401: - callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); - return; - - case 403: - callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); - return; - - default: - callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); - return; - } - } - ); - return; - - case 401: - callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); - return; - - case 403: - callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); - return; - - // IIS 6+ configured not to serve extensionless files or .prop files - // http://support.microsoft.com/kb/326965 - case 404: - callback(uri, Zotero.Sync.Storage.ERROR_FILE_MISSING_AFTER_UPLOAD); - return; - - case 500: - callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); - return; - - default: - callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); - return; - } - } - ); - return; - - case 401: - callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); - return; - - case 403: - callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); - return; - - case 500: - callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); - return; - - default: - callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); - return; - } - }); - return; - - case 400: - callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST); - return; + case 201: + callback(uri, Zotero.Sync.Storage.SUCCESS); + break; case 401: callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); @@ -1023,655 +405,1246 @@ Zotero.Sync.Storage.Session.WebDAV.prototype.checkServer = function (callback) { callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); return; - case 404: - // Include Authorization header from /zotero request, - // since Firefox probably won't apply it to the parent request - var newHeaders = {}; - for (var header in headers) { - newHeaders[header] = headers[header]; - } - newHeaders["Authorization"] = channelAuthorization; - - // Zotero directory wasn't found, so see if at least - // the parent directory exists - Zotero.HTTP.WebDAV.doProp("PROPFIND", parentURI, xmlstr, - function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - - switch (req.status) { - // Parent directory existed - case 207: - callback(uri, Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND); - return; - - case 400: - callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST); - return; - - case 401: - callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); - return; - - // Parent directory wasn't found either - case 404: - callback(uri, Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND); - return; - - default: - callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); - return; - } - }, newHeaders); + case 405: + callback(uri, Zotero.Sync.Storage.ERROR_NOT_ALLOWED); return; case 500: callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); return; - + default: callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); return; } - }, headers); - }); - - if (!request) { - callback(uri, Zotero.Sync.Storage.ERROR_OFFLINE); + }); } - requestHolder.request = request; - return requestHolder; -} - - -Zotero.Sync.Storage.Session.WebDAV.prototype.checkServerCallback = function (uri, status, window, skipSuccessMessage, e) { - var promptService = - Components.classes["@mozilla.org/embedcomp/prompt-service;1"]. - createInstance(Components.interfaces.nsIPromptService); - if (uri) { - var spec = uri.scheme + '://' + uri.hostPort + uri.path; - } - // If there's an error, just display that - if (e) { - promptService.alert( - window, - Zotero.getString('general.error'), - e.toString() - ); - return false; + /** + * 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.Module.WebDAV.rootURI; + uri.spec = uri.spec + item.key + '.zip'; + return uri; } - switch (status) { - case Zotero.Sync.Storage.SUCCESS: - if (!skipSuccessMessage) { - promptService.alert( - window, - Zotero.getString('sync.storage.serverConfigurationVerified'), - Zotero.getString('sync.storage.fileSyncSetUp') - ); - } - Zotero.Prefs.set("sync.storage.verified", true); - return true; - - case Zotero.Sync.Storage.ERROR_NO_URL: - var errorMessage = Zotero.getString('sync.storage.error.webdav.enterURL'); - break; - - case Zotero.Sync.Storage.ERROR_NO_PASSWORD: - var errorMessage = Zotero.getString('sync.error.enterPassword'); - break; - - case Zotero.Sync.Storage.ERROR_UNREACHABLE: - var errorMessage = Zotero.getString('sync.storage.error.serverCouldNotBeReached', uri.host); - break; - - case Zotero.Sync.Storage.ERROR_NOT_DAV: - var errorMessage = Zotero.getString('sync.storage.error.webdav.invalidURL', spec); - break; + + /** + * 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.Module.WebDAV.rootURI; + uri.spec = uri.spec + item.key + '.prop'; + return uri; + } - case Zotero.Sync.Storage.ERROR_AUTH_FAILED: - var errorTitle = Zotero.getString('general.permissionDenied'); - var errorMessage = Zotero.localeJoin([ - Zotero.getString('sync.storage.error.webdav.invalidLogin'), - Zotero.getString('sync.storage.error.checkFileSyncSettings') - ]); - break; - case Zotero.Sync.Storage.ERROR_FORBIDDEN: - var errorTitle = Zotero.getString('general.permissionDenied'); - var errorMessage = Zotero.localeJoin([ - Zotero.getString('sync.storage.error.webdav.permissionDenied', uri.path), - Zotero.getString('sync.storage.error.checkFileSyncSettings') - ]); - break; + /** + * 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, callback) { + var results = { + deleted: [], + missing: [], + error: [] + }; - case Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND: - var errorTitle = Zotero.getString('sync.storage.error.directoryNotFound'); - var parentSpec = spec.replace(/\/zotero\/$/, ""); - var errorMessage = Zotero.getString('sync.storage.error.doesNotExist', parentSpec); - break; + if (files.length == 0) { + if (callback) { + callback(results); + } + return; + } - case Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND: - var create = promptService.confirmEx( - window, - Zotero.getString('sync.storage.error.directoryNotFound'), - Zotero.getString('sync.storage.error.doesNotExist', spec) + "\n\n" - + Zotero.getString('sync.storage.error.createNow'), - promptService.BUTTON_POS_0 - * promptService.BUTTON_TITLE_IS_STRING - + promptService.BUTTON_POS_1 - * promptService.BUTTON_TITLE_CANCEL, - Zotero.getString('general.create'), - null, null, null, {} - ); + for (var i=0; i<files.length; i++) { + let last = (i == files.length - 1); + let fileName = files[i]; - if (create != 0) { - return; + let deleteURI = rootURI; + // This should never happen, but let's be safe + if (!deleteURI.spec.match(/\/$/)) { + if (callback) { + callback(deleted); + } + Zotero.Sync.Storage.EventManager.error( + "Root URI does not end in slash in " + + "Zotero.Sync.Storage.WebDAV.deleteStorageFiles()" + ); } - - this._createServerDirectory(function (uri, status) { - switch (status) { - case Zotero.Sync.Storage.SUCCESS: - if (!skipSuccessMessage) { - promptService.alert( - window, - Zotero.getString('sync.storage.serverConfigurationVerified'), - Zotero.getString('sync.storage.fileSyncSetUp') - ); - } - Zotero.Prefs.set("sync.storage.verified", true); - return true; + deleteURI.QueryInterface(Components.interfaces.nsIURL); + deleteURI.fileName = files[i]; + deleteURI.QueryInterface(Components.interfaces.nsIURI); + Zotero.HTTP.WebDAV.doDelete(deleteURI, function (req) { + switch (req.status) { + case 204: + // IIS 5.1 and Sakai return 200 + case 200: + var fileDeleted = true; + break; - case Zotero.Sync.Storage.ERROR_FORBIDDEN: - var errorTitle = Zotero.getString('general.permissionDenied'); - var errorMessage = Zotero.getString('sync.storage.error.permissionDeniedAtAddress') + "\n\n" - + spec + "\n\n" - + Zotero.getString('sync.storage.error.checkFileSyncSettings'); + case 404: + var fileDeleted = false; break; + + default: + if (last && callback) { + callback(results); + } + + results.error.push(fileName); + var msg = "An error occurred attempting to delete " + + "'" + fileName + + "' (" + req.status + " " + req.statusText + ")."; + Zotero.Sync.Storage.EventManager.error(msg); } - // TEMP - if (!errorMessage) { - var errorMessage = status; + // If an item file URI, get the property URI + var deletePropURI = getPropertyURIFromItemURI(deleteURI); + if (!deletePropURI) { + if (fileDeleted) { + results.deleted.push(fileName); + } + else { + results.missing.push(fileName); + } + if (last && callback) { + callback(results); + } + return; } - promptService.alert(window, errorTitle, errorMessage); + + // If property file appears separately in delete queue, + // remove it, since we're taking care of it here + var propIndex = files.indexOf(deletePropURI.fileName); + if (propIndex > i) { + delete files[propIndex]; + i--; + last = (i == files.length - 1); + } + + // Delete property file + Zotero.HTTP.WebDAV.doDelete(deletePropURI, function (req) { + switch (req.status) { + case 204: + // IIS 5.1 and Sakai return 200 + case 200: + results.deleted.push(fileName); + break; + + case 404: + if (fileDeleted) { + results.deleted.push(fileName); + } + else { + results.missing.push(fileName); + } + break; + + default: + var error = true; + } + + if (last && callback) { + callback(results); + } + + if (error) { + results.error.push(fileName); + var msg = "An error occurred attempting to delete " + + "'" + fileName + + "' (" + req.status + " " + req.statusText + ")."; + Zotero.Sync.Storage.EventManager.error(msg); + } + }); }); - - return false; - - case Zotero.Sync.Storage.ERROR_FILE_MISSING_AFTER_UPLOAD: - // TODO: localize - var errorTitle = "WebDAV Server Configuration Error"; - var errorMessage = "Your WebDAV server must be configured to serve files without extensions " - + "and files with .prop extensions in order to work with Zotero."; - break; - - case Zotero.Sync.Storage.ERROR_SERVER_ERROR: - // TODO: localize - var errorTitle = "WebDAV Server Configuration Error"; - var errorMessage = "Your WebDAV server returned an internal error." - + "\n\n" + Zotero.getString('sync.storage.error.checkFileSyncSettings'); - break; - - case Zotero.Sync.Storage.ERROR_UNKNOWN: - var errorMessage = Zotero.localeJoin([ - Zotero.getString('general.unknownErrorOccurred'), - Zotero.getString('sync.storage.error.checkFileSyncSettings') - ]); - break; - } - - if (!skipSuccessMessage) { - if (!errorTitle) { - var errorTitle = Zotero.getString("general.error"); - } - // TEMP - if (!errorMessage) { - var errorMessage = status; } - promptService.alert(window, errorTitle, errorMessage); - } - return false; -} - - -/** - * Remove files on storage server that were deleted locally more than - * sync.storage.deleteDelayDays days ago - * - * @param {Function} callback Passed number of files deleted - */ -Zotero.Sync.Storage.Session.WebDAV.prototype.purgeDeletedStorageFiles = function (callback) { - if (!this.active) { - return; } - Zotero.debug("Purging deleted storage files"); - var files = Zotero.Sync.Storage.getDeletedFiles(); - if (!files) { - Zotero.debug("No files to delete remotely"); - if (callback) { - callback(); + + /** + * Checks for an invalid SSL certificate and displays a nice error + */ + function checkResponse(req) { + var channel = req.channel; + if (!channel instanceof Ci.nsIChannel) { + Zotero.Sync.Storage.EventManager.error('No HTTPS channel available'); + } + 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) + + " " + Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo'); + + Zotero.Sync.Storage.EventManager.error(msg); + } + else if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_BROKEN) == Ci.nsIWebProgressListener.STATE_IS_BROKEN) { + var msg = Zotero.localeJoin([ + Zotero.getString('sync.storage.error.webdav.sslConnectionError', host), + Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo') + ]); + Zotero.Sync.Storage.EventManager.error(msg); + } } - return; } - // Add .zip extension - var files = files.map(function (file) file + ".zip"); - this._deleteStorageFiles(files, function (results) { - // Remove deleted and nonexistent files from storage delete log - var toPurge = results.deleted.concat(results.missing); - if (toPurge.length > 0) { - var done = 0; - var maxFiles = 999; - var numFiles = toPurge.length; + return { + name: "WebDAV", + + get includeUserFiles() { + return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'webdav'; + }, + includeGroupItems: false, + + get enabled() { + return this.includeUserFiles; + }, + + get verified() { + return Zotero.Prefs.get("sync.storage.verified"); + }, + + get username() { + return Zotero.Prefs.get('sync.storage.username'); + }, + + get password() { + var username = this.username; + + if (!username) { + Zotero.debug('Username not set before getting Zotero.Sync.Storage.Module.WebDAV.password'); + return ''; + } - Zotero.DB.beginTransaction(); + 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); - 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; + // 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; + } } - while (done < numFiles); - Zotero.DB.commitTransaction(); - } + return ''; + }, - if (callback) { - callback(results.deleted.length); - } - }); -} - - -/** - * Delete orphaned storage files older than a day before last sync time - * - * @param {Function} callback - */ -Zotero.Sync.Storage.Session.WebDAV.prototype.purgeOrphanedStorageFiles = function (callback) { - const daysBeforeSyncTime = 1; - - if (!this.active) { - return; - } - - // If recently purged, skip - var lastpurge = Zotero.Prefs.get('lastWebDAVOrphanPurge'); - var days = 10; - if (lastpurge && new Date(lastpurge * 1000) > (new Date() - (1000 * 60 * 60 * 24 * days))) { - return; - } - - Zotero.debug("Purging orphaned storage files"); - - var uri = this.rootURI; - var path = uri.path; - - var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; - var D = new Namespace("D", "DAV:"); - var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"'; - - var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); - requestXML.D::prop = ''; - requestXML.D::prop.D::getlastmodified = ''; - - var xmlstr = prolog + requestXML.toXMLString(); - - var lastSyncDate = new Date(Zotero.Sync.Server.lastLocalSyncTime * 1000); - - var self = this; - - Zotero.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) { - Zotero.debug(req.responseText); + set password(password) { + var username = this.username; + if (!username) { + Zotero.debug('Username not set before setting Zotero.Sync.Server.Module.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); + } + }, + + get rootURI() { + if (!_rootURI) { + throw new Error("Root URI not initialized"); + } + return _rootURI.clone(); + }, + + get parentURI() { + if (!_parentURI) { + throw new Error("Parent URI not initialized"); + } + return _parentURI.clone(); + }, + + + init: function (url, dir, username, password) { + if (!url) { + var msg = "WebDAV URL not provided"; + Zotero.debug(msg); + throw ({ + message: msg, + name: "Z_ERROR_NO_URL", + filename: "webdav.js", + toString: function () { return this.message; } + }); + } + + if (username && !password) { + var msg = "WebDAV password not provided"; + Zotero.debug(msg); + throw ({ + message: msg, + name: "Z_ERROR_NO_PASSWORD", + filename: "webdav.js", + toString: function () { return this.message; } + }); + } + + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + try { + var uri = ios.newURI(url, null, null); + if (username) { + uri.username = username; + uri.password = password; + } + } + catch (e) { + Zotero.debug(e); + Components.utils.reportError(e); + return false; + } + if (!uri.spec.match(/\/$/)) { + uri.spec += "/"; + } + _parentURI = uri; + + var uri = uri.clone(); + uri.spec += "zotero/"; + _rootURI = uri; + return true; + }, + + + initFromPrefs: function () { + var scheme = Zotero.Prefs.get('sync.storage.scheme'); + switch (scheme) { + case 'http': + case 'https': + break; + + default: + throw new Error("Invalid WebDAV scheme '" + scheme + "'"); + } + + var url = Zotero.Prefs.get('sync.storage.url'); + if (!url) { + return false; + } - var funcName = "Zotero.Sync.Storage.purgeOrphanedStorageFiles()"; + url = scheme + '://' + url; + var dir = "zotero"; + var username = this.username; + var password = this.password; + + return this.init(url, dir, username, password); + }, + + + /** + * Begin download process for individual file + * + * @param {Zotero.Sync.Storage.Request} [request] + */ + downloadFile: function (request) { + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + if (!item) { + throw new Error("Item '" + request.name + "' not found"); + } + + // Retrieve modification time from server to store locally afterwards + getStorageModificationTime(item, function (item, mdate) { + if (!request.isRunning()) { + Zotero.debug("Download request '" + request.name + + "' is no longer running after getting mod time"); + return; + } + + if (!mdate) { + Zotero.debug("Remote file not found for item " + Zotero.Items.getLibraryKeyHash(item)); + request.finish(); + return; + } + + try { + var syncModTime = mdate.getTime(); + + // Skip download if local file exists and matches mod time + var file = item.getFile(); + if (file && file.exists() && syncModTime == file.lastModifiedTime) { + Zotero.debug("File mod time matches remote file -- skipping download"); + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + var updateItem = syncState != 1; + Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + onChangesMade(); + request.finish(); + return; + } + + var uri = getItemURI(item); + var destFile = Zotero.getTempDirectory(); + destFile.append(item.key + '.zip.tmp'); + if (destFile.exists()) { + destFile.remove(false); + } + + var listener = new Zotero.Sync.Storage.StreamListener( + { + onStart: function (request, data) { + if (data.request.isFinished()) { + Zotero.debug("Download request " + data.request.name + + " stopped before download started -- closing channel"); + request.cancel(0x804b0002); // NS_BINDING_ABORTED + return; + } + }, + onProgress: function (a, b, c) { + request.onProgress(a, b, c) + }, + onStop: function (request, status, response, data) { + if (status == 404) { + var msg = "Remote ZIP file not found for item " + item.key; + Zotero.debug(msg, 2); + Components.utils.reportError(msg); + + // Delete the orphaned prop file + deleteStorageFiles([item.key + ".prop"]); + + data.request.finish(); + return; + } + else if (status != 200) { + var msg = "Unexpected status code " + status + + " for request " + data.request.name + + " in Zotero.Sync.Storage.Module.WebDAV.downloadFile()"; + Zotero.debug(msg, 1); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(_defaultError); + } + + // Don't try to process if the request has been cancelled + if (data.request.isFinished()) { + Zotero.debug("Download request " + data.request.name + + " is no longer running after file download"); + return; + } + + Zotero.debug("Finished download of " + destFile.path); + + try { + Zotero.Sync.Storage.processDownload(data); + data.request.finish(); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } + }, + request: request, + item: item, + compressed: true, + syncModTime: syncModTime + } + ); + + // Don't display password in console + var disp = uri.clone(); + if (disp.password) { + disp.password = '********'; + } + Zotero.debug('Saving ' + disp.spec + ' with saveURI()'); + const nsIWBP = Components.interfaces.nsIWebBrowserPersist; + var wbp = Components + .classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"] + .createInstance(nsIWBP); + wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE; + wbp.progressListener = listener; + wbp.saveURI(uri, null, null, null, null, destFile); + } + catch (e) { + request.error(e); + } + }); + }, + + + uploadFile: function (request) { + Zotero.Sync.Storage.createUploadFile(request, function (data) { processUploadFile(data); }); + }, + + + getLastSyncTime: function (callback) { + // Cache the credentials at the root URI + var self = this; + this.cacheCredentials(function () { + try { + var uri = this.rootURI; + var successFileURI = uri.clone(); + successFileURI.spec += "lastsync"; + Zotero.HTTP.doGet(successFileURI, function (req) { + var ts = undefined; + try { + if (req.responseText) { + Zotero.debug(req.responseText); + } + Zotero.debug(req.status); + + if (req.status == 403) { + Zotero.debug("Clearing WebDAV authentication credentials", 2); + _cachedCredentials = false; + } + + if (req.status != 200 && req.status != 404) { + var msg = "Unexpected status code " + req.status + " for HEAD request " + + "in Zotero.Sync.Storage.Module.WebDAV.getLastSyncTime()"; + Zotero.debug(msg, 1); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(_defaultError); + } + + if (req.status == 200) { + var lastModified = req.getResponseHeader("Last-Modified"); + var date = new Date(lastModified); + Zotero.debug("Last successful storage sync was " + date); + ts = Zotero.Date.toUnixTimestamp(date); + } + else { + ts = null; + } + } + finally { + callback(ts); + } + }); + return; + } + catch (e) { + Zotero.debug(e); + Components.utils.reportError(e); + callback(); + return; + } + }); + }, - // Strip XML declaration and convert to E4X - var xml = new XML(req.responseText.replace(/<\?xml.*\?>/, '')); - var deleteFiles = []; - var trailingSlash = !!path.match(/\/$/); - for each(var response in xml.D::response) { - var href = response.D::href.toString(); - - // Strip trailing slash if there isn't one on the root path - if (!trailingSlash) { - href = href.replace(/\/$/, "") - } - - // Absolute - if (href.match(/^https?:\/\//)) { - var ios = Components.classes["@mozilla.org/network/io-service;1"]. - getService(Components.interfaces.nsIIOService); - var href = ios.newURI(href, null, null); - href = href.path; + setLastSyncTime: function (callback) { + try { + var uri = this.rootURI; + var successFileURI = uri.clone(); + successFileURI.spec += "lastsync"; + + Zotero.HTTP.WebDAV.doPut(successFileURI, " ", function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 200: + case 201: + case 204: + getLastSyncTime(function (ts) { + if (ts) { + var sql = "REPLACE INTO version VALUES ('storage_webdav', ?)"; + Zotero.DB.query(sql, { int: ts }); + } + if (callback) { + callback(); + } + }); + return; + } + + var msg = "Unexpected error code " + req.status + " uploading storage success file"; + Zotero.debug(msg, 2); + Components.utils.reportError(msg); + if (callback) { + callback(); + } + }); } - - // Skip root URI - if (href == path - // Some Apache servers respond with a "/zotero" href - // even for a "/zotero/" request - || (trailingSlash && href + '/' == path) - // Try URL-encoded as well, as above - || decodeURIComponent(href) == path) { - continue; + catch (e) { + Zotero.debug(e); + Components.utils.reportError(e); + if (callback) { + callback(); + } + return; } - - if (href.indexOf(path) == -1 - // Try URL-encoded as well, in case there's a '~' or similar - // character in the URL and the server (e.g., Sakai) is - // encoding the value - && decodeURIComponent(href).indexOf(path) == -1) { - self.onError("DAV:href '" + href - + "' does not begin with path '" + path + "' in " + funcName); + }, + + + cacheCredentials: function (callback) { + if (_cachedCredentials) { + Zotero.debug("Credentials are already cached"); + setTimeout(function () { + callback(); + }, 0); + return false; } - var matches = href.match(/[^\/]+$/); - if (!matches) { - self.onError("Unexpected href '" + href + "' in " + funcName) - } - var file = matches[0]; + Zotero.HTTP.doOptions(this.rootURI, function (req) { + checkResponse(req); + + if (req.status != 200) { + var msg = "Unexpected status code " + req.status + " for OPTIONS request " + + "in Zotero.Sync.Storage.Module.WebDAV.getLastSyncTime()"; + Zotero.debug(msg, 1); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(_defaultErrorRestart); + } + Zotero.debug("Credentials are cached"); + _cachedCredentials = true; + callback(); + }); + return true; + }, + + + /** + * @param {Function} callback Function to pass URI and result value to + * @param {Object} errorCallbacks + */ + checkServer: function (callback) { + this.initFromPrefs(); - if (file.indexOf('.') == 0) { - Zotero.debug("Skipping hidden file " + file); - continue; - } - if (!file.match(/\.zip$/) && !file.match(/\.prop$/)) { - Zotero.debug("Skipping file " + file); - continue; + try { + var parentURI = this.parentURI; + var uri = this.rootURI; } - - var key = file.replace(/\.(zip|prop)$/, ''); - var item = Zotero.Items.getByLibraryAndKey(null, key); - if (item) { - Zotero.debug("Skipping existing file " + file); - continue; + catch (e) { + switch (e.name) { + case 'Z_ERROR_NO_URL': + callback(null, Zotero.Sync.Storage.ERROR_NO_URL); + return; + + case 'Z_ERROR_NO_PASSWORD': + callback(null, Zotero.Sync.Storage.ERROR_NO_PASSWORD); + return; + + default: + Zotero.debug(e); + Components.utils.reportError(e); + callback(null, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } } - Zotero.debug("Checking orphaned file " + file); - - // TODO: Parse HTTP date properly - var lastModified = response..*::getlastmodified.toString(); - lastModified = Zotero.Date.strToISO(lastModified); - lastModified = Zotero.Date.sqlToDate(lastModified); + var requestHolder = { request: null }; - // Delete files older than a day before last sync time - var days = (lastSyncDate - lastModified) / 1000 / 60 / 60 / 24; - - if (days > daysBeforeSyncTime) { - deleteFiles.push(file); - } - } - - self._deleteStorageFiles(deleteFiles, function (results) { - Zotero.Prefs.set("lastWebDAVOrphanPurge", Math.round(new Date().getTime() / 1000)) - if (callback) { - callback(results); - } - }); - }, - { Depth: 1 }); -} - - -/** - * Create a Zotero directory on the storage server - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._createServerDirectory = function (callback) { - var uri = this.rootURI; - Zotero.HTTP.WebDAV.doMkCol(uri, function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - - switch (req.status) { - case 201: - callback(uri, Zotero.Sync.Storage.SUCCESS); - break; + var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; + var D = new Namespace("D", "DAV:"); + var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"'; - case 401: - callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); - return; + var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); + requestXML.D::prop = ''; + // IIS 5.1 requires at least one property in PROPFIND + requestXML.D::prop.D::getcontentlength = ''; - case 403: - callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); - return; + var xmlstr = prolog + requestXML.toXMLString(); - case 405: - callback(uri, Zotero.Sync.Storage.ERROR_NOT_ALLOWED); - return; + // Test whether URL is WebDAV-enabled + var request = Zotero.HTTP.doOptions(uri, function (req) { + // Timeout + if (req.status == 0) { + checkResponse(req); + + callback(uri, Zotero.Sync.Storage.ERROR_UNREACHABLE); + return; + } + + Zotero.debug(req.getAllResponseHeaders()); + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 400: + callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + case 403: + callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); + return; + + case 500: + callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); + return; + } + + var dav = req.getResponseHeader("DAV"); + if (dav == null) { + callback(uri, Zotero.Sync.Storage.ERROR_NOT_DAV); + return; + } + + // Get the Authorization header used in case we need to do a request + // on the parent below + var channelAuthorization = Zotero.HTTP.getChannelAuthorization(req.channel); + + var headers = { Depth: 0 }; + + // Test whether Zotero directory exists + Zotero.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 207: + // Test if Zotero directory is writable + var testFileURI = uri.clone(); + testFileURI.spec += "zotero-test-file"; + Zotero.HTTP.WebDAV.doPut(testFileURI, " ", function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 200: + case 201: + case 204: + Zotero.HTTP.doGet( + testFileURI, + function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 200: + // Delete test file + Zotero.HTTP.WebDAV.doDelete( + testFileURI, + function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 200: // IIS 5.1 and Sakai return 200 + case 204: + callback( + uri, + Zotero.Sync.Storage.SUCCESS + ); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + case 403: + callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); + return; + + default: + callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + } + ); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + case 403: + callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); + return; + + // IIS 6+ configured not to serve extensionless files or .prop files + // http://support.microsoft.com/kb/326965 + case 404: + callback(uri, Zotero.Sync.Storage.ERROR_FILE_MISSING_AFTER_UPLOAD); + return; + + case 500: + callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); + return; + + default: + callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + } + ); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + case 403: + callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); + return; + + case 500: + callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); + return; + + default: + callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + }); + return; + + case 400: + callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + case 403: + callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); + return; + + case 404: + // Include Authorization header from /zotero request, + // since Firefox probably won't apply it to the parent request + var newHeaders = {}; + for (var header in headers) { + newHeaders[header] = headers[header]; + } + newHeaders["Authorization"] = channelAuthorization; + + // Zotero directory wasn't found, so see if at least + // the parent directory exists + Zotero.HTTP.WebDAV.doProp("PROPFIND", this.parentURI, xmlstr, + function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + // Parent directory existed + case 207: + callback(uri, Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND); + return; + + case 400: + callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + // Parent directory wasn't found either + case 404: + callback(uri, Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND); + return; + + default: + callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + }, newHeaders); + return; + + case 500: + callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); + return; + + default: + callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + }, headers); + }); - case 500: - callback(uri, Zotero.Sync.Storage.ERROR_SERVER_ERROR); - return; + if (!request) { + callback(uri, Zotero.Sync.Storage.ERROR_OFFLINE); + } - default: - callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); - return; - } - }); -} - - - -// -// Private methods -// - -/** - * Get the storage URI for an item - * - * @inner - * @param {Zotero.Item} - * @return {nsIURI} URI of file on storage server - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._getItemURI = function (item) { - var uri = this.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 - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._getItemPropertyURI = function (item) { - var uri = this.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 - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._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 - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._deleteStorageFiles = function (files, callback, session) { - var results = { - deleted: [], - missing: [], - error: [] - }; - - if (files.length == 0) { - if (callback) { - callback(results); - } - return; - } - - var self = session ? session : this; - - for (var i=0; i<files.length; i++) { - let last = (i == files.length - 1); - let fileName = files[i]; + requestHolder.request = request; + return requestHolder; + }, - let deleteURI = self.rootURI; - // This should never happen, but let's be safe - if (!deleteURI.spec.match(/\/$/)) { - if (callback) { - callback(deleted); + + checkServerCallback: function (uri, status, window, skipSuccessMessage) { + var promptService = + Components.classes["@mozilla.org/embedcomp/prompt-service;1"]. + createInstance(Components.interfaces.nsIPromptService); + if (uri) { + var spec = uri.scheme + '://' + uri.hostPort + uri.path; } - this.onError("Root URI does not end in slash in " - + "Zotero.Sync.Storage._deleteStorageFiles()"); - } - deleteURI.QueryInterface(Components.interfaces.nsIURL); - deleteURI.fileName = files[i]; - deleteURI.QueryInterface(Components.interfaces.nsIURI); - Zotero.HTTP.WebDAV.doDelete(deleteURI, function (req) { - switch (req.status) { - case 204: - // IIS 5.1 and Sakai return 200 - case 200: - var fileDeleted = true; + + switch (status) { + case Zotero.Sync.Storage.SUCCESS: + if (!skipSuccessMessage) { + promptService.alert( + window, + Zotero.getString('sync.storage.serverConfigurationVerified'), + Zotero.getString('sync.storage.fileSyncSetUp') + ); + } + Zotero.Prefs.set("sync.storage.verified", true); + return true; + + case Zotero.Sync.Storage.ERROR_NO_URL: + var errorMessage = Zotero.getString('sync.storage.error.webdav.enterURL'); break; - case 404: - var fileDeleted = false; + case Zotero.Sync.Storage.ERROR_NO_PASSWORD: + var errorMessage = Zotero.getString('sync.error.enterPassword'); break; - default: - if (last && callback) { - callback(results); + case Zotero.Sync.Storage.ERROR_UNREACHABLE: + var errorMessage = Zotero.getString('sync.storage.error.serverCouldNotBeReached', uri.host); + break; + + case Zotero.Sync.Storage.ERROR_NOT_DAV: + var errorMessage = Zotero.getString('sync.storage.error.webdav.invalidURL', spec); + break; + + case Zotero.Sync.Storage.ERROR_AUTH_FAILED: + var errorTitle = Zotero.getString('general.permissionDenied'); + var errorMessage = Zotero.localeJoin([ + Zotero.getString('sync.storage.error.webdav.invalidLogin'), + Zotero.getString('sync.storage.error.checkFileSyncSettings') + ]); + break; + + case Zotero.Sync.Storage.ERROR_FORBIDDEN: + var errorTitle = Zotero.getString('general.permissionDenied'); + var errorMessage = Zotero.localeJoin([ + Zotero.getString('sync.storage.error.webdav.permissionDenied', uri.path), + Zotero.getString('sync.storage.error.checkFileSyncSettings') + ]); + break; + + case Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND: + var errorTitle = Zotero.getString('sync.storage.error.directoryNotFound'); + var parentSpec = spec.replace(/\/zotero\/$/, ""); + var errorMessage = Zotero.getString('sync.storage.error.doesNotExist', parentSpec); + break; + + case Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND: + var create = promptService.confirmEx( + window, + Zotero.getString('sync.storage.error.directoryNotFound'), + Zotero.getString('sync.storage.error.doesNotExist', spec) + "\n\n" + + Zotero.getString('sync.storage.error.createNow'), + promptService.BUTTON_POS_0 + * promptService.BUTTON_TITLE_IS_STRING + + promptService.BUTTON_POS_1 + * promptService.BUTTON_TITLE_CANCEL, + Zotero.getString('general.create'), + null, null, null, {} + ); + + if (create != 0) { + return; } - results.error.push(fileName); - var msg = "An error occurred attempting to delete " - + "'" + fileName - + "' (" + req.status + " " + req.statusText + ")."; - self.onError(msg); - return; + createServerDirectory(function (uri, status) { + switch (status) { + case Zotero.Sync.Storage.SUCCESS: + if (!skipSuccessMessage) { + promptService.alert( + window, + Zotero.getString('sync.storage.serverConfigurationVerified'), + Zotero.getString('sync.storage.fileSyncSetUp') + ); + } + Zotero.Prefs.set("sync.storage.verified", true); + return true; + + case Zotero.Sync.Storage.ERROR_FORBIDDEN: + var errorTitle = Zotero.getString('general.permissionDenied'); + var errorMessage = Zotero.getString('sync.storage.error.permissionDeniedAtAddress') + "\n\n" + + spec + "\n\n" + + Zotero.getString('sync.storage.error.checkFileSyncSettings'); + break; + } + + // TEMP + if (!errorMessage) { + var errorMessage = status; + } + promptService.alert(window, errorTitle, errorMessage); + }); + + return false; + + case Zotero.Sync.Storage.ERROR_FILE_MISSING_AFTER_UPLOAD: + // TODO: localize + var errorTitle = "WebDAV Server Configuration Error"; + var errorMessage = "Your WebDAV server must be configured to serve files without extensions " + + "and files with .prop extensions in order to work with Zotero."; + break; + + case Zotero.Sync.Storage.ERROR_SERVER_ERROR: + // TODO: localize + var errorTitle = "WebDAV Server Configuration Error"; + var errorMessage = "Your WebDAV server returned an internal error." + + "\n\n" + Zotero.getString('sync.storage.error.checkFileSyncSettings'); + break; + + case Zotero.Sync.Storage.ERROR_UNKNOWN: + var errorMessage = Zotero.localeJoin([ + Zotero.getString('general.unknownErrorOccurred'), + Zotero.getString('sync.storage.error.checkFileSyncSettings') + ]); + break; } - // If an item file URI, get the property URI - var deletePropURI = self._getPropertyURIFromItemURI(deleteURI); - if (!deletePropURI) { - if (fileDeleted) { - results.deleted.push(fileName); - } - else { - results.missing.push(fileName); + if (!skipSuccessMessage) { + if (!errorTitle) { + var errorTitle = Zotero.getString("general.error"); } - if (last && callback) { - callback(results); + // TEMP + if (!errorMessage) { + var errorMessage = status; } + promptService.alert(window, errorTitle, errorMessage); + } + return false; + }, + + + /** + * Remove files on storage server that were deleted locally more than + * sync.storage.deleteDelayDays days ago + * + * @param {Function} callback Passed number of files deleted + */ + purgeDeletedStorageFiles: function (callback) { + if (!this.active) { return; } - // If property file appears separately in delete queue, - // remove it, since we're taking care of it here - var propIndex = files.indexOf(deletePropURI.fileName); - if (propIndex > i) { - delete files[propIndex]; - i--; - last = (i == files.length - 1); + Zotero.debug("Purging deleted storage files"); + var files = Zotero.Sync.Storage.getDeletedFiles(); + if (!files) { + Zotero.debug("No files to delete remotely"); + if (callback) { + callback(); + } + Zotero.Sync.Storage.EventManager.skip(); + return; } - // Delete property file - Zotero.HTTP.WebDAV.doDelete(deletePropURI, function (req) { - switch (req.status) { - case 204: - // IIS 5.1 and Sakai return 200 - case 200: - results.deleted.push(fileName); - break; + // Add .zip extension + var files = files.map(function (file) file + ".zip"); + + deleteStorageFiles(files, function (results) { + // Remove deleted and nonexistent files from storage delete log + var toPurge = results.deleted.concat(results.missing); + if (toPurge.length > 0) { + var done = 0; + var maxFiles = 999; + var numFiles = toPurge.length; - case 404: - if (fileDeleted) { - results.deleted.push(fileName); - } - else { - results.missing.push(fileName); - } - break; + Zotero.DB.beginTransaction(); - default: - var error = true; + do { + var chunk = toPurge.splice(0, maxFiles); + var sql = "DELETE FROM storageDeleteLog WHERE key IN (" + + chunk.map(function () '?').join() + ")"; + Zotero.DB.query(sql, chunk); + done += chunk.length; + } + while (done < numFiles); + + Zotero.DB.commitTransaction(); } - if (last && callback) { - callback(results); + if (callback) { + callback(results.deleted.length); } - if (error) { - results.error.push(fileName); - var msg = "An error occurred attempting to delete " - + "'" + fileName - + "' (" + req.status + " " + req.statusText + ")."; - self.onError(msg); - } + Zotero.Sync.Storage.EventManager.success(); }); - }); - } -} - - -/** - * Checks for an invalid SSL certificate and displays a nice error - */ -Zotero.Sync.Storage.Session.WebDAV.prototype._checkResponse = function (req, obj) { - var channel = req.channel; - if (!channel instanceof Ci.nsIChannel) { - obj.onError('No HTTPS channel available'); - } - 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; + }, + + + /** + * Delete orphaned storage files older than a day before last sync time + * + * @param {Function} callback + */ + purgeOrphanedStorageFiles: function (callback) { + const daysBeforeSyncTime = 1; + + if (!this.active) { + Zotero.Sync.Storage.EventManager.skip(); + return; } - catch (e) { - Zotero.debug(e); + + // If recently purged, skip + var lastpurge = Zotero.Prefs.get('lastWebDAVOrphanPurge'); + var days = 10; + if (lastpurge && new Date(lastpurge * 1000) > (new Date() - (1000 * 60 * 60 * 24 * days))) { + Zotero.Sync.Storage.EventManager.skip(); + return; } - var msg = Zotero.localeJoin([ - Zotero.getString('sync.storage.error.webdav.sslCertificateError', host), - Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo') - ]); + Zotero.debug("Purging orphaned storage files"); - obj.onError(msg); - return; - } - else if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_BROKEN) == Ci.nsIWebProgressListener.STATE_IS_BROKEN) { - var msg = Zotero.localeJoin([ - Zotero.getString('sync.storage.error.webdav.sslConnectionError', host), - Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo') - ]); - obj.onError(msg); - return; + var uri = this.rootURI; + var path = uri.path; + + var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; + var D = new Namespace("D", "DAV:"); + var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"'; + + var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); + requestXML.D::prop = ''; + requestXML.D::prop.D::getlastmodified = ''; + + var xmlstr = prolog + requestXML.toXMLString(); + + var lastSyncDate = new Date(Zotero.Sync.Server.lastLocalSyncTime * 1000); + + Zotero.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) { + Zotero.debug(req.responseText); + + var funcName = "Zotero.Sync.Storage.purgeOrphanedStorageFiles()"; + + // Strip XML declaration and convert to E4X + var xml = new XML(req.responseText.replace(/<\?xml.*\?>/, '')); + + var deleteFiles = []; + var trailingSlash = !!path.match(/\/$/); + for each(var response in xml.D::response) { + var href = response.D::href.toString(); + + // Strip trailing slash if there isn't one on the root path + if (!trailingSlash) { + href = href.replace(/\/$/, "") + } + + // Absolute + if (href.match(/^https?:\/\//)) { + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + var href = ios.newURI(href, null, null); + href = href.path; + } + + // Skip root URI + if (href == path + // Some Apache servers respond with a "/zotero" href + // even for a "/zotero/" request + || (trailingSlash && href + '/' == path) + // Try URL-encoded as well, as above + || decodeURIComponent(href) == path) { + continue; + } + + if (href.indexOf(path) == -1 + // Try URL-encoded as well, in case there's a '~' or similar + // character in the URL and the server (e.g., Sakai) is + // encoding the value + && decodeURIComponent(href).indexOf(path) == -1) { + Zotero.Sync.Storage.EventManager.error( + "DAV:href '" + href + "' does not begin with path '" + + path + "' in " + funcName + ); + } + + var matches = href.match(/[^\/]+$/); + if (!matches) { + Zotero.Sync.Storage.EventManager.error( + "Unexpected href '" + href + "' in " + funcName + ) + } + var file = matches[0]; + + if (file.indexOf('.') == 0) { + Zotero.debug("Skipping hidden file " + file); + continue; + } + if (!file.match(/\.zip$/) && !file.match(/\.prop$/)) { + Zotero.debug("Skipping file " + file); + continue; + } + + var key = file.replace(/\.(zip|prop)$/, ''); + var item = Zotero.Items.getByLibraryAndKey(null, key); + if (item) { + Zotero.debug("Skipping existing file " + file); + continue; + } + + Zotero.debug("Checking orphaned file " + file); + + // TODO: Parse HTTP date properly + var lastModified = response..*::getlastmodified.toString(); + lastModified = Zotero.Date.strToISO(lastModified); + lastModified = Zotero.Date.sqlToDate(lastModified); + + // Delete files older than a day before last sync time + var days = (lastSyncDate - lastModified) / 1000 / 60 / 60 / 24; + + if (days > daysBeforeSyncTime) { + deleteFiles.push(file); + } + } + + deleteStorageFiles(deleteFiles, function (results) { + Zotero.Prefs.set("lastWebDAVOrphanPurge", Math.round(new Date().getTime() / 1000)) + if (callback) { + callback(results); + } + Zotero.Sync.Storage.EventManager.success(); + }); + }, { Depth: 1 }); } - } -} + }; +}()); diff --git a/chrome/content/zotero/xpcom/storage/zfs.js b/chrome/content/zotero/xpcom/storage/zfs.js @@ -24,486 +24,233 @@ */ -Zotero.Sync.Storage.Session.ZFS = function (callbacks) { - this.onChangesMade = callbacks.onChangesMade ? callbacks.onChangesMade : function () {}; - this.onError = callbacks.onError ? callbacks.onError : function () {}; +Zotero.Sync.Storage.Module.ZFS = (function () { + var _rootURI; + var _userURI; + var _cachedCredentials = false; + var _lastSyncTime = null; - this._rootURI; - this._userURI; - this._cachedCredentials = false; - this._lastSyncTime = null; -} - -Zotero.Sync.Storage.Session.ZFS.prototype.name = "ZFS"; - -Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('includeUserFiles', function () { - return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'zotero'; -}); - -Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('includeGroupFiles', function () { - return Zotero.Prefs.get("sync.storage.groups.enabled"); -}); - -Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('enabled', function () { - return this.includeUserFiles || this.includeGroupFiles; -}); - -Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('active', function () { - return this.enabled; -}); - - -Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('rootURI', function () { - if (!this._rootURI) { - throw ("Root URI not initialized in Zotero.Sync.Storage.Session.ZFS.rootURI"); - } - return this._rootURI.clone(); -}); - -Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('userURI', function () { - if (!this._userURI) { - throw ("User URI not initialized in Zotero.Sync.Storage.Session.ZFS.userURI"); - } - return this._userURI.clone(); -}); - -Zotero.Sync.Storage.Session.ZFS.prototype.init = function (url, username, password) { - var ios = Components.classes["@mozilla.org/network/io-service;1"]. - getService(Components.interfaces.nsIIOService); - try { - var uri = ios.newURI(url, null, null); - if (username) { - uri.username = username; - uri.password = password; - } - } - catch (e) { - Zotero.debug(e); - Components.utils.reportError(e); - return false; - } - this._rootURI = uri; - - uri = uri.clone(); - uri.spec += 'users/' + Zotero.userID + '/'; - this._userURI = uri; - - return true; -} - - -Zotero.Sync.Storage.Session.ZFS.prototype.initFromPrefs = function () { - var url = ZOTERO_CONFIG.API_URL; - var username = Zotero.Sync.Server.username; - var password = Zotero.Sync.Server.password; - return this.init(url, username, password); -} - - -/** - * Get file metadata on storage server - * - * @param {Zotero.Item} item - * @param {Function} callback Callback f(item, etag) - */ -Zotero.Sync.Storage.Session.ZFS.prototype._getStorageFileInfo = function (item, callback) { - var uri = this._getItemInfoURI(item); - - var self = this; - - Zotero.HTTP.doGet(uri, function (req) { - var funcName = "Zotero.Sync.Storage.Session.ZFS._getStorageFileInfo()"; - - if (req.status == 404) { - callback(item, false); - return; - } - else if (req.status != 200) { - var msg = "Unexpected status code " + req.status + " in " + funcName - + " (" + Zotero.Items.getLibraryKeyHash(item) + ")"; - Zotero.debug(msg, 1); - Zotero.debug(req.responseText); - Components.utils.reportError(msg); - self.onError(); - return; - } - - 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.responseText); - Components.utils.reportError(msg); - try { - Zotero.debug(req.getAllResponseHeaders()); - } - catch (e) { - Zotero.debug("Response headers unavailable"); - } - // TODO: localize? - var msg = "A file sync error occurred. Please restart Firefox and/or your computer and try syncing again.\n\n" - + "If the error persists, there may be a problem with either your computer or your network: security software, proxy server, VPN, etc. " - + "Try disabling any security/firewall software you're using or, if this is a laptop, try from a different network."; - self.onError(msg); - return; - } - 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); - - callback(item, info); - }); -} - - -/** - * Begin download process for individual file - * - * @param {Zotero.Sync.Storage.Request} [request] - */ -Zotero.Sync.Storage.Session.ZFS.prototype.downloadFile = function (request) { - var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); - if (!item) { - throw ("Item '" + request.name + "' not found in Zotero.Sync.Storage.Session.ZFS.downloadFile()"); - } - - var self = this; - - // Retrieve file info from server to store locally afterwards - this._getStorageFileInfo(item, function (item, info) { - if (!request.isRunning()) { - Zotero.debug("Download request '" + request.name - + "' is no longer running after getting remote file info"); - return; - } - - if (!info) { - Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key); - request.finish(); - return; - } + /** + * Get file metadata on storage server + * + * @param {Zotero.Item} item + * @param {Function} callback Callback f(item, etag) + */ + function getStorageFileInfo(item, callback) { + var uri = getItemInfoURI(item); - try { - var syncModTime = info.mtime; - var syncHash = info.hash; - - var file = item.getFile(); - // Skip download if local file exists and matches mod time - if (file && file.exists()) { - if (syncModTime == file.lastModifiedTime) { - Zotero.debug("File mod time matches remote file -- skipping download"); - - Zotero.DB.beginTransaction(); - var syncState = Zotero.Sync.Storage.getSyncState(item.id); - //var updateItem = syncState != 1; - var updateItem = false; - Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); - Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); - Zotero.DB.commitTransaction(); - self.onChangesMade(); - request.finish(); - return; - } - // If not compressed, check hash, in case only timestamp changed - else if (!info.compressed && item.attachmentHash == syncHash) { - Zotero.debug("File hash matches remote file -- skipping download"); - - Zotero.DB.beginTransaction(); - var syncState = Zotero.Sync.Storage.getSyncState(item.id); - //var updateItem = syncState != 1; - var updateItem = false; - if (!info.compressed) { - Zotero.Sync.Storage.setSyncedHash(item.id, syncHash, false); - } - Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); - Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); - Zotero.DB.commitTransaction(); - self.onChangesMade(); - request.finish(); - return; - } - } + Zotero.HTTP.doGet(uri, function (req) { + var funcName = "Zotero.Sync.Storage.Module.ZFS.getStorageFileInfo()"; - var destFile = Zotero.getTempDirectory(); - if (info.compressed) { - destFile.append(item.key + '.zip.tmp'); + if (req.status == 404) { + callback(item, false); + return; } - else { - destFile.append(item.key + '.tmp'); + else if (req.status != 200) { + var msg = "Unexpected status code " + req.status + " in " + funcName + + " (" + Zotero.Items.getLibraryKeyHash(item) + ")"; + Zotero.debug(msg, 1); + Zotero.debug(req.responseText); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError); } - if (destFile.exists()) { + 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.responseText); + Components.utils.reportError(msg); try { - destFile.remove(false); + Zotero.debug(req.getAllResponseHeaders()); } catch (e) { - Zotero.File.checkFileAccessError(e, destFile, 'delete'); + Zotero.debug("Response headers unavailable"); } + // TODO: localize? + var msg = "A file sync error occurred. Please restart Firefox and/or your computer and try syncing again.\n\n" + + "If the error persists, there may be a problem with either your computer or your network: security software, proxy server, VPN, etc. " + + "Try disabling any security/firewall software you're using or, if this is a laptop, try from a different network."; + Zotero.Sync.Storage.EventManager.error(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); - // saveURI() below appears not to create empty files for Content-Length: 0, - // so we create one here just in case - try { - destFile.create(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644); - } - catch (e) { - Zotero.File.checkFileAccessError(e, destFile, 'create'); + callback(item, info); + }); + } + + + /** + * Upload the 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); + getStorageFileInfo(item, function (item, info) { + if (request.isFinished()) { + Zotero.debug("Upload request '" + request.name + + "' is no longer running after getting file info"); + return; } - var listener = new Zotero.Sync.Storage.StreamListener( - { - onStart: function (request, data) { - if (data.request.isFinished()) { - Zotero.debug("Download request " + data.request.name - + " stopped before download started -- closing channel"); - request.cancel(0x804b0002); // NS_BINDING_ABORTED - return; + try { + // 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"); } - }, - onProgress: function (a, b, c) { - request.onProgress(a, b, c) - }, - onStop: function (request, status, response, data) { - if (status != 200) { - var msg = "Unexpected status code " + status - + " for request " + data.request.name + " in Zotero.Sync.Storage.Session.ZFS.downloadFile()"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - self.onError(); - return; + // 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; } - // 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); + 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(); + Zotero.Sync.Storage.EventManager.changesMade(); + request.finish(); return; } - Zotero.debug("Finished download of " + destFile.path); - + var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id); + if (!useLocal && smtime != mtime) { + var localData = { modTime: fmtime }; + var remoteData = { modTime: mtime }; + Zotero.Sync.Storage.QueueManager.addConflict( + request.name, localData, remoteData + ); + Zotero.debug("Conflict -- last synced file mod time " + + "does not match time on storage server" + + " (" + smtime + " != " + mtime + ")"); + request.finish(); + return; + } + } + else { + Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key); + } + } + + getFileUploadParameters( + item, + function (item, target, uploadKey, params) { try { - Zotero.Sync.Storage.processDownload(data); - data.request.finish(); + postFile(request, item, target, uploadKey, params); } catch (e) { - self.onError(e); + Zotero.Sync.Storage.EventManager.error(e); } }, - request: request, - item: item, - compressed: info.compressed, - syncModTime: syncModTime, - syncHash: syncHash - } - ); - - var uri = self._getItemURI(item); - - // Don't display password in console - var disp = uri.clone(); - if (disp.password) { - disp.password = "********"; + function () { + updateItemFileInfo(item); + request.finish(); + } + ); } - Zotero.debug('Saving ' + disp.spec + ' with saveURI()'); - const nsIWBP = Components.interfaces.nsIWebBrowserPersist; - var wbp = Components - .classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"] - .createInstance(nsIWBP); - wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE; - wbp.progressListener = listener; - wbp.saveURI(uri, null, null, null, null, destFile); - } - catch (e) { - self.onError(e); - } - }); -} - - -Zotero.Sync.Storage.Session.ZFS.prototype.uploadFile = function (request) { - var self = this; - var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); - if (Zotero.Attachments.getNumFiles(item) > 1) { - Zotero.Sync.Storage.createUploadFile(request, function (data) { self._processUploadFile(data); }); - } - else { - this._processUploadFile({ request: request }); + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } + }); } -} - - -/** - * Upload the file to the server - * - * @param {Object} Object with 'request' property - * @return {void} - */ -Zotero.Sync.Storage.Session.ZFS.prototype._processUploadFile = function (data) { - /* - _updateSizeMultiplier( - (100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100 - ); - */ - - var request = data.request; - var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); - var self = this; - this._getStorageFileInfo(item, function (item, info) { - if (request.isFinished()) { - Zotero.debug("Upload request '" + request.name - + "' is no longer running after getting file info"); - return; - } + /** + * 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 uri = getItemURI(item); - try { - // 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(); - self.onChangesMade(); - request.finish(); - return; - } - - var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id); - if (!useLocal && smtime != mtime) { - var localData = { modTime: fmtime }; - var remoteData = { modTime: mtime }; - Zotero.Sync.Storage.QueueManager.addConflict( - request.name, localData, remoteData - ); - Zotero.debug("Conflict -- last synced file mod time " - + "does not match time on storage server" - + " (" + smtime + " != " + mtime + ")"); - request.finish(); - return; - } - } - else { - Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key); - } - } - - self._getFileUploadParameters( - item, - function (item, target, uploadKey, params) { - try { - self._postFile(request, item, target, uploadKey, params); - } - catch (e) { - self.onError(e); - } - }, - function () { - self._updateItemFileInfo(item); - request.finish(); - } - ); + 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; } - catch (e) { - self.onError(e); + else { + var file = item.getFile(); + var filename = file.leafName; + var zip = false; } - }); -} - - -/** - * 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 - */ -Zotero.Sync.Storage.Session.ZFS.prototype._getFileUploadParameters = function (item, uploadCallback, existsCallback) { - var uri = this._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; - } - else { - var file = item.getFile(); - var filename = file.leafName; - var zip = false; - } - - 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"; - } - - var self = this; - - Zotero.HTTP.doPost(uri, body, function (req) { - var funcName = "Zotero.Sync.Storage.Session.ZFS._getFileUploadParameters()"; - if (req.status == 413) { - var retry = req.getResponseHeader('Retry-After'); - if (retry) { - var minutes = Math.round(retry / 60); - var e = new Zotero.Error("You have too many queued uploads. Please try again in " + minutes + " minutes.", "ZFS_UPLOAD_QUEUE_LIMIT"); - self.onError(e); - } - else { - // TODO: localize + 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"; + } + + Zotero.HTTP.doPost(uri, body, function (req) { + var funcName = "Zotero.Sync.Storage.Module.ZFS.getFileUploadParameters()"; + + if (req.status == 413) { + var retry = req.getResponseHeader('Retry-After'); + if (retry) { + var minutes = Math.round(retry / 60); + // TODO: localize + var e = new Zotero.Error( + "You have too many queued uploads. " + + "Please try again in " + minutes + " minutes.", + "ZFS_UPLOAD_QUEUE_LIMIT" + ); + Zotero.Sync.Storage.EventManager.error(e); + } var text, buttonText = null, buttonCallback; @@ -543,518 +290,773 @@ Zotero.Sync.Storage.Session.ZFS.prototype._getFileUploadParameters = function (i dialogButtonCallback: buttonCallback } ); - self.onError(e); + Zotero.debug(e, 2); + Components.utils.reportError(e); + // Stop uploads, log warning, and continue + Zotero.Sync.Storage.QueueManager.get('upload').stop(); + Zotero.Sync.Storage.EventManager.warning(e); + Zotero.Sync.Storage.EventManager.success(); + return; } - return; - } - else if (req.status == 403) { + else if (req.status == 403) { + Zotero.debug(req.responseText); + + var groupID = Zotero.Groups.getGroupIDFromLibraryID(item.libraryID); + var e = new Zotero.Error( + "File editing denied for group", + "ZFS_FILE_EDITING_DENIED", + { + groupID: groupID + } + ); + Zotero.Sync.Storage.EventManager.error(e); + } + else if (req.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; + Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError); + } + else if (req.status != 200) { + var msg = "Unexpected status code " + req.status + " in " + funcName + + " (" + Zotero.Items.getLibraryKeyHash(item) + ")"; + Zotero.debug(msg, 1); + Zotero.debug(req.responseText); + Zotero.debug(req.getAllResponseHeaders()); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError); + } + Zotero.debug(req.responseText); - var groupID = Zotero.Groups.getGroupIDFromLibraryID(item.libraryID); - var e = new Zotero.Error("File editing denied for group", "ZFS_FILE_EDITING_DENIED", { groupID: groupID }); - self.onError(e); - return; - } - else if (req.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; + try { + // Strip XML declaration and convert to E4X + var xml = new XML(Zotero.Utilities.trim(req.responseText.replace(/<\?xml.*\?>/, ''))); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error( + "Invalid response retrieving file upload parameters" + ); + } + + if (xml.name() != 'upload' && xml.name() != 'exists') { + Zotero.Sync.Storage.EventManager.error( + "Invalid response retrieving file upload parameters" + ); } - if (!Zotero.Sync.Server.canAutoResetClient) { - Components.utils.reportError("Client has already been auto-reset -- manual sync required"); + // File was already available, so uploading isn't required + if (xml.name() == 'exists') { + existsCallback(); return; } - Zotero.Sync.Server.resetClient(); - Zotero.Sync.Server.canAutoResetClient = false; - self.onError(); - return; - } - else if (req.status != 200) { - var msg = "Unexpected status code " + req.status + " in " + funcName - + " (" + Zotero.Items.getLibraryKeyHash(item) + ")"; - Zotero.debug(msg, 1); - Zotero.debug(req.responseText); - Zotero.debug(req.getAllResponseHeaders()); - Components.utils.reportError(msg); - self.onError(); + + var url = xml.url.toString(); + var uploadKey = xml.key.toString(); + var params = {}, p = ''; + for each(var param in xml.params.children()) { + params[param.name()] = param.toString(); + } + Zotero.debug(params); + uploadCallback(item, url, uploadKey, params); + }); + } + + + 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; } - Zotero.debug(req.responseText); + var file = getUploadFile(item); - try { - // Strip XML declaration and convert to E4X - var xml = new XML(Zotero.Utilities.trim(req.responseText.replace(/<\?xml.*\?>/, ''))); - } - catch (e) { - self.onError("Invalid response retrieving file upload parameters"); - return; - } + // TODO: make sure this doesn't appear in file + var boundary = "---------------------------" + Math.random().toString().substr(2); - if (xml.name() != 'upload' && xml.name() != 'exists') { - self.onError("Invalid response retrieving file upload parameters"); - return; + 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); } - // File was already available, so uploading isn't required - if (xml.name() == 'exists') { - existsCallback(); - return; + + // 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); + + 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 url = xml.url.toString(); - var uploadKey = xml.key.toString(); - var params = {}, p = ''; - for each(var param in xml.params.children()) { - params[param.name()] = param.toString(); + var listener = new Zotero.Sync.Storage.StreamListener( + { + onProgress: function (a, b, c) { + request.onProgress(a, b, c); + }, + onStop: function (httpRequest, status, response, data) { onUploadComplete(httpRequest, status, response, data); }, + onCancel: function (httpRequest, status, data) { onUploadCancel(httpRequest, status, data); }, + request: request, + item: item, + uploadKey: uploadKey, + streams: [mis] + } + ); + channel.notificationCallbacks = listener; + + var dispURI = uri.clone(); + if (dispURI.password) { + dispURI.password = '********'; } - Zotero.debug(params); - uploadCallback(item, url, uploadKey, params); - }); -} - - -Zotero.Sync.Storage.Session.ZFS.prototype._postFile = function (request, item, url, uploadKey, params) { - if (request.isFinished()) { - Zotero.debug("Upload request " + request.name + " is no longer running after getting upload parameters"); - return; + Zotero.debug("HTTP POST of " + file.leafName + " to " + dispURI.spec); + + channel.asyncOpen(listener, null); } - var file = this._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); + function onUploadComplete(httpRequest, status, response, data) { + var request = data.request; + var item = data.item; + var uploadKey = data.uploadKey; - var conv = Components.classes["@mozilla.org/intl/converter-output-stream;1"] - .createInstance(Components.interfaces.nsIConverterOutputStream); - conv.init(out, null, 4096, "?"); + Zotero.debug("Upload of attachment " + item.key + + " finished with status code " + status); - var str = "--" + boundary + '\r\nContent-Disposition: form-data; name="' + key + '"' - + '\r\n\r\n' + params[key] + '\r\n'; - conv.writeString(str); - conv.close(); + Zotero.debug(response); - 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); - - 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); - - var self = this; - - request.setChannel(channel); - - var listener = new Zotero.Sync.Storage.StreamListener( - { - onProgress: function (a, b, c) { - request.onProgress(a, b, c); - }, - onStop: function (httpRequest, status, response, data) { self._onUploadComplete(httpRequest, status, response, data); }, - onCancel: function (httpRequest, status, data) { self._onUploadCancel(httpRequest, status, data); }, - request: request, - item: item, - uploadKey: uploadKey, - streams: [mis] + switch (status) { + case 201: + break; + + case 500: + Zotero.Sync.Storage.EventManager.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); + Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError); } - ); - channel.notificationCallbacks = listener; - - var dispURI = uri.clone(); - if (dispURI.password) { - dispURI.password = '********'; + + var uri = getItemURI(item); + var body = "update=" + uploadKey + "&mtime=" + item.attachmentModificationTime; + + // Register upload on server + Zotero.HTTP.doPost(uri, body, function (req) { + if (req.status != 204) { + var msg = "Unexpected file registration status " + req.status + + " in Zotero.Sync.Storage.ZFS.onUploadComplete()" + + " (" + Zotero.Items.getLibraryKeyHash(item) + ")"; + Zotero.debug(msg, 1); + Zotero.debug(req.responseText); + Zotero.debug(req.getAllResponseHeaders()); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError); + } + + updateItemFileInfo(item); + request.finish(); + }); } - Zotero.debug("HTTP POST of " + file.leafName + " to " + dispURI.spec); - - channel.asyncOpen(listener, null); -} - - -Zotero.Sync.Storage.Session.ZFS.prototype._onUploadComplete = function (httpRequest, status, response, data) { - 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: - break; + function updateItemFileInfo(item) { + // Mark as changed locally + Zotero.DB.beginTransaction(); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); - case 500: - this.onError("File upload failed. Please try again."); - return; + // Store file mod time + var mtime = item.attachmentModificationTime; + Zotero.Sync.Storage.setSyncedModificationTime(item.id, mtime, true); - default: - var msg = "Unexpected file upload status " + status - + " in Zotero.Sync.Storage._onUploadComplete()" - + " (" + Zotero.Items.getLibraryKeyHash(item) + ")"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - this.onError(); - return; + // Store file hash of individual files + if (Zotero.Attachments.getNumFiles(item) == 1) { + var hash = item.attachmentHash; + Zotero.Sync.Storage.setSyncedHash(item.id, hash); + } + + Zotero.DB.commitTransaction(); + + try { + if (Zotero.Attachments.getNumFiles(item) > 1) { + var file = Zotero.getTempDirectory(); + file.append(item.key + '.zip'); + file.remove(false); + } + } + catch (e) { + Components.utils.reportError(e); + } + + Zotero.Sync.Storage.EventManager.changesMade(); } - var uri = this._getItemURI(item); - var body = "update=" + uploadKey + "&mtime=" + item.attachmentModificationTime; - - var self = this; - // Register upload on server - Zotero.HTTP.doPost(uri, body, function (req) { - if (req.status != 204) { - var msg = "Unexpected file registration status " + req.status - + " in Zotero.Sync.Storage._onUploadComplete()" - + " (" + Zotero.Items.getLibraryKeyHash(item) + ")"; - Zotero.debug(msg, 1); - Zotero.debug(req.responseText); - Zotero.debug(req.getAllResponseHeaders()); - Components.utils.reportError(msg); - self.onError(); - return; + function onUploadCancel(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) { + var file = Zotero.getTempDirectory(); + file.append(item.key + '.zip'); + file.remove(false); + } + } + catch (e) { + Components.utils.reportError(e); } - self._updateItemFileInfo(item); request.finish(); - }); -} - - -Zotero.Sync.Storage.Session.ZFS.prototype._updateItemFileInfo = function (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); + /** + * 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.Module.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; } - Zotero.DB.commitTransaction(); - try { - if (Zotero.Attachments.getNumFiles(item) > 1) { - var file = Zotero.getTempDirectory(); - file.append(item.key + '.zip'); - file.remove(false); - } - } - catch (e) { - Components.utils.reportError(e); + /** + * 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.Module.ZFS.rootURI; + uri.spec += Zotero.URI.getItemPath(item) + '/file?auth=1&iskey=1&version=1&info=1'; + return uri; } - this.onChangesMade(); -} - - -Zotero.Sync.Storage.Session.ZFS.prototype._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 { + function getUploadFile(item) { if (Zotero.Attachments.getNumFiles(item) > 1) { var file = Zotero.getTempDirectory(); - file.append(item.key + '.zip'); - file.remove(false); + var filename = item.key + '.zip'; + file.append(filename); } - } - catch (e) { - Components.utils.reportError(e); + else { + var file = item.getFile(); + } + return file; } - request.finish(); -} - - -Zotero.Sync.Storage.Session.ZFS.prototype.getLastSyncTime = function (callback) { - var uri = this.userURI; - var successFileURI = uri.clone(); - successFileURI.spec += "laststoragesync?auth=1"; - - var self = this; - // Cache the credentials - if (!this._cachedCredentials) { - var uri = this.rootURI; - // TODO: move to root uri - uri.spec += "?auth=1"; - Zotero.HTTP.doGet(uri, function (req) { - if (req.status == 401) { - // TODO: localize - var msg = "File sync login failed\n\nCheck your username and password in the Sync pane of the Zotero preferences."; - self.onError(msg); - return; - } - else if (req.status != 200) { - var msg = "Unexpected status code " + req.status + " caching " - + "authentication credentials in Zotero.Sync.Storage.Session.ZFS.getLastSyncTime()"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - self.onError(Zotero.Sync.Storage.defaultErrorRestart); - return; + return { + name: "ZFS", + + get includeUserFiles() { + return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'zotero'; + }, + + get includeGroupFiles() { + return Zotero.Prefs.get("sync.storage.groups.enabled"); + }, + + get enabled() { + return this.includeUserFiles || this.includeGroupFiles; + }, + + get verified() { + return true; + }, + + get rootURI() { + if (!_rootURI) { + throw ("Root URI not initialized in Zotero.Sync.Storage.ZFS.rootURI"); } - self._cachedCredentials = true; - self.getLastSyncTime(callback); - }); - return; - } - - Zotero.HTTP.doGet(successFileURI, function (req) { - if (req.responseText) { - Zotero.debug(req.responseText); - } - Zotero.debug(req.status); + return _rootURI.clone(); + }, - if (req.status == 401 || req.status == 403) { - Zotero.debug("Clearing ZFS authentication credentials", 2); - self._cachedCredentials = false; - } + get userURI() { + if (!_userURI) { + throw ("User URI not initialized in Zotero.Sync.Storage.ZFS.userURI"); + } + return _userURI.clone(); + }, - if (req.status != 200 && req.status != 404) { - var msg = "Unexpected status code " + req.status + " getting " - + "last file sync time"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - self.onError(); - return; - } - if (req.status == 200) { - var ts = req.responseText; - var date = new Date(ts * 1000); - Zotero.debug("Last successful storage sync was " + date); - self._lastSyncTime = ts; - } - else { - var ts = null; - self._lastSyncTime = null; - } - callback(ts); - }); -} - - -Zotero.Sync.Storage.Session.ZFS.prototype.setLastSyncTime = function (callback, useLastSyncTime) { - if (useLastSyncTime) { - if (!this._lastSyncTime) { - if (callback) { - callback(); + init: function (url, username, password) { + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + try { + var uri = ios.newURI(url, null, null); + if (username) { + uri.username = username; + uri.password = password; + } } - return; - } + catch (e) { + Zotero.debug(e, 1); + Components.utils.reportError(e); + return false; + } + _rootURI = uri; + + uri = uri.clone(); + uri.spec += 'users/' + Zotero.userID + '/'; + _userURI = uri; + + return true; + }, - var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)"; - Zotero.DB.query(sql, { int: this._lastSyncTime }); - this._lastSyncTime = null; - this._cachedCredentials = false; + initFromPrefs: function () { + var url = ZOTERO_CONFIG.API_URL; + var username = Zotero.Sync.Server.username; + var password = Zotero.Sync.Server.password; + return this.init(url, username, password); + }, - if (callback) { - callback(); - } - return; - } - this._lastSyncTime = null; - - var uri = this.userURI; - var successFileURI = uri.clone(); - successFileURI.spec += "laststoragesync?auth=1"; - - var self = this; - - Zotero.HTTP.doPost(successFileURI, "", function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - if (req.status != 200) { - var msg = "Unexpected status code " + req.status + " setting " - + "last file sync time"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg); - self.onError(); - return; - } + /** + * Begin download process for individual file + * + * @param {Zotero.Sync.Storage.Request} [request] + */ + downloadFile: function (request) { + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + if (!item) { + throw new Error("Item '" + request.name + "' not found"); + } + + // Retrieve file info from server to store locally afterwards + getStorageFileInfo(item, function (item, info) { + if (!request.isRunning()) { + Zotero.debug("Download request '" + request.name + + "' is no longer running after getting remote file info"); + return; + } + + if (!info) { + Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key); + request.finish(); + return; + } + + try { + var syncModTime = info.mtime; + var syncHash = info.hash; + + var file = item.getFile(); + // Skip download if local file exists and matches mod time + if (file && file.exists()) { + if (syncModTime == file.lastModifiedTime) { + Zotero.debug("File mod time matches remote file -- skipping download"); + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + //var updateItem = syncState != 1; + var updateItem = false; + Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + Zotero.Sync.Storage.EventManager.changesMade(); + request.finish(); + return; + } + // If not compressed, check hash, in case only timestamp changed + else if (!info.compressed && item.attachmentHash == syncHash) { + Zotero.debug("File hash matches remote file -- skipping download"); + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + //var updateItem = syncState != 1; + var updateItem = false; + if (!info.compressed) { + Zotero.Sync.Storage.setSyncedHash(item.id, syncHash, false); + } + Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + Zotero.Sync.Storage.EventManager.changesMade(); + request.finish(); + return; + } + } + + var destFile = Zotero.getTempDirectory(); + if (info.compressed) { + destFile.append(item.key + '.zip.tmp'); + } + else { + destFile.append(item.key + '.tmp'); + } + + if (destFile.exists()) { + try { + destFile.remove(false); + } + catch (e) { + Zotero.File.checkFileAccessError(e, destFile, 'delete'); + } + } + + // saveURI() below appears not to create empty files for Content-Length: 0, + // so we create one here just in case + try { + destFile.create(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644); + } + catch (e) { + Zotero.File.checkFileAccessError(e, destFile, 'create'); + } + + var listener = new Zotero.Sync.Storage.StreamListener( + { + onStart: function (request, data) { + if (data.request.isFinished()) { + Zotero.debug("Download request " + data.request.name + + " stopped before download started -- closing channel"); + request.cancel(0x804b0002); // NS_BINDING_ABORTED + return; + } + }, + onProgress: function (a, b, c) { + request.onProgress(a, b, c) + }, + onStop: function (request, status, response, data) { + if (status != 200) { + var msg = "Unexpected status code " + status + + " for request " + data.request.name + + " in Zotero.Sync.Storage.Module.ZFS.downloadFile()"; + Zotero.debug(msg, 1); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError); + } + + // Don't try to process if the request has been cancelled + if (data.request.isFinished()) { + Zotero.debug("Download request " + data.request.name + + " is no longer running after file download", 2); + return; + } + + Zotero.debug("Finished download of " + destFile.path); + + try { + Zotero.Sync.Storage.processDownload(data); + data.request.finish(); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } + }, + request: request, + item: item, + compressed: info.compressed, + syncModTime: syncModTime, + syncHash: syncHash + } + ); + + var uri = getItemURI(item); + + // Don't display password in console + var disp = uri.clone(); + if (disp.password) { + disp.password = "********"; + } + Zotero.debug('Saving ' + disp.spec + ' with saveURI()'); + const nsIWBP = Components.interfaces.nsIWebBrowserPersist; + var wbp = Components + .classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"] + .createInstance(nsIWBP); + wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE; + wbp.progressListener = listener; + wbp.saveURI(uri, null, null, null, null, destFile); + } + catch (e) { + Zotero.Sync.Storage.EventManager.error(e); + } + }); + }, - var ts = req.responseText; - var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)"; - Zotero.DB.query(sql, { int: ts }); + uploadFile: function (request) { + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + if (Zotero.Attachments.getNumFiles(item) > 1) { + Zotero.Sync.Storage.createUploadFile(request, function (data) { processUploadFile(data); }); + } + else { + processUploadFile({ request: request }); + } + }, - self._cachedCredentials = false; - if (callback) { - callback(); - } - }); -} - - -/** - * Remove all synced files from the server - */ -Zotero.Sync.Storage.Session.ZFS.prototype.purgeDeletedStorageFiles = function (callback) { - // If we don't have a user id we've never synced and don't need to bother - if (!Zotero.userID) { - return; - } - - var sql = "SELECT value FROM settings WHERE setting=? AND key=?"; - var values = Zotero.DB.columnQuery(sql, ['storage', 'zfsPurge']); - if (!values) { - return; - } - - Zotero.debug("Unlinking synced files on ZFS"); - - var uri = this.userURI; - uri.spec += "removestoragefiles?"; - // Unused - for each(var value in values) { - switch (value) { - case 'user': - uri.spec += "user=1&"; - break; + getLastSyncTime: function (callback) { + var uri = this.userURI; + var successFileURI = uri.clone(); + successFileURI.spec += "laststoragesync?auth=1"; - case 'group': - uri.spec += "group=1&"; - break; + // Cache the credentials at the root + var self = this; + this.cacheCredentials(function () { + Zotero.HTTP.doGet(successFileURI, function (req) { + if (req.responseText) { + Zotero.debug(req.responseText); + } + Zotero.debug(req.status); + + if (req.status == 401 || req.status == 403) { + Zotero.debug("Clearing ZFS authentication credentials", 2); + _cachedCredentials = false; + } + + if (req.status != 200 && req.status != 404) { + Zotero.Sync.Storage.EventManager.error( + "Unexpected status code " + req.status + " getting " + + "last file sync time" + ); + } + + if (req.status == 200) { + var ts = req.responseText; + var date = new Date(ts * 1000); + Zotero.debug("Last successful storage sync was " + date); + _lastSyncTime = ts; + } + else { + var ts = null; + _lastSyncTime = null; + } + callback(ts); + }); + }); + }, + + + setLastSyncTime: function (callback, useLastSyncTime) { + if (useLastSyncTime) { + if (!_lastSyncTime) { + if (callback) { + callback(); + } + return; + } + + var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)"; + Zotero.DB.query(sql, { int: _lastSyncTime }); + + Zotero.debug("Clearing ZFS authentication credentials", 2); + _lastSyncTime = null; + _cachedCredentials = false; + + if (callback) { + callback(); + } + + return; + } + _lastSyncTime = null; - default: - throw ("Invalid zfsPurge value '" + value + "' in ZFS purgeDeletedStorageFiles()"); - } - } - uri.spec = uri.spec.substr(0, uri.spec.length - 1); - - var self = this; - - Zotero.HTTP.doPost(uri, "", function (xmlhttp) { - if (xmlhttp.status != 204) { - if (callback) { - callback(false); + var uri = this.userURI; + var successFileURI = uri.clone(); + successFileURI.spec += "laststoragesync?auth=1"; + + Zotero.HTTP.doPost(successFileURI, "", function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + if (req.status != 200) { + var msg = "Unexpected status code " + req.status + " setting last file sync time"; + Zotero.debug(msg, 1); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultError); + } + + var ts = req.responseText; + + var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)"; + Zotero.DB.query(sql, { int: ts }); + + Zotero.debug("Clearing ZFS authentication credentials", 2); + _cachedCredentials = false; + + if (callback) { + callback(); + } + }); + }, + + + cacheCredentials: function (callback) { + if (_cachedCredentials) { + Zotero.debug("Credentials are already cached"); + setTimeout(function () { + callback(); + }, 0); + return false; } - self.onError("Unexpected status code " + xmlhttp.status + " purging ZFS files"); - } + + var uri = this.rootURI; + // TODO: move to root uri + uri.spec += "?auth=1"; + Zotero.HTTP.doGet(uri, function (req) { + if (req.status == 401) { + // TODO: localize + var msg = "File sync login failed\n\nCheck your username and password in the Sync pane of the Zotero preferences."; + Zotero.Sync.Storage.EventManager.error(msg); + } + else if (req.status != 200) { + var msg = "Unexpected status code " + req.status + " caching " + + "authentication credentials in Zotero.Sync.Storage.Module.ZFS.cacheCredentials()"; + Zotero.debug(msg, 1); + Components.utils.reportError(msg); + Zotero.Sync.Storage.EventManager.error(Zotero.Sync.Storage.defaultErrorRestart); + } + Zotero.debug("Credentials are cached"); + _cachedCredentials = true; + callback(); + }); + return true; + }, - var sql = "DELETE FROM settings WHERE setting=? AND key=?"; - Zotero.DB.query(sql, ['storage', 'zfsPurge']); - if (callback) { - callback(true); + /** + * Remove all synced files from the server + */ + purgeDeletedStorageFiles: function (callback) { + // If we don't have a user id we've never synced and don't need to bother + if (!Zotero.userID) { + Zotero.Sync.Storage.EventManager.skip(); + return; + } + + var sql = "SELECT value FROM settings WHERE setting=? AND key=?"; + var values = Zotero.DB.columnQuery(sql, ['storage', 'zfsPurge']); + if (!values) { + Zotero.Sync.Storage.EventManager.skip(); + return; + } + + Zotero.debug("Unlinking synced files on ZFS"); + + var uri = this.userURI; + uri.spec += "removestoragefiles?"; + // Unused + for each(var value in values) { + switch (value) { + case 'user': + uri.spec += "user=1&"; + break; + + case 'group': + uri.spec += "group=1&"; + break; + + default: + Zotero.Sync.Storage.EventManager.error( + "Invalid zfsPurge value '" + value + "' in ZFS purgeDeletedStorageFiles()" + ); + } + } + uri.spec = uri.spec.substr(0, uri.spec.length - 1); + + Zotero.HTTP.doPost(uri, "", function (xmlhttp) { + if (xmlhttp.status != 204) { + if (callback) { + callback(false); + } + Zotero.Sync.Storage.EventManager.error( + "Unexpected status code " + xmlhttp.status + " purging ZFS files" + ); + } + + var sql = "DELETE FROM settings WHERE setting=? AND key=?"; + Zotero.DB.query(sql, ['storage', 'zfsPurge']); + + if (callback) { + callback(true); + } + + Zotero.Sync.Storage.EventManager.success(); + }); } - }); -} - - -// -// Private methods -// - -/** - * Get the storage URI for an item - * - * @inner - * @param {Zotero.Item} - * @return {nsIURI} URI of file on storage server - */ -Zotero.Sync.Storage.Session.ZFS.prototype._getItemURI = function (item) { - var uri = this.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 - */ -Zotero.Sync.Storage.Session.ZFS.prototype._getItemInfoURI = function (item) { - var uri = this.rootURI; - uri.spec += Zotero.URI.getItemPath(item) + '/file?auth=1&iskey=1&version=1&info=1'; - return uri; -} - - -Zotero.Sync.Storage.Session.ZFS.prototype._getUploadFile = function (item) { - if (Zotero.Attachments.getNumFiles(item) > 1) { - var file = Zotero.getTempDirectory(); - var filename = item.key + '.zip'; - file.append(filename); - } - else { - var file = item.getFile(); } - return file; -} +}()); diff --git a/chrome/content/zotero/xpcom/sync.js b/chrome/content/zotero/xpcom/sync.js @@ -425,7 +425,7 @@ Zotero.Sync.EventListener = new function () { var sql = "REPLACE INTO syncDeleteLog VALUES (?, ?, ?, ?)"; var syncStatement = Zotero.DB.getStatement(sql); - if (isItem && Zotero.Sync.Storage.isActive('webdav')) { + if (isItem && Zotero.Sync.Storage.isActive('WebDAV')) { var storageEnabled = true; var sql = "INSERT INTO storageDeleteLog VALUES (?, ?, ?)"; var storageStatement = Zotero.DB.getStatement(sql); @@ -562,74 +562,59 @@ Zotero.Sync.Runner = new function () { Zotero.Sync.Runner.setSyncStatus(Zotero.getString('sync.status.syncingFiles')); - Zotero.Sync.Storage.sync( - 'webdav', - - { - // WebDAV success + var zfsSync = function (skipSyncNeeded) { + Zotero.Sync.Storage.sync('ZFS', { + // ZFS success onSuccess: function () { - syncNeeded = true; - - Zotero.Sync.Storage.sync( - 'zfs', - - { - // ZFS success - onSuccess: function () { - Zotero.Sync.Server.sync(finalCallbacks); - }, - - // ZFS skip - onSkip: function () { - if (syncNeeded) { - Zotero.Sync.Server.sync(finalCallbacks); - } - }, - - // ZFS cancel - onStop: Zotero.Sync.Runner.stop, - - // ZFS failure - onError: Zotero.Sync.Runner.error, - - onWarning: Zotero.Sync.Runner.warning - } - ) + setTimeout(function () { + Zotero.Sync.Server.sync(finalCallbacks); + }, 0); }, - // WebDAV skip + // ZFS skip onSkip: function () { - Zotero.Sync.Storage.sync( - 'zfs', - - { - // ZFS success - onSuccess: function () { - Zotero.Sync.Server.sync(finalCallbacks); - }, - - // ZFS skip - onSkip: Zotero.Sync.Runner.stop, - - // ZFS cancel - onStop: Zotero.Sync.Runner.stop, - - // ZFS failure - onError: Zotero.Sync.Runner.error, - - onWarning: Zotero.Sync.Runner.warning + setTimeout(function () { + if (skipSyncNeeded) { + Zotero.Sync.Server.sync(finalCallbacks); } - ) + else { + Zotero.Sync.Runner.stop(); + } + }, 0); }, - // WebDAV cancel - onStop: Zotero.Sync.Runner.stop, + // ZFS cancel + onStop: function () { + setTimeout(function () { + Zotero.Sync.Runner.stop(); + }, 0); + }, - // WebDAV failure - onError: Zotero.Sync.Runner.error - } - ) - } + // ZFS failure + onError: Zotero.Sync.Runner.error, + + onWarning: Zotero.Sync.Runner.warning + }) + }; + + Zotero.Sync.Storage.sync('WebDAV', { + // WebDAV success + onSuccess: function () { + zfsSync(true); + }, + + // WebDAV skip + onSkip: function () { + zfsSync(); + }, + + // WebDAV cancel + onStop: Zotero.Sync.Runner.stop, + + // WebDAV failure + onError: Zotero.Sync.Runner.error + }); + }; Zotero.Sync.Server.sync({ // Sync 1 success diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js @@ -1859,12 +1859,12 @@ const ZOTERO_CONFIG = { Zotero.Relations.purge(); if (!skipStoragePurge && Math.random() < 1/10) { - Zotero.Sync.Storage.purgeDeletedStorageFiles('zfs'); - Zotero.Sync.Storage.purgeDeletedStorageFiles('webdav'); + Zotero.Sync.Storage.purgeDeletedStorageFiles('ZFS'); + Zotero.Sync.Storage.purgeDeletedStorageFiles('WebDAV'); } if (!skipStoragePurge) { - Zotero.Sync.Storage.purgeOrphanedStorageFiles('webdav'); + Zotero.Sync.Storage.purgeOrphanedStorageFiles('WebDAV'); } } @@ -1911,6 +1911,32 @@ Zotero.Prefs = new function(){ // Register observer to handle pref changes this.register(); + + // Process pref version updates + var fromVersion = this.get('prefVersion'); + if (!fromVersion) { + fromVersion = 0; + } + var toVersion = 1; + if (fromVersion < toVersion) { + for (var i = fromVersion + 1; i <= toVersion; i++) { + switch (i) { + case 1: + // If a sync username is entered and ZFS is enabled, turn + // on-demand downloading off to maintain current behavior + if (this.get('sync.server.username')) { + if (this.get('sync.storage.enabled') + && this.get('sync.storage.protocol') == 'zotero') { + this.set('sync.storage.downloadMode.personal', 'on-sync'); + } + if (this.get('sync.storage.groups.enabled')) { + this.set('sync.storage.downloadMode.groups', 'on-sync'); + } + } + } + } + this.set('prefVersion', toVersion); + } } diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js @@ -2590,14 +2590,8 @@ var ZoteroPane = new function() createInstance(Components.interfaces.nsIURI); var snapID = item.getBestAttachment(); if (snapID) { - spec = Zotero.Items.get(snapID).getLocalFileURL(); - if (spec) { - uri.spec = spec; - if (uri.scheme && uri.scheme == 'file') { - ZoteroPane_Local.viewAttachment(snapID, event); - return; - } - } + ZoteroPane_Local.viewAttachment(snapID, event); + return; } var uri = item.getField('url'); @@ -3352,22 +3346,22 @@ var ZoteroPane = new function() } for each(var itemID in itemIDs) { - var attachment = Zotero.Items.get(itemID); - if (!attachment.isAttachment()) { + var item = Zotero.Items.get(itemID); + if (!item.isAttachment()) { throw ("Item " + itemID + " is not an attachment in ZoteroPane_Local.viewAttachment()"); } - if (attachment.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { - this.loadURI(attachment.getField('url'), event); + if (item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { + this.loadURI(item.getField('url'), event); continue; } - var file = attachment.getFile(); + var file = item.getFile(); if (file) { if(forceExternalViewer !== undefined) { var externalViewer = forceExternalViewer; } else { - var mimeType = attachment.attachmentMIMEType; + var mimeType = item.attachmentMIMEType; // If no MIME type specified, try to detect again (I guess in case // we've gotten smarter since the file was imported?) if (!mimeType) { @@ -3393,12 +3387,43 @@ var ZoteroPane = new function() } catch (e) { Zotero.debug("launch() not supported -- passing file to loadURI()"); - var fileURL = attachment.getLocalFileURL(); + var fileURL = item.getLocalFileURL(); this.loadURI(fileURL); } } } else { + if (item.isImportedAttachment() && Zotero.Sync.Storage.downloadAsNeeded(item.libraryID)) { + let downloadedItem = item; + var started = Zotero.Sync.Storage.downloadFile(item, { + onStart: function (request) { + if (!(request instanceof Zotero.Sync.Storage.Request)) { + throw new Error("Invalid request object"); + } + }, + + onProgress: function (progress, progressMax) { + + }, + + onStop: function () { + if (!downloadedItem.getFile()) { + ZoteroPane_Local.showAttachmentNotFoundDialog(itemID, noLocateOnMissing); + return; + } + + // check if unchanged? + // maybe not necessary, since we'll get an error if there's an error + + ZoteroPane_Local.viewAttachment(downloadedItem.id, event, false, forceExternalViewer); + }, + }); + + if (started) { + continue; + } + } + this.showAttachmentNotFoundDialog(itemID, noLocateOnMissing); } } diff --git a/chrome/content/zotero/zoteroPane.xul b/chrome/content/zotero/zoteroPane.xul @@ -38,7 +38,7 @@ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> <script src="include.js"/> - <script src="zoteroPane.js"/> + <script src="zoteroPane.js" type="application/javascript;version=1.8"/> <script src="fileInterface.js"/> <script src="reportInterface.js"/> <script src="timelineInterface.js"/> @@ -413,8 +413,17 @@ flex="1" zotero-persist="width ordinal hidden sortActive sortDirection"/> <splitter class="tree-splitter"/> <treecol - id="zotero-items-column-numChildren" - label="&zotero.items.numChildren_column;" + id="zotero-items-column-hasAttachment" + class="treecol-image" + label="&zotero.tabs.attachments.label;" + src="chrome://zotero/skin/attach-small.png" + zotero-persist="width ordinal hidden sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol + id="zotero-items-column-hasNote" + class="treecol-image" + label="&zotero.tabs.notes.label;" + src="chrome://zotero/skin/treeitem-note-small.png" zotero-persist="width ordinal hidden sortActive sortDirection"/> </treecols> <treechildren/> diff --git a/chrome/locale/en-US/zotero/preferences.dtd b/chrome/locale/en-US/zotero/preferences.dtd @@ -3,6 +3,7 @@ <!ENTITY zotero.preferences.default "Default:"> <!ENTITY zotero.preferences.items "items"> <!ENTITY zotero.preferences.period "."> +<!ENTITY zotero.preferences.settings "Settings"> <!ENTITY zotero.preferences.prefpane.general "General"> @@ -58,7 +59,9 @@ <!ENTITY zotero.preferences.sync.fileSyncing.url "URL:"> <!ENTITY zotero.preferences.sync.fileSyncing.myLibrary "Sync attachment files in My Library using"> <!ENTITY zotero.preferences.sync.fileSyncing.groups "Sync attachment files in group libraries using Zotero storage"> -<!ENTITY zotero.preferences.sync.fileSyncing.about "About File Syncing"> +<!ENTITY zotero.preferences.sync.fileSyncing.download "Download files"> +<!ENTITY zotero.preferences.sync.fileSyncing.download.atSyncTime "at sync time"> +<!ENTITY zotero.preferences.sync.fileSyncing.download.onDemand "as needed"> <!ENTITY zotero.preferences.sync.fileSyncing.tos1 "By using Zotero storage, you agree to become bound by its"> <!ENTITY zotero.preferences.sync.fileSyncing.tos2 "terms and conditions"> <!ENTITY zotero.preferences.sync.reset.fullSync "Full Sync with Zotero Server"> @@ -69,6 +72,7 @@ <!ENTITY zotero.preferences.sync.reset.restoreToServer.desc "Erase all server data and overwrite with local Zotero data."> <!ENTITY zotero.preferences.sync.reset.resetFileSyncHistory "Reset File Sync History"> <!ENTITY zotero.preferences.sync.reset.resetFileSyncHistory.desc "Force checking of the storage server for all local attachment files."> +<!ENTITY zotero.preferences.sync.reset "Reset"> <!ENTITY zotero.preferences.sync.reset.button "Reset..."> diff --git a/chrome/skin/default/zotero/attach-small.png b/chrome/skin/default/zotero/attach-small.png Binary files differ. diff --git a/chrome/skin/default/zotero/bullet_blue.png b/chrome/skin/default/zotero/bullet_blue.png Binary files differ. diff --git a/chrome/skin/default/zotero/bullet_blue_empty.png b/chrome/skin/default/zotero/bullet_blue_empty.png Binary files differ. diff --git a/chrome/skin/default/zotero/bullet_yellow.png b/chrome/skin/default/zotero/bullet_yellow.png Binary files differ. diff --git a/chrome/skin/default/zotero/overlay.css b/chrome/skin/default/zotero/overlay.css @@ -66,6 +66,11 @@ background-image: none; } +#zotero-items-column-hasAttachment, #zotero-items-column-hasNote +{ + min-width: 21px; +} + #zotero-items-tree treechildren::-moz-tree-image { margin-right: 5px; diff --git a/chrome/skin/default/zotero/preferences.css b/chrome/skin/default/zotero/preferences.css @@ -22,7 +22,7 @@ radio[pane] } /* Links within messages */ -description label[class=text-link], label label[class=text-link] +description label[class=zotero-text-link], label label[class=zotero-text-link] { margin: 0; } @@ -114,10 +114,9 @@ grid row hbox:first-child margin-right: 10px; } -#storage-settings +.storage-settings-download-options { - margin-left: 10px; - margin-right: 5px; + margin-left: 40px; } #storage-verify, #storage-abort, #storage-clean @@ -137,7 +136,7 @@ grid row hbox:first-child margin-right: .25em; } -#storage-terms > label[class=text-link] +#storage-terms > label[class=zotero-text-link] { margin-right: 0; } diff --git a/chrome/skin/default/zotero/treeitem-note-small.png b/chrome/skin/default/zotero/treeitem-note-small.png Binary files differ. diff --git a/components/zotero-service.js b/components/zotero-service.js @@ -96,7 +96,12 @@ const xpcomFilesLocal = [ 'style', 'sync', 'storage', - 'storage/session', + 'storage/streamListener', + 'storage/eventManager', + 'storage/queueManager', + 'storage/queue', + 'storage/request', + 'storage/module', 'storage/zfs', 'storage/webdav', 'timeline', diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js @@ -140,6 +140,8 @@ pref("extensions.zotero.sync.storage.maxDownloads", 4); pref("extensions.zotero.sync.storage.maxUploads", 4); pref("extensions.zotero.sync.storage.deleteDelayDays", 30); pref("extensions.zotero.sync.storage.groups.enabled", true); +pref("extensions.zotero.sync.storage.downloadMode.personal", "on-demand"); +pref("extensions.zotero.sync.storage.downloadMode.groups", "on-demand"); // Proxy pref("extensions.zotero.proxies.autoRecognize", true);