www

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

commit a8bb8dae4013193d449495a6cf04de496ae476a9
parent 227b4cbfcdd24da783c402c2313d658d92797416
Author: Dan Stillman <dstillman@zotero.org>
Date:   Sun, 31 Aug 2008 23:36:01 +0000

Adds WebDAV file sync

- Still experimental and incomplete, with no lock support and not much error handling

Also:

- New expiry date for sync functions
- Attachment character set was being dropped during syncing
- Possibly improves sizing issues with preferences window
- Fixes problems with attachment filenames with extended characters
- Fixes some problem with tags that I don't remember
- Makes XMLHTTPRequest calls are now background requests (no auth windows or other prompts)
- Z.U.HTTP.doOptions() now takes an nsIURI instead of a URL spec
- New methods:
  - Zotero.Utilities.rand(min, max)
  - Zotero.Utilities.probability(x)
  - Zotero.Utilities.Base64.encode(str) and decode(str)
  - Zotero.getTempDirectory()
  - Zotero.Date.dateToISO(date) - convert JS Date object to ISO 8601 UTC date/time
  - Zotero.Date.isoToDate(isoDate) - convert an ISO 8601 UTC date/time to a JS Date object


Diffstat:
Mchrome/content/zotero/overlay.xul | 43++++++++++++++++++++++++++++++++++++++++---
Mchrome/content/zotero/preferences/preferences.js | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mchrome/content/zotero/preferences/preferences.xul | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mchrome/content/zotero/xpcom/attachments.js | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
Mchrome/content/zotero/xpcom/data/item.js | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mchrome/content/zotero/xpcom/data/items.js | 5+++++
Mchrome/content/zotero/xpcom/schema.js | 8++++++++
Achrome/content/zotero/xpcom/storage.js | 2168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mchrome/content/zotero/xpcom/sync.js | 384+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mchrome/content/zotero/xpcom/utilities.js | 432+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mchrome/content/zotero/xpcom/zotero.js | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mchrome/locale/en-US/zotero/zotero.dtd | 7+++++--
Mchrome/locale/en-US/zotero/zotero.properties | 6++++--
Achrome/skin/default/zotero/drive_network.png | 0
Mchrome/skin/default/zotero/overlay.css | 21+++++++++++++++++++--
Mchrome/skin/default/zotero/preferences.css | 52+++++++++++++++++++++++++++++++++++++++++++++-------
Mcomponents/zotero-service.js | 1+
Mdefaults/preferences/zotero.js | 11+++++++++--
Muserdata.sql | 11++++++++++-
19 files changed, 3517 insertions(+), 249 deletions(-)

diff --git a/chrome/content/zotero/overlay.xul b/chrome/content/zotero/overlay.xul @@ -133,6 +133,10 @@ <menuitem label="Clear Server Data" oncommand="Zotero.Sync.Server.clear()"/> <menuitem label="Reset Server Lock" oncommand="Zotero.Sync.Server.resetServer()"/> <menuitem label="Reset Client" oncommand="Zotero.Sync.Server.resetClient()"/> + <menuseparator id="zotero-tb-actions-storage-separator"/> + <menuitem label="Reset Storage History" oncommand="Zotero.Sync.Storage.resetAllSyncStates()"/> + <menuitem label="Purge Deleted Storage Files" oncommand="Zotero.Sync.Storage.purgeDeletedStorageFiles(function(results) { Zotero.debug(results); })"/> + <menuitem label="Purge Orphaned Storage Files" oncommand="Zotero.Sync.Storage.purgeOrphanedStorageFiles(function(results) { Zotero.debug(results); })"/> <menuseparator id="zotero-tb-actions-separator"/> <menuitem id="zotero-tb-actions-prefs" label="&zotero.toolbar.preferences.label;" oncommand="window.openDialog('chrome://zotero/content/preferences/preferences.xul', 'zotero-prefs', 'chrome,titlebar,toolbar,' + Zotero.Prefs.get('browser.preferences.instantApply', true) ? 'dialog=no' : 'modal')"/> @@ -305,16 +309,49 @@ <splitter id="zotero-view-splitter" resizebefore="closest" resizeafter="closest"/> <vbox id="zotero-item-pane" persist="width"> - <toolbar align="right"> + <toolbar align="center" pack="end"> + <progressmeter id="zotero-tb-syncProgress" mode="determined" + value="0" tooltip="zotero-tb-syncProgress-tooltip" + hidden="true"> + </progressmeter> + <tooltip id="zotero-tb-syncProgress-tooltip" noautohide="true"> + <grid> + <columns> + <column/> + <column/> + </columns> + <rows> + <row> + <label value="&zotero.sync.storage.progress;"/> + <label id="zotero-tb-syncProgress-tooltip-progress"/> + </row> + <row> + <label value="&zotero.sync.storage.downloads;"/> + <label + id="zotero-tb-syncProgress-tooltip-downloads"/> + </row> + <row> + <label value="&zotero.sync.storage.uploads;"/> + <label + id="zotero-tb-syncProgress-tooltip-uploads"/> + </row> + </rows> + </grid> + </tooltip> <toolbarbutton id="zotero-tb-sync" tooltip="_child" - oncommand="Zotero.Sync.Server.sync()"> + oncommand="Zotero.Sync.Runner.sync()"> <tooltip onpopupshowing="if (Zotero.Sync.Server.lastSyncError) { this.firstChild.nextSibling.value = 'Last error: ' + Zotero.Sync.Server.lastSyncError; return; } this.firstChild.nextSibling.value = 'Last sync: ' + (Zotero.Sync.Server.lastLocalSyncTime ? new Date(Zotero.Sync.Server.lastLocalSyncTime * 1000).toLocaleString() : 'Not yet synced')" - noautohide="true"><!-- localize --> + noautohide="true"><!-- TODO: localize --> <label value="Sync with Zotero Server"/> <label id="zotero-last-sync-time"/> </tooltip> </toolbarbutton> + <!-- + <toolbarbutton id="zotero-tb-storage-sync" + tooltiptext="Sync with Storage Server" + oncommand="Zotero.Sync.Storage.sync()"/> + --> <toolbarseparator/> <toolbarbutton id="zotero-tb-fullscreen" tooltiptext="&zotero.toolbar.fullscreen.tooltip;" oncommand="ZoteroPane.fullScreen();"/> <toolbarbutton class="tabs-closebutton" oncommand="ZoteroPane.toggleDisplay()"/> diff --git a/chrome/content/zotero/preferences/preferences.js b/chrome/content/zotero/preferences/preferences.js @@ -137,6 +137,170 @@ function populateOpenURLResolvers() { } +// +// Sync +// +function unverifyStorageServer() { + Zotero.debug("Clearing storage settings"); + Zotero.Sync.Storage.clearSettingsCache(); + Zotero.Prefs.set('sync.storage.verified', false); +} + +function verifyStorageServer() { + Zotero.debug("Verifying storage"); + + var verifyButton = document.getElementById("storage-verify"); + var abortButton = document.getElementById("storage-abort"); + var progressMeter = document.getElementById("storage-progress"); + + var callback = function (uri, status, authRequired) { + verifyButton.hidden = false; + abortButton.hidden = true; + progressMeter.hidden = true; + + var promptService = + Components.classes["@mozilla.org/network/default-prompt;1"]. + createInstance(Components.interfaces.nsIPrompt); + if (uri) { + var spec = uri.scheme + '://' + uri.hostPort + uri.path; + } + + switch (status) { + case Zotero.Sync.Storage.SUCCESS: + promptService.alert( + "Server configuration verified", + "File storage is successfully set up." + ); + Zotero.Prefs.set("sync.storage.verified", true); + return true; + + case Zotero.Sync.Storage.ERROR_NO_URL: + var errorMessage = "Please enter a URL."; + setTimeout(function () { + document.getElementById("storage-url").focus(); + }, 1); + break; + + case Zotero.Sync.Storage.ERROR_NO_USERNAME: + var errorMessage = "Please enter a username."; + setTimeout(function () { + document.getElementById("storage-username").focus(); + }, 1); + break; + + case Zotero.Sync.Storage.ERROR_NO_PASSWORD: + var errorMessage = "Please enter a password."; + setTimeout(function () { + document.getElementById("storage-password").focus(); + }, 1); + break; + + case Zotero.Sync.Storage.ERROR_UNREACHABLE: + var errorMessage = "The server " + uri.host + " could not be reached."; + break; + + case Zotero.Sync.Storage.ERROR_NOT_DAV: + var errorMessage = spec + " is not a valid WebDAV URL."; + break; + + case Zotero.Sync.Storage.ERROR_AUTH_FAILED: + var errorTitle = "Permission denied"; + var errorMessage = "The server did not accept the username and " + + "password you entered." + " " + + "Please check your server settings " + + "or contact your server administrator."; + break; + + case Zotero.Sync.Storage.ERROR_FORBIDDEN: + var errorTitle = "Permission denied"; + var errorMessage = "You don't have permission to access " + + uri.path + " on this server." + " " + + "Please check your server settings " + + "or contact your server administrator."; + break; + + case Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND: + var errorTitle = "Directory not found"; + var parentSpec = spec.replace(/\/zotero\/$/, ""); + var errorMessage = parentSpec + " does not exist."; + break; + + case Zotero.Sync.Storage.ERROR_ZOTERO_DIR_NOT_FOUND: + var create = promptService.confirmEx( + // TODO: localize + "Directory not found", + spec + " does not exist.\n\nDo you want to create it now?", + promptService.BUTTON_POS_0 + * promptService.BUTTON_TITLE_IS_STRING + + promptService.BUTTON_POS_1 + * promptService.BUTTON_TITLE_CANCEL, + "Create", + null, null, null, {} + ); + + if (create != 0) { + return; + } + + Zotero.Sync.Storage.createServerDirectory(function (uri, status) { + switch (status) { + case Zotero.Sync.Storage.SUCCESS: + promptService.alert( + "Server configuration verified", + "File storage is successfully set up." + ); + Zotero.Prefs.set("sync.storage.verified", true); + return true; + + case Zotero.Sync.Storage.ERROR_FORBIDDEN: + var errorTitle = "Permission denied"; + var errorMessage = "You do not have " + + "permission to create a Zotero directory " + + "at the following address:" + "\n\n" + spec; + errorMessage += "\n\n" + + "Please check your server settings or " + + "contact your server administrator."; + break; + } + + // TEMP + if (!errorMessage) { + var errorMessage = status; + } + promptService.alert(errorTitle, errorMessage); + }); + + return false; + } + + if (!errorTitle) { + var errorTitle = Zotero.getString("general.error"); + } + // TEMP + if (!errorMessage) { + var errorMessage = status; + } + promptService.alert(errorTitle, errorMessage); + return false; + } + + verifyButton.hidden = true; + abortButton.hidden = false; + progressMeter.hidden = false; + var requestHolder = Zotero.Sync.Storage.checkServer(callback); + abortButton.onclick = function () { + if (requestHolder.request) { + requestHolder.request.onreadystatechange = undefined; + requestHolder.request.abort(); + verifyButton.hidden = false; + abortButton.hidden = true; + progressMeter.hidden = true; + } + } +} + + + /* * Builds the main Quick Copy drop-down from the current global pref */ diff --git a/chrome/content/zotero/preferences/preferences.xul b/chrome/content/zotero/preferences/preferences.xul @@ -156,31 +156,116 @@ To add a new preference: <!-- localize --> <prefpane id="zotero-prefpane-sync" label="Sync" - onpaneload="document.getElementById('sync-password').value = Zotero.Sync.Server.password;" + onpaneload="document.getElementById('sync-password').value = Zotero.Sync.Server.password; document.getElementById('storage-password').value = Zotero.Sync.Storage.password;" image="chrome://zotero/skin/prefs-sync.png"> <preferences> - <preference id="pref-sync-username" name="extensions.zotero.sync.server.username" type="string"/> + <preference id="pref-sync-autosync" name="extensions.zotero.sync.autoSync" type="bool"/> + <preference id="pref-sync-username" name="extensions.zotero.sync.server.username" type="string" instantApply="true"/> + <preference id="pref-storage-enabled" name="extensions.zotero.sync.storage.enabled" type="bool"/> + <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"/> </preferences> - <grid> - <columns> - <columns/> - <columns/> - </columns> + <groupbox> + <caption label="Zotero Sync Server"/> - <rows> - <row> - <label value="Username:"/> - <textbox preference="pref-sync-username" - onchange="Zotero.Prefs.set('sync.server.username', this.value); var pass = document.getElementById('sync-password'); if (pass.value) { Zotero.Sync.Server.password = pass.value; }"/> - </row> - <row> - <label value="Password:"/> - <textbox id="sync-password" type="password" - onchange="Zotero.Sync.Server.password = this.value"/> - </row> - </rows> - </grid> + <grid> + <columns> + <column/> + <column/> + </columns> + + <rows> + <row> + <label value="Username:"/> + <textbox preference="pref-sync-username" + onchange="Zotero.Prefs.set('sync.server.username', this.value); var pass = document.getElementById('sync-password'); if (pass.value) { Zotero.Sync.Server.password = pass.value; }"/> + </row> + <row> + <label value="Password:"/> + <textbox id="sync-password" type="password" + onchange="Zotero.Sync.Server.password = this.value"/> + </row> + <!-- + <row> + <box/> + <hbox> + <button label="Verify login" + oncommand="alert('Unimplemented')"/> + </hbox> + </row> + --> + </rows> + </grid> + + <separator class="thin"/> + + <hbox> + <checkbox label="Sync automatically" preference="pref-sync-autosync"/> + </hbox> + </groupbox> + + + <groupbox> + <caption label="Storage Server"/> + + <hbox> + <checkbox label="Enable file syncing" preference="pref-storage-enabled"/> + </hbox> + + <separator class="thin"/> + + <grid id="storage-settings"> + <columns> + <column/> + <column flex="1"/> + </columns> + + <rows> + <row> + <label value="URL:"/> + <hbox> + <label value="https://"/> + <textbox id="storage-url" flex="1" + preference="pref-storage-url" + onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { this.blur(); verifyStorageServer(); }" + onsynctopreference="unverifyStorageServer();" + onchange="this.value = this.value.replace(/(^https?:\/\/|\/zotero\/?$|\/$)/g, '')"/> + <label value="/zotero/"/> + </hbox> + </row> + <row> + <label value="Username:"/> + <hbox> + <textbox id="storage-username" + preference="pref-storage-username" + onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { this.blur(); setTimeout('verifyStorageServer();', 1); }" + onsynctopreference="unverifyStorageServer();" + onchange="var pass = document.getElementById('storage-password'); if (pass.value) { Zotero.Sync.Storage.password = pass.value; }"/> + </hbox> + </row> + <row> + <label value="Password:"/> + <hbox> + <textbox id="storage-password" flex="0" type="password" + onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { this.blur(); setTimeout('verifyStorageServer();', 1); }" + oninput="unverifyStorageServer()" + onchange="Zotero.Sync.Storage.password = this.value"/> + </hbox> + </row> + <row> + <box/> + <hbox> + <button id="storage-verify" label="Verify Server" + oncommand="verifyStorageServer()"/> + <button id="storage-abort" label="Stop" hidden="true"/> + <progressmeter id="storage-progress" hidden="true" + mode="undetermined"/> + </hbox> + </row> + </rows> + </grid> + </groupbox> </prefpane> @@ -282,8 +367,12 @@ To add a new preference: <groupbox> <caption label="&zotero.preferences.citationOptions.caption;"/> + <checkbox label="&zotero.preferences.export.citePaperJournalArticleURL;" preference="pref-export-citePaperJournalArticleURL"/> - <label id="export-citePaperJournalArticleURL">&zotero.preferences.export.citePaperJournalArticleURL.description;</label> + <!-- This doesn't wrap without an explicit wrap, for some reason --> + <label id="export-citePaperJournalArticleURL" width="45em"> + &zotero.preferences.export.citePaperJournalArticleURL.description; + </label> </groupbox> <groupbox> @@ -511,9 +600,8 @@ To add a new preference: </groupbox> </prefpane> - - <!-- These mess up the prefwindow if they come before the prefpanes - https://bugzilla.mozilla.org/show_bug.cgi?id=296418 --> + <!-- These mess up the prefwindow (more) if they come before the prefpanes + https://bugzilla.mozilla.org/show_bug.cgi?id=296418 --> <script src="chrome://zotero/content/include.js"/> <script src="preferences.js"/> </prefwindow> \ No newline at end of file diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js @@ -943,13 +943,62 @@ Zotero.Attachments = new function(){ function getPath(file, linkMode) { if (linkMode == self.LINK_MODE_IMPORTED_URL || linkMode == self.LINK_MODE_IMPORTED_FILE) { - return 'storage:' + file.leafName; + file.QueryInterface(Components.interfaces.nsILocalFile); + var fileName = file.getRelativeDescriptor(file.parent); + return 'storage:' + fileName; } return file.persistentDescriptor; } + /** + * @param {Zotero.Item} item + * @param {Boolean} [skipHidden=FALSE] Don't count hidden files + * @return {Integer} Total file size in bytes + */ + this.getTotalFileSize = function (item, skipHidden) { + var funcName = "Zotero.Attachments.getTotalFileSize()"; + + if (!item.isAttachment()) { + throw ("Item is not an attachment in " + funcName); + } + + var linkMode = item.attachmentLinkMode; + switch (linkMode) { + case Zotero.Attachments.LINK_MODE_IMPORTED_URL: + case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: + case Zotero.Attachments.LINK_MODE_LINKED_FILE: + break; + + default: + throw ("Invalid attachment link mode in " + funcName); + } + + var file = item.getFile(); + if (!file) { + throw ("File not found in " + funcName); + } + + if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { + return item.fileSize; + } + + var parentDir = file.parent; + var files = parentDir.directoryEntries; + var size = 0; + while (files.hasMoreElements()) { + file = files.getNext(); + file.QueryInterface(Components.interfaces.nsIFile); + if (skipHidden && file.leafName.indexOf('.') == 0) { + continue; + } + size += file.fileSize; + } + return size; + } + + function _getFileNameFromURL(url, mimeType){ var nsIURL = Components.classes["@mozilla.org/network/standard-url;1"] .createInstance(Components.interfaces.nsIURL); diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js @@ -87,8 +87,9 @@ Zotero.Item.prototype._init = function () { this._attachmentLinkMode = null; this._attachmentMIMEType = null; - this._attachmentCharset = null; + this._attachmentCharset; this._attachmentPath = null; + this._attachmentSyncState; this._relatedItems = false; } @@ -1254,22 +1255,13 @@ Zotero.Item.prototype.save = function() { // Attachment if (this.isAttachment()) { var sql = "INSERT INTO itemAttachments (itemID, sourceItemID, linkMode, " - + "mimeType, charsetID, path) VALUES (?,?,?,?,?,?)"; + + "mimeType, charsetID, path, syncState) VALUES (?,?,?,?,?,?,?)"; var parent = this.getSource(); var linkMode = this.attachmentLinkMode; - switch (linkMode) { - case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: - case Zotero.Attachments.LINK_MODE_IMPORTED_URL: - case Zotero.Attachments.LINK_MODE_LINKED_FILE: - case Zotero.Attachments.LINK_MODE_LINKED_URL: - break; - - default: - throw ("Invalid attachment link mode " + linkMode + " in Zotero.Item.save()"); - } var mimeType = this.attachmentMIMEType; var charsetID = this.attachmentCharset; var path = this.attachmentPath; + var syncState = this.attachmentSyncState; var bindParams = [ itemID, @@ -1277,7 +1269,8 @@ Zotero.Item.prototype.save = function() { { int: linkMode }, mimeType ? { string: mimeType } : null, charsetID ? { int: charsetID } : null, - path ? { string: path } : null + path ? { string: path } : null, + syncState ? { int: syncState } : 0 ]; Zotero.DB.query(sql, bindParams); } @@ -1596,21 +1589,24 @@ Zotero.Item.prototype.save = function() { // Attachment if (this._changedAttachmentData) { - var sql = "REPLACE INTO itemAttachments (itemID, sourceItemID, linkMode, " - + "mimeType, charsetID, path) VALUES (?,?,?,?,?,?)"; + var sql = "UPDATE itemAttachments SET sourceItemID=?, " + + "linkMode=?, mimeType=?, charsetID=?, path=?, syncState=? " + + "WHERE itemID=?"; var parent = this.getSource(); var linkMode = this.attachmentLinkMode; var mimeType = this.attachmentMIMEType; var charsetID = this.attachmentCharset; var path = this.attachmentPath; + var syncState = this.attachmentSyncState; var bindParams = [ - this.id, parent ? parent : null, { int: linkMode }, mimeType ? { string: mimeType } : null, charsetID ? { int: charsetID } : null, - path ? { string: path } : null + path ? { string: path } : null, + syncState ? { int: syncState } : 0, + this.id ]; Zotero.DB.query(sql, bindParams); } @@ -2109,7 +2105,7 @@ Zotero.Item.prototype.numAttachments = function() { * Get an nsILocalFile for the attachment, or false if the associated file * doesn't exist * -* _row_ is optional itemAttachments row if available to skip query +* _row_ is optional itemAttachments row if available to skip queries * * Note: Always returns false for items with LINK_MODE_LINKED_URL, * since they have no files -- use getField('url') instead @@ -2120,12 +2116,10 @@ Zotero.Item.prototype.getFile = function(row, skipExistsCheck) { } if (!row) { - var sql = "SELECT linkMode, path FROM itemAttachments WHERE itemID=?" - var row = Zotero.DB.rowQuery(sql, this.id); - } - - if (!row) { - throw ('Attachment data not found for item ' + this.id + ' in getFile()'); + var row = { + linkMode: this.attachmentLinkMode, + path: this.attachmentPath + }; } // No associated files for linked URLs @@ -2144,7 +2138,7 @@ Zotero.Item.prototype.getFile = function(row, skipExistsCheck) { var path = row.path.substr(8); var file = Zotero.Attachments.getStorageDirectory(this.id); file.QueryInterface(Components.interfaces.nsILocalFile); - file.append(path); + file.setRelativeDescriptor(file, path); if (!file.exists()) { Zotero.debug("Attachment file '" + path + "' not found"); throw ('File not found'); @@ -2321,7 +2315,8 @@ Zotero.Item.prototype.__defineSetter__('attachmentLinkMode', function (val) { break; default: - throw ("Invalid attachment link mode '" + val + "' in Zotero.Item.attachmentLinkMode setter"); + throw ("Invalid attachment link mode '" + val + + "' in Zotero.Item.attachmentLinkMode setter"); } if (val === this._attachmentLinkMode) { @@ -2402,18 +2397,18 @@ Zotero.Item.prototype.__defineGetter__('attachmentCharset', function () { return undefined; } - if (this._attachmentCharset !== null) { + if (this._attachmentCharset != undefined) { return this._attachmentCharset; } if (!this.id) { - return ''; + return null; } var sql = "SELECT charsetID FROM itemAttachments WHERE itemID=?"; var charset = Zotero.DB.valueQuery(sql, this.id); if (!charset) { - charset = ''; + charset = null; } this._attachmentCharset = charset; return charset; @@ -2425,8 +2420,10 @@ Zotero.Item.prototype.__defineSetter__('attachmentCharset', function (val) { throw (".attachmentCharset can only be set for attachment items"); } + val = Zotero.CharacterSets.getID(val); + if (!val) { - val = ''; + val = null; } if (val == this._attachmentCharset) { @@ -2489,6 +2486,90 @@ Zotero.Item.prototype.__defineSetter__('attachmentPath', function (val) { }); +Zotero.Item.prototype.__defineGetter__('attachmentSyncState', function () { + if (!this.isAttachment()) { + return undefined; + } + + if (this._attachmentSyncState != undefined) { + return this._attachmentSyncState; + } + + if (!this.id) { + return undefined; + } + + var sql = "SELECT syncState FROM itemAttachments WHERE itemID=?"; + var syncState = Zotero.DB.valueQuery(sql, this.id); + this._attachmentSyncState = syncState; + return syncState; +}); + + +Zotero.Item.prototype.__defineSetter__('attachmentSyncState', function (val) { + if (!this.isAttachment()) { + throw ("attachmentSyncState can only be set for attachment items"); + } + + switch (this.attachmentLinkMode) { + case Zotero.Attachments.LINK_MODE_IMPORTED_URL: + case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: + break; + + default: + throw ("attachmentSyncState can only be set for snapshots and " + + "imported files"); + } + + switch (val) { + case Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD: + case Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD: + case Zotero.Sync.Storage.SYNC_STATE_IN_SYNC: + break; + + default: + throw ("Invalid sync state '" + val + + "' in Zotero.Item.attachmentSyncState setter"); + } + + if (val == this._attachmentSyncState) { + return; + } + + if (!this._changedAttachmentData) { + this._changedAttachmentData = {}; + } + this._changedAttachmentData.syncState = true; + this._attachmentSyncState = val; +}); + + +/** + * Modification time of an attachment file + * + * Note: This is the mod time of the file itself, not the last-known mod time + * of the file on the storage server as stored in the database + * + * @return {Number} File modification time as UNIX timestamp + */ +Zotero.Item.prototype.__defineGetter__('attachmentModificationTime', function () { + if (!this.isAttachment()) { + return undefined; + } + + if (!this.id) { + return undefined; + } + + var file = this.getFile(); + if (!file) { + return undefined; + } + + return file.lastModifiedTime / 1000; +}); + + /** * Returns an array of attachment itemIDs that have this item as a source, * or FALSE if none @@ -2579,16 +2660,26 @@ Zotero.Item.prototype.addTag = function(name, type) { Zotero.DB.beginTransaction(); - var existingTypes = Zotero.Tags.getTypes(name); - if (existingTypes) { - // If existing automatic and adding identical user, remove automatic - if (type == 0 && existingTypes.indexOf(1) != -1) { - this.removeTag(Zotero.Tags.getID(name, 1)); - } - else { - Zotero.debug('Identical tag already exists -- not adding tag'); - Zotero.DB.commitTransaction(); - return false; + var matchingTags = Zotero.Tags.getIDs(name); + if (matchingTags) { + var itemTags = this.getTags(); + for each(var id in matchingTags) { + if (itemTags.indexOf(id) != -1) { + var tag = Zotero.Tags.get(id); + // If existing automatic and adding identical user, + // remove automatic + if (type == 0 && tag.type == 1) { + this.removeTag(id); + break; + } + // If existing user and adding automatic, skip + else if (type == 1 && tag.type == 0) { + Zotero.debug("Identical user tag '" + name + + "' already exists -- skipping automatic tag"); + Zotero.DB.commitTransaction(); + return false; + } + } } } @@ -2601,9 +2692,9 @@ Zotero.Item.prototype.addTag = function(name, type) { } try { - this.addTagByID(tagID); + var added = this.addTagByID(tagID); Zotero.DB.commitTransaction(); - return tagID; + return added ? tagID : false; } catch (e) { Zotero.DB.rollbackTransaction(); @@ -2641,8 +2732,12 @@ Zotero.Item.prototype.addTagByID = function(tagID) { throw ('Cannot add invalid tag ' + tagID + ' in Zotero.Item.addTagByID()'); } - tag.addItem(this.id); + var added = tag.addItem(this.id); + if (!added) { + return false; + } tag.save(); + return true; } Zotero.Item.prototype.hasTag = function(tagID) { diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js @@ -401,6 +401,11 @@ Zotero.Items = new function() { var sql = "DELETE FROM itemDataValues WHERE valueID NOT IN " + "(SELECT valueID FROM itemData)"; Zotero.DB.query(sql); + + var ZU = new Zotero.Utilities; + if (Zotero.Sync.Storage.active && ZU.probability(10)) { + Zotero.Sync.Storage.purgeDeletedStorageFiles(); + } } diff --git a/chrome/content/zotero/xpcom/schema.js b/chrome/content/zotero/xpcom/schema.js @@ -1671,6 +1671,14 @@ Zotero.Schema = new function(){ Zotero.DB.query("CREATE TABLE proxyHosts (\n hostID INTEGER PRIMARY KEY,\n proxyID INTEGER,\n hostname TEXT,\n FOREIGN KEY (proxyID) REFERENCES proxies(proxyID)\n)"); Zotero.DB.query("CREATE INDEX proxyHosts_proxyID ON proxyHosts(proxyID)"); } + + if (i==40) { + Zotero.DB.query("ALTER TABLE itemAttachments ADD COLUMN syncState INT DEFAULT 0"); + Zotero.DB.query("ALTER TABLE itemAttachments ADD COLUMN storageModTime INT"); + Zotero.DB.query("CREATE INDEX itemAttachments_syncState ON itemAttachments(syncState)"); + Zotero.DB.query("CREATE TABLE storageDeleteLog (\n key TEXT PRIMARY KEY,\n timestamp INT NOT NULL\n)"); + Zotero.DB.query("CREATE INDEX storageDeleteLog_timestamp ON storageDeleteLog(timestamp)"); + } } _updateDBVersion('userdata', toVersion); diff --git a/chrome/content/zotero/xpcom/storage.js b/chrome/content/zotero/xpcom/storage.js @@ -0,0 +1,2168 @@ +Zotero.Sync.Storage = new function () { + // + // Constants + // + this.SYNC_STATE_TO_UPLOAD = 0; + this.SYNC_STATE_TO_DOWNLOAD = 1; + this.SYNC_STATE_IN_SYNC = 2; + + this.SUCCESS = 1; + this.ERROR_NO_URL = -1; + this.ERROR_NO_USERNAME = -2; + this.ERROR_NO_PASSWORD = -3; + this.ERROR_OFFLINE = -4; + this.ERROR_UNREACHABLE = -5; + this.ERROR_SERVER_ERROR = -6; + this.ERROR_NOT_DAV = -7; + this.ERROR_BAD_REQUEST = -8; + this.ERROR_AUTH_FAILED = -9; + this.ERROR_FORBIDDEN = -10; + this.ERROR_PARENT_DIR_NOT_FOUND = -11; + this.ERROR_ZOTERO_DIR_NOT_FOUND = -12; + this.ERROR_ZOTERO_DIR_NOT_WRITABLE = -13; + this.ERROR_NOT_ALLOWED = -14; + this.ERROR_UNKNOWN = -15; + + + // + // Public properties + // + + /** + * URI of Zotero directory on storage server + * + * @return {nsIURI} nsIURI of data directory, with spec ending in '/' + */ + this.__defineGetter__('rootURI', function () { + if (_rootURI) { + return _rootURI.clone() + } + + var spec = Zotero.Prefs.get('sync.storage.url'); + if (!spec) { + var msg = "Zotero storage URL not provided"; + Zotero.debug(msg); + throw ({ + message: msg, + name: "Z_ERROR_NO_URL", + filename: "storage.js", + toString: function () { return this.message; } + }); + } + var username = Zotero.Sync.Storage.username; + if (!username) { + var msg = "Zotero storage username not provided"; + Zotero.debug(msg); + throw ({ + message: msg, + name: "Z_ERROR_NO_USERNAME", + filename: "storage.js", + toString: function () { return this.message; } + }); + } + var password = Zotero.Sync.Storage.password; + if (!password) { + var msg = "Zotero storage password not provided"; + Zotero.debug(msg); + throw ({ + message: msg, + name: "Z_ERROR_NO_PASSWORD", + filename: "storage.js", + toString: function () { return this.message; } + }); + } + + spec = 'https://' + spec + '/zotero/'; + + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + try { + var uri = ios.newURI(spec, null, null); + uri.username = username; + uri.password = password; + } + catch (e) { + Zotero.debug(e); + Components.utils.reportError(e); + return false; + } + _rootURI = uri; + return _rootURI.clone(); + + + return ; + }); + + this.__defineGetter__('username', function () { + return Zotero.Prefs.get('sync.storage.username'); + }); + + this.__defineGetter__('password', function () { + var username = this.username; + + if (!username) { + Zotero.debug('Username not set before setting Zotero.Sync.Storage.password'); + return ''; + } + + if (_cachedCredentials.username && _cachedCredentials.username == username) { + return _cachedCredentials.password; + } + + Zotero.debug('Getting Zotero storage password'); + var loginManager = Components.classes["@mozilla.org/login-manager;1"] + .getService(Components.interfaces.nsILoginManager); + var logins = loginManager.findLogins({}, _loginManagerHost, _loginManagerURL, null); + + // Find user from returned array of nsILoginInfo objects + for (var i = 0; i < logins.length; i++) { + if (logins[i].username == username) { + _cachedCredentials.username = username; + _cachedCredentials.password = logins[i].password; + return logins[i].password; + } + } + + return ''; + }); + + this.__defineSetter__('password', function (password) { + _rootURI = false; + + var username = this.username; + if (!username) { + Zotero.debug('Username not set before setting Zotero.Sync.Server.password'); + return; + } + + _cachedCredentials = {}; + + 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 Zotero storage passwords'); + loginManager.removeLogin(logins[i]); + break; + } + + if (password) { + Zotero.debug('Setting Zotero storage password'); + 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); + _cachedCredentials.username = username; + _cachedCredentials.password = password; + } + }); + + this.__defineGetter__('active', function () { + return Zotero.Prefs.get("sync.storage.enabled") && + Zotero.Prefs.get("sync.storage.verified"); + }); + + this.__defineGetter__("syncInProgress", function () _syncInProgress); + + this.compressionTracker = { + compressed: 0, + uncompressed: 0, + get ratio() { + return Math.round( + (Zotero.Sync.Storage.compressionTracker.uncompressed - + Zotero.Sync.Storage.compressionTracker.compressed) / + Zotero.Sync.Storage.compressionTracker.uncompressed * 100); + } + } + + // + // Private properties + // + var _loginManagerHost = 'chrome://zotero'; + var _loginManagerURL = 'Zotero Storage Server'; + var _cachedCredentials = { username: null, password: null, authHeader: null }; + var _rootURI; + var _syncInProgress; + var _finishCallback; + + // Queue + var _queues = { + download: { current: 0, queue: [] }, + upload: { current: 0, queue: [] } + }; + var _queueSimultaneous = { + download: null, + upload: null + }; + + // Progress + var _requests = { + download: {}, + upload: {} + }; + var _numRequests = { + download: { active: 0, queued: 0, done: 0 }, + upload: { active: 0, queued: 0, done: 0 } + } + var _totalProgress = { + download: 0, + upload: 0 + }; + var _totalProgressMax = { + download: 0, + upload: 0 + } + _requestSizeMultiplier = 1; + + + // + // Public methods + // + this.init = function () { + _queueSimultaneous.download = Zotero.Prefs.get('sync.storage.maxDownloads'); + _queueSimultaneous.upload = Zotero.Prefs.get('sync.storage.maxUploads'); + } + + + this.sync = function () { + if (!Zotero.Sync.Storage.active) { + Zotero.debug("Storage sync is not active"); + Zotero.Sync.Runner.next(); + return; + } + + if (_syncInProgress) { + _error("Storage sync operation already in progress"); + } + + Zotero.debug("Beginning storage sync"); + Zotero.Sync.Runner.setSyncIcon('animate'); + _syncInProgress = true; + + Zotero.Sync.Storage.checkForUpdatedFiles(); + + // If authorization header isn't cached, cache it before proceeding, + // since during testing Firefox 3.0.1 was being a bit amnesic with auth + // info for subsequent requests -- surely a better way to fix this + if (!_cachedCredentials.authHeader) { + Zotero.Utilities.HTTP.doOptions(Zotero.Sync.Storage.rootURI, function (req) { + var authHeader = Zotero.Utilities.HTTP.getChannelAuthorization(req.channel); + if (authHeader) { + _cachedCredentials.authHeader = authHeader; + } + + var activeDown = Zotero.Sync.Storage.downloadFiles(); + var activeUp = Zotero.Sync.Storage.uploadFiles(); + if (!activeDown && !activeUp) { + _syncInProgress = false; + Zotero.Sync.Runner.next(); + } + }); + return; + } + + var activeDown = Zotero.Sync.Storage.downloadFiles(); + var activeUp = Zotero.Sync.Storage.uploadFiles(); + if (!activeDown && !activeUp) { + _syncInProgress = false; + Zotero.Sync.Runner.next(); + } + } + + + /** + * @param {Integer} itemID + */ + this.getSyncState = function (itemID) { + var sql = "SELECT syncState FROM itemAttachments WHERE itemID=?"; + return Zotero.DB.valueQuery(sql, itemID); + } + + + /** + * @param {Integer} itemID + * @param {Integer} syncState Constant from Zotero.Sync.Storage + */ + this.setSyncState = function (itemID, syncState) { + switch (syncState) { + case this.SYNC_STATE_TO_UPLOAD: + case this.SYNC_STATE_TO_DOWNLOAD: + case this.SYNC_STATE_IN_SYNC: + break; + + default: + _error("Invalid sync state '" + syncState + + "' in Zotero.Sync.Storage.setSyncState()"); + } + + var sql = "UPDATE itemAttachments SET syncState=? WHERE itemID=?"; + return Zotero.DB.valueQuery(sql, [syncState, itemID]); + } + + + /** + * @param {Integer} itemID + * @return {Integer|NULL} Mod time as Unix timestamp, + * or NULL if never synced + */ + this.getSyncedModificationTime = function (itemID) { + var sql = "SELECT storageModTime FROM itemAttachments WHERE itemID=?"; + var mtime = Zotero.DB.valueQuery(sql, itemID); + if (mtime === false) { + _error("Item " + itemID + + " not found in Zotero.Sync.Storage.getSyncedModificationTime()"); + } + return mtime; + } + + + /** + * @param {Integer} itemID + * @param {Integer} mtime File modification time as + * Unix timestamp + * @param {Boolean} [updateItem=FALSE] Update dateModified field of + * attachment item + */ + this.setSyncedModificationTime = function (itemID, mtime, updateItem) { + Zotero.DB.beginTransaction(); + + var sql = "UPDATE itemAttachments SET storageModTime=? WHERE itemID=?"; + Zotero.DB.valueQuery(sql, [mtime, itemID]); + + if (updateItem) { + // Update item date modified so the new mod time will be synced + var item = Zotero.Items.get(itemID); + //var date = new Date(mtime * 1000); + //item.setField('dateModified', Zotero.Date.dateToSQL(date, true)); + item.setField('dateModified', Zotero.DB.transactionDateTime); + item.save(); + } + + Zotero.DB.commitTransaction(); + } + + + /** + * Get mod time of file on storage server + * + * @param {Zotero.Item} item + * @param {Function} callback Callback f(item, mdate) + */ + this.getStorageModificationTime = function (item, callback) { + var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; + var D = new Namespace("D", "DAV:"); + var dcterms = new Namespace("dcterms", "http://purl.org/dc/terms/"); + + var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '" ' + + 'xmlns:' + dcterms.prefix + '=' + '"' + dcterms.uri + '" '; + + // Retrieve Dublin Core 'modified' property + var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); + requestXML.D::prop = ''; + requestXML.D::prop.dcterms::modified = ''; + + var xmlstr = prolog + requestXML.toXMLString(); + + var uri = _getItemURI(item); + var headers = _cachedCredentials.authHeader ? + { Authorization: _cachedCredentials.authHeader } : null; + + Zotero.Utilities.HTTP.WebDAV.doProp('PROPFIND', uri, xmlstr, function (req) { + var funcName = "Zotero.Sync.Storage.getStorageModificationTime()"; + + if (req.status == 404) { + callback(item, false); + return; + } + else if (req.status != 207) { + _error("Unexpected status code " + req.status + " in " + funcName); + } + + _checkResponse(req); + + Zotero.debug(req.responseText); + + var D = "DAV:"; + var dcterms = "http://purl.org/dc/terms/"; + + // Error checking + var multistatus = req.responseXML.firstChild; + var responses = multistatus.getElementsByTagNameNS(D, "response"); + if (responses.length == 0) { + _error("No <response/> sections found in " + funcName); + } + else if (responses.length > 1) { + _error("Multiple <response/> sections in " + funcName); + } + + var response = responses.item(0); + var href = response.getElementsByTagNameNS(D, "href").item(0); + if (!href) { + _error("DAV:href not found in " + funcName); + } + + if (href.firstChild.nodeValue != uri.path) { + _error("DAV:href does not match path in " + funcName); + } + + var modified = response.getElementsByTagNameNS(dcterms, "modified").item(0); + if (!modified) { + _error("dcterms:modified not found in " + funcName); + } + + // No modification time set + if (modified.childNodes.length == 0) { + callback(item, false); + return; + } + + var mdate = Zotero.Date.isoToDate(modified.firstChild.nodeValue); + callback(item, mdate); + }, headers); + } + + + /** + * Set mod time of file on storage server + * + * @param {Zotero.Item} item + * @param {Function} callback Callback f(item, mtime) + */ + this.setStorageModificationTime = function (item, callback) { + var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; + var D = new Namespace("D", "DAV:"); + var dcterms = new Namespace("dcterms", "http://purl.org/dc/terms/"); + + var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '" ' + + 'xmlns:' + dcterms.prefix + '=' + '"' + dcterms.uri + '" '; + + // Set Dublin Core 'modified' property + var requestXML = new XML('<D:propertyupdate ' + nsDeclarations + '/>'); + + var mdate = new Date(item.attachmentModificationTime * 1000); + var modified = Zotero.Date.dateToISO(mdate); + requestXML.D::set.D::prop.dcterms::modified = modified; + + var xmlstr = prolog + requestXML.toXMLString(); + + var uri = _getItemURI(item); + var headers = _cachedCredentials.authHeader ? + { Authorization: _cachedCredentials.authHeader } : null; + + Zotero.Utilities.HTTP.WebDAV.doProp('PROPPATCH', uri, xmlstr, function (req) { + _checkResponse(req); + + callback(item, Zotero.Date.toUnixTimestamp(mdate)); + }, headers); + } + + + /** + * Check if modification time of file on disk matches the mod time + * in the database + * + * @param {Integer} itemID + * @return {Boolean} + */ + this.isFileModified = function (itemID) { + var item = Zotero.Items.get(itemID); + if (!item.getFile()) { + return false; + } + + var fileModTime = item.attachmentModificationTime; + if (!fileModTime) { + return false; + } + + var syncModTime = Zotero.Sync.Storage.getSyncedModificationTime(itemID); + if (fileModTime != syncModTime) { + return true; + } + + return false; + } + + + /** + * Scans local files and marks any that have changed as 0 for uploading + * and any that are missing as 1 for downloading + * + * Also marks missing files for downloading + * + * @param {Integer[]} itemIDs An optional set of item ids to check + * @param {Object} itemModTimes Item mod times indexed by item ids + * appearing in itemIDs; if set, + * items with stored mod times + * that differ from the provided + * time but file mod times + * matching the stored time will + * be marked for download + * @return {Boolean} TRUE if any items changed state, + * FALSE otherwise + */ + this.checkForUpdatedFiles = function (itemIDs, itemModTimes) { + Zotero.debug("Checking for locally changed attachment files"); + // check for current ops? + + if (itemModTimes && !itemIDs) { + _error("itemModTimes can only be set if itemIDs is an array " + + "in Zotero.Sync.Storage.checkForUpdatedFiles()"); + } + + var changed = false; + + if (!itemIDs) { + itemIDs = []; + } + + // Can only handle 999 bound parameters at a time + var numIDs = itemIDs.length; + var maxIDs = 999; + var done = 0; + var rows = []; + + Zotero.DB.beginTransaction(); + + do { + var chunk = itemIDs.splice(0, maxIDs); + var sql = "SELECT itemID, linkMode, path, storageModTime FROM itemAttachments " + + "WHERE linkMode IN (?,?) AND syncState IN (?,?)"; + var params = [ + Zotero.Attachments.LINK_MODE_IMPORTED_FILE, + Zotero.Attachments.LINK_MODE_IMPORTED_URL, + Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD, + Zotero.Sync.Storage.SYNC_STATE_IN_SYNC + ]; + if (chunk.length) { + sql += " AND itemID IN (" + chunk.map(function () '?').join() + ")"; + params = params.concat(chunk); + } + var chunkRows = Zotero.DB.query(sql, params); + if (chunkRows) { + rows = rows.concat(chunkRows); + } + done += chunk.length; + } + while (done < numIDs); + + if (!rows) { + Zotero.debug("No to-upload or in-sync files found"); + Zotero.DB.commitTransaction(); + return changed; + } + + // Index mtimes by item id + var itemIDs = []; + var mtimes = {}; + var attachmentData = {}; + for each(var row in rows) { + var id = row.itemID; + + // In download-marking mode, ignore attachments whose + // storage mod times haven't changed + if (itemModTimes && + row.storageModTime == itemModTimes[id]) { + Zotero.debug("Storage mod time (" + row.storageModTime + + ") hasn't changed for attachment " + id); + continue; + } + itemIDs.push(id); + mtimes[id] = row.storageModTime; + attachmentData[id] = { + linkMode: row.linkMode, + path: row.path + }; + } + if (itemIDs.length == 0) { + Zotero.DB.commitTransaction(); + return changed; + } + + rows = undefined; + + var updatedStates = {}; + var items = Zotero.Items.get(itemIDs); + for each(var item in items) { + var file = item.getFile(attachmentData[item.id]); + if (!file) { + Zotero.debug("Marking attachment " + item.id + " as missing"); + updatedStates[item.id] = + Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD; + continue; + } + + var fileModTime = Math.round(file.lastModifiedTime / 1000); + + //Zotero.debug("Stored mtime is " + mtimes[item.id]); + //Zotero.debug("File mtime is " + fileModTime); + + if (itemModTimes) { + Zotero.debug("Item mod time is " + itemModTimes[item.id]); + } + + if (mtimes[item.id] != fileModTime) { + Zotero.debug("Marking attachment " + item.id + " as changed"); + updatedStates[item.id] = + Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD; + } + else if (itemModTimes) { + Zotero.debug("Marking attachment " + item.id + " for download"); + updatedStates[item.id] = + Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD; + } + } + + for (var itemID in updatedStates) { + var sql = "UPDATE itemAttachments SET syncState=? WHERE itemID=?"; + Zotero.DB.query( + sql, + [ + updatedStates[itemID], + itemID + ] + ); + changed = true; + } + + if (!changed) { + Zotero.debug("No synced files have changed locally"); + } + + //throw ('foo'); + + Zotero.DB.commitTransaction(); + return changed; + } + + + /** + * Start download of all attachments marked for download + * + * @return {Boolean} + */ + this.downloadFiles = function () { + // Check for active operations? + _queueReset('download'); + + var downloadFileIDs = _getFilesToDownload(); + if (!downloadFileIDs) { + Zotero.debug("No files to download"); + return false; + } + + for each(var itemID in downloadFileIDs) { + var item = Zotero.Items.get(itemID); + if (this.isFileModified(itemID)) { + Zotero.debug("File for attachment " + itemID + " has been modified"); + this.setSyncState(itemID, this.SYNC_STATE_TO_UPLOAD); + continue; + } + + _addRequest({ + name: _getItemURI(item).spec, + requestMethod: "GET", + QueryInterface: function (iid) { + if (iid.equals(Components.interfaces.nsIHttpChannel) || + iid.equals(Components.interfaces.nsISupports)) { + return this; + } + throw Components.results.NS_NOINTERFACE; + } + }); + _queueAdd('download', itemID); + } + + // Start downloads + _queueAdvance('download', Zotero.Sync.Storage.downloadFile); + return true; + } + + + /** + * Begin download process for individual file + * + * @param {Integer} itemID + */ + this.downloadFile = function (itemID) { + var item = Zotero.Items.get(itemID); + if (!item) { + var msg = "Item " + itemID + + " not found in Zotero.Sync.Storage.downloadFile()"; + Zotero.debug(msg); + Components.utils.reportError(msg); + _queueAdvance('download', Zotero.Sync.Storage.downloadFile, true); + return; + } + + // Retrieve modification time from server to store locally afterwards + Zotero.Sync.Storage.getStorageModificationTime(item, function (item, mdate) { + if (!mdate) { + Zotero.debug("Remote file not found for item " + item.id); + _queueAdvance('download', Zotero.Sync.Storage.downloadFile, true); + return; + } + + var syncModTime = Zotero.Date.toUnixTimestamp(mdate); + var uri = _getItemURI(item); + var destFile = Zotero.getTempDirectory(); + destFile.append(item.key + '.zip.tmp'); + if (destFile.exists()) { + destFile.remove(null); + } + + var listener = new Zotero.Sync.Storage.StreamListener( + { + onProgress: _updateProgress, + onStop: _processDownload, + item: item, + syncModTime: syncModTime + } + ); + + Zotero.debug('Saving 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); + + + /* + // Start the download + var incrDown = Components.classes["@mozilla.org/network/incremental-download;1"] + .createInstance(Components.interfaces.nsIIncrementalDownload); + incrDown.init(uri, destFile, -1, 2); + incrDown.start(listener, null); + */ + }); + } + + + /** + * Start upload of all attachments marked for upload + * + * If mod time on server doesn't match file, display conflict window + * + * @return {Boolean} + */ + this.uploadFiles = function () { + // Check for active operations? + _queueReset('upload'); + + var uploadFileIDs = _getFilesToUpload(); + if (!uploadFileIDs) { + Zotero.debug("No files to upload"); + return false; + } + + Zotero.debug(uploadFileIDs.length + " file(s) to upload"); + + for each(var itemID in uploadFileIDs) { + var item = Zotero.Items.get(itemID); + var size = Zotero.Attachments.getTotalFileSize(item, true); + _addRequest({ + name: _getItemURI(item).spec, + requestMethod: "PUT", + QueryInterface: function (iid) { + if (iid.equals(Components.interfaces.nsIHttpChannel) || + iid.equals(Components.interfaces.nsISupports)) { + return this; + } + throw Components.results.NS_NOINTERFACE; + } + }, size); + _queueAdd('upload', itemID); + } + + // Start uploads + _queueAdvance('upload', Zotero.Sync.Storage.uploadFile); + return true; + } + + + this.uploadFile = function (itemID) { + _createUploadFile(itemID); + } + + + /** + * Remove files on storage server that were deleted locally more than + * sync.storage.deleteDelayDays days ago + * + * @param {Function} callback Passed number of files deleted + */ + this.purgeDeletedStorageFiles = function (callback) { + Zotero.debug("Purging deleted storage files"); + var files = _getDeletedFiles(); + if (!files) { + Zotero.debug("No files to delete remotely"); + return; + } + + // Add .zip extension + var files = files.map(function (file) file + ".zip"); + + _deleteStorageFiles(files, function (results) { + // Remove nonexistent files from storage delete log + if (results.missing.length > 0) { + var done = 0; + var maxFiles = 999; + var numFiles = results.missing.length; + + Zotero.DB.beginTransaction(); + + do { + var chunk = files.splice(0, maxFiles); + var sql = "DELETE FROM storageDeleteLog WHERE key IN (" + + chunk.map(function () '?').join() + ")"; + Zotero.DB.query(sql, chunk); + done += chunk.length; + } + while (done < numFiles); + + Zotero.DB.commitTransaction(); + } + + if (callback) { + callback(results.deleted.length); + } + }); + } + + + /** + * Delete orphaned storage files older than a day before last sync time + * + * @param {Function} callback + */ + this.purgeOrphanedStorageFiles = function (callback) { + const daysBeforeSyncTime = 1; + + Zotero.debug("Purging orphaned storage files"); + var uri = Zotero.Sync.Storage.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.Utilities.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) { + // Strip XML declaration and convert to E4X + var xml = new XML(req.responseText.replace(/<\?xml.*\?>/, '')); + + var deleteFiles = []; + for each(var response in xml.D::response) { + var href = response.D::href.toString(); + if (href == path) { + continue; + } + var file = href.match(/[^\/]+$/)[0]; + if (file.indexOf('.') == 0) { + Zotero.debug("Skipping hidden file " + file); + continue; + } + if (!file.match(/\.zip/)) { + Zotero.debug("Skipping non-ZIP file " + file); + continue; + } + + var key = file.replace(/\.zip/, ''); + var item = Zotero.Items.getByKey(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, callback); + }, + { Depth: 1 }); + } + + + /** + * Create a Zotero directory on the storage server + */ + this.createServerDirectory = function (callback) { + var uri = this.rootURI; + Zotero.Utilities.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; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + case 403: + callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); + return; + + 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; + } + }); + } + + + this.resetAllSyncStates = function () { + var sql = "UPDATE itemAttachments SET syncState=?"; + Zotero.DB.query(sql, [this.SYNC_STATE_TO_UPLOAD]); + } + + + this.clearSettingsCache = function () { + _rootURI = undefined; + } + + + // + // Private methods + // + + + /** + * Extract a downloaded ZIP file and update the database metadata + * + * This is called from Zotero.Sync.Server.StreamListener.onStopRequest() + * + * @param {nsIRequest} request + * @param {Integer} status Status code from download listener + * @param {String} response + * @return {Object} data Properties 'item', 'syncModTime' + */ + function _processDownload(request, status, response, data) { + var item = data.item; + var syncModTime = data.syncModTime; + var zipFile = Zotero.getTempDirectory(); + zipFile.append(item.key + '.zip.tmp'); + + Zotero.debug("Finished download of " + zipFile.path + " with status " + status); + + var zipReader = Components.classes["@mozilla.org/libjar/zip-reader;1"]. + createInstance(Components.interfaces.nsIZipReader); + try { + zipReader.open(zipFile); + zipReader.test(null); + + Zotero.debug("ZIP file is OK"); + } + catch (e) { + Zotero.debug(zipFile.leafName + " is not a valid ZIP file", 2); + zipFile.remove(null); + _removeRequest(request); + _queueAdvance('download', Zotero.Sync.Storage.downloadFile, true); + return; + } + + var parentDir = Zotero.Attachments.createDirectoryForItem(item.id); + + // Delete existing files + var otherFiles = parentDir.directoryEntries; + while (otherFiles.hasMoreElements()) { + var file = otherFiles.getNext(); + file.QueryInterface(Components.interfaces.nsIFile); + if (file.leafName.indexOf('.') == 0 || file.equals(zipFile)) { + continue; + } + Zotero.debug("Deleting existing file " + file.leafName); + file.remove(null); + } + + var entries = zipReader.findEntries(null); + while (entries.hasMore()) { + var entryName = entries.getNext(); + var b64re = /%ZB64$/; + if (entryName.match(b64re)) { + var fileName = Zotero.Utilities.Base64.decode( + entryName.replace(b64re, '') + ); + } + else { + var fileName = entryName; + } + + if (fileName.indexOf('/') != -1 || fileName.indexOf('.') == 0) { + Zotero.debug("Skipping " + fileName); + continue; + } + + Zotero.debug("Extracting " + fileName); + var destFile = parentDir.clone(); + destFile.QueryInterface(Components.interfaces.nsILocalFile); + destFile.setRelativeDescriptor(parentDir, fileName); + destFile.create(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644); + zipReader.extract(entryName, destFile); + destFile.permissions = 0644; + } + zipReader.close(); + zipFile.remove(null); + + var file = item.getFile(); + if (!file) { + _error("File " + file.leafName + " not found for item " + + itemID + " after extracting ZIP"); + } + file.lastModifiedTime = syncModTime * 1000; + + 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(); + + _removeRequest(request); + _queueAdvance('download', Zotero.Sync.Storage.downloadFile, true); + } + + + /** + * Create zip file of attachment directory + * + * @param {Integer} itemID + * @return {Boolean} TRUE if zip process started, + * FALSE if storage was empty + */ + function _createUploadFile(itemID) { + Zotero.debug('Creating zip file for item ' + itemID); + var item = Zotero.Items.get(itemID); + + switch (item.attachmentLinkMode) { + case Zotero.Attachments.LINK_MODE_LINKED_FILE: + case Zotero.Attachments.LINK_MODE_LINKED_URL: + _error("Upload file must be an imported snapshot or file in " + + "Zotero.Sync.Storage.createUploadFile()"); + } + + var dir = Zotero.Attachments.getStorageDirectory(itemID); + + var tmpFile = Zotero.getTempDirectory(); + tmpFile.append(item.key + '.zip'); + + var zw = Components.classes["@mozilla.org/zipwriter;1"] + .createInstance(Components.interfaces.nsIZipWriter); + zw.open(tmpFile, 0x04 | 0x08 | 0x20); // open rw, create, truncate + var fileList = []; + dir = dir.directoryEntries; + while (dir.hasMoreElements()) { + var file = dir.getNext(); + file.QueryInterface(Components.interfaces.nsILocalFile); + var fileName = file.getRelativeDescriptor(file.parent); + + if (fileName.indexOf('.') == 0) { + Zotero.debug('Skipping file ' + fileName); + continue; + } + + //Zotero.debug("Adding file " + fileName); + + fileName = Zotero.Utilities.Base64.encode(fileName) + "%ZB64"; + zw.addEntryFile( + fileName, + Components.interfaces.nsIZipWriter.COMPRESSION_DEFAULT, + file, + true + ); + fileList.push(fileName); + } + + if (fileList.length == 0) { + Zotero.debug('No files to add -- removing zip file'); + tmpFile.remove(null); + _queueAdvance('upload', Zotero.Sync.Storage.uploadFile, true); + return false; + } + + Zotero.debug('Creating ' + tmpFile.leafName + ' with ' + fileList.length + ' file(s)'); + + var observer = new Zotero.Sync.Storage.ZipWriterObserver( + zw, _processUploadFile, { itemID: itemID, files: fileList } + ); + zw.processQueue(observer, null); + return true; + } + + + /** + * Upload the generated ZIP file to the server + * + * @param {Object} Object with 'itemID' property + * @return {void} + */ + function _processUploadFile(data) { + _updateSizeMultiplier( + (100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100 + ); + + var item = Zotero.Items.get(data.itemID); + + Zotero.Sync.Storage.getStorageModificationTime(item, function (item, mdate) { + // Check for conflict + if (mdate) { + var file = item.getFile(); + if (Zotero.Date.toUnixTimestamp(mdate) + != Zotero.Sync.Storage.getSyncedModificationTime(item.id)) { + _error("Conflict! Last known mod time does not match remote time!") + } + } + 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; + if (_cachedCredentials.authHeader) { + channel.setRequestHeader( + 'Authorization', _cachedCredentials.authHeader, false + ); + } + channel.setRequestHeader('Keep-Alive', '', false); + channel.setRequestHeader('Connection', '', false); + + var listener = new Zotero.Sync.Storage.StreamListener( + { + onProgress: _updateProgress, + onStop: _onUploadComplete, + item: item + } + ); + channel.notificationCallbacks = listener; + channel.asyncOpen(listener, null); + }); + } + + + function _onUploadComplete(request, status, response, data) { + var item = data.item; + var url = request.name; + + Zotero.debug("Upload of attachment " + item.id + + " finished with status code " + status); + + switch (status) { + case 201: + case 204: + break; + + default: + _error("File upload status was " + status + + " in Zotero.Sync.Storage._onUploadComplete()"); + } + + Zotero.Sync.Storage.setStorageModificationTime(item, function (item, mtime) { + Zotero.DB.beginTransaction(); + + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.Sync.Storage.setSyncedModificationTime(item.id, mtime, true); + + Zotero.DB.commitTransaction(); + + var file = Zotero.getTempDirectory(); + file.append(item.key + '.zip'); + file.remove(null); + + _removeRequest(request); + _queueAdvance('upload', Zotero.Sync.Storage.uploadFile, true); + }); + } + + + /** + * Get files marked as ready to upload + * + * @inner + * @return {Number[]} Array of attachment itemIDs + */ + function _getFilesToDownload() { + var sql = "SELECT itemID FROM itemAttachments WHERE syncState=?"; + return Zotero.DB.columnQuery(sql, Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD); + } + + + /** + * Get files marked as ready to upload + * + * @inner + * @return {Number[]} Array of attachment itemIDs + */ + function _getFilesToUpload() { + var sql = "SELECT itemID FROM itemAttachments WHERE syncState=? " + + "AND linkMode IN (?,?)"; + return Zotero.DB.columnQuery(sql, + [ + Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD, + Zotero.Attachments.LINK_MODE_IMPORTED_FILE, + Zotero.Attachments.LINK_MODE_IMPORTED_URL + ] + ); + } + + + /** + * @inner + * @param {Integer} [days=pref:e.z.sync.storage.deleteDelayDays] + * Number of days old files have to be + * @return {String[]|FALSE} Array of keys, or FALSE if none + */ + function _getDeletedFiles(days) { + if (!days) { + days = Zotero.Prefs.get("sync.storage.deleteDelayDays"); + } + + var ts = Zotero.Date.getUnixTimestamp(); + ts = ts - (86400 * days); + + var sql = "SELECT key FROM storageDeleteLog WHERE timestamp<?"; + return Zotero.DB.columnQuery(sql, ts); + } + + + /** + * @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: [] + }; + + if (files.length == 0) { + if (callback) { + callback(results); + } + return; + } + + for (var i=0; i<files.length; i++) { + let last = (i == files.length - 1); + let fileName = files[i]; + + var deleteURI = Zotero.Sync.Storage.rootURI; + // This should never happen, but let's be safe + if (!deleteURI.spec.match(/\/$/)) { + callback(deleted); + _error("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.Utilities.HTTP.WebDAV.doDelete(deleteURI, function (req) { + switch (req.status) { + case 204: + results.deleted.push(fileName); + break; + + case 404: + 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 + ")."; + _error(msg); + } + }); + } + } + + + /** + * @param {Function} callback Function to pass URI and result value to + */ + this.checkServer = function (callback) { + try { + 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_USERNAME': + callback(null, Zotero.Sync.Storage.ERROR_NO_USERNAME); + return; + + case 'Z_ERROR_NO_PASSWORD': + callback(null, Zotero.Sync.Storage.ERROR_NO_PASSWORD); + return; + + default: + Zotero.debug(e); + Components.utils.reportError(e); + callback(null, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + } + + var requestHolder = { request: null }; + + var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; + var D = new Namespace("D", "DAV:"); + var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"'; + + var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); + requestXML.D::prop = ''; + + var xmlstr = prolog + requestXML.toXMLString(); + + // Test whether URL is WebDAV-enabled + var request = Zotero.Utilities.HTTP.doOptions(uri, function (req) { + Zotero.debug(req.status); + + // Timeout + if (req.status == 0) { + 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; + } + + var headers = { Depth: 0 }; + + var authHeader = Zotero.Utilities.HTTP.getChannelAuthorization(req.channel); + if (authHeader) { + _cachedCredentials.authHeader = authHeader; + headers.Authorization = authHeader; + // Create a version without Depth + var authHeaders = { Authorization: authHeader }; + var authRequired = true; + } + else { + var authRequired = false; + } + + // Test whether Zotero directory exists + Zotero.Utilities.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.Utilities.HTTP.WebDAV.doPut(testFileURI, "", function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 201: + // Delete test file + Zotero.Utilities.HTTP.WebDAV.doDelete( + testFileURI, + function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 204: + callback( + uri, + Zotero.Sync.Storage.SUCCESS, + !authRequired + ); + 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; + + 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: + var parentURI = uri.clone(); + parentURI.spec = parentURI.spec.replace(/zotero\/$/, ''); + + // Zotero directory wasn't found, so if at least + // the parent directory exists + Zotero.Utilities.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 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; + } + }, headers); + 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; + } + + + /** + * 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.rootURI; + uri.spec = uri.spec + item.key + '.zip'; + return uri; + } + + + /** + * @inner + * @param {XMLHTTPRequest} req + * @throws + */ + function _checkResponse(req) { + if (!req.responseXML || + !req.responseXML.firstChild || + !(req.responseXML.firstChild.namespaceURI == 'DAV:' && + req.responseXML.firstChild.localName == 'multistatus')) { + Zotero.debug(req.responseText); + _error('Invalid response from server'); + } + + if (!req.responseXML.childNodes[0].firstChild) { + _error('Empty response from server'); + } + } + + + // + // Queuing functions + // + function _queueAdd(queueName, id) { + Zotero.debug("Queuing " + queueName + " object " + id); + var q = _queues[queueName]; + if (q.queue.indexOf(id) != -1) { + return; + } + q.queue.push(id); + } + + + function _queueAdvance(queueName, callback, decrement) { + var q = _queues[queueName]; + + if (decrement) { + q.current--; + } + + if (q.queue.length == 0) { + Zotero.debug("No objects in " + queueName + " queue (" + + q.current + " current)"); + return; + } + + if (q.current >= _queueSimultaneous[queueName]) { + Zotero.debug(queueName + " queue is busy (" + q.current + ")"); + return; + } + + Zotero.debug("Processing next object in " + queueName + " queue"); + + var id = q.queue.shift(); + q.current++; + + callback(id); + + // Wait a second, and then, if still under limit and there are more + // requests, process another + setTimeout(function () { + if (q.queue.length > 0 && q.current < _queueSimultaneous[queueName]) { + _queueAdvance(queueName, callback); + } + }, 1000); + } + + + function _queueReset(queueName) { + Zotero.debug("Resetting " + queueName + " queue"); + var q = _queues[queueName]; + q.queue = []; + q.current = 0; + } + + + // + // Progress management + // + /** + * @param {nsIRequest} + * @param {Integer} [size] Total size in bytes, which might be + * scaled by a compression multiplier + */ + function _addRequest(request, size) { + var info = _getRequestInfo(request); + var queue = info.queue; + var name = info.name; + + if (_requests[queue][name]) { + queue = queue.substr(0, 1).toUpperCase() + queue.substr(1); + _error(queue + " request already exists in Zotero.Sync.Storage._addRequest()"); + } + _requests[queue][name] = { + state: 0, // 0: queued, 1: active, 2: done + progress: 0, + progressMax: 0, + size: size ? size : null + }; + // Add estimated size + if (size) { + _totalProgressMax[queue] += Math.round(size * _requestSizeMultiplier); + } + _numRequests[queue].queued++; + } + + + /** + * 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(); + } + + + /** + * Update counters for given request + * + * Also updates progress meter + * + * @param {nsIRequest} request + * @param {Integer} progress Bytes transferred so far + * @param {Integer} progressMax Total bytes in this request + */ + function _updateProgress(request, progress, progressMax) { + //Zotero.debug("Updating progress"); + + var info = _getRequestInfo(request); + var queue = info.queue; + var name = info.name; + + var r = _requests[queue][name]; + + switch (r.state) { + // Queued + case 0: + r.state = 1; + _numRequests[queue].queued--; + _numRequests[queue].active++; + // Remove estimated size + if (r.size) { + _totalProgressMax[queue] -= + Math.round(r.size * _requestSizeMultiplier); + } + break; + + // Done + case 2: + _error("Trying to update a finished request in " + + "_Zotero.Sync.Storage._updateProgress()"); + } + + _totalProgress[queue] += progress - r.progress; + r.progress = progress; + + _totalProgressMax[queue] += progressMax - r.progressMax; + r.progressMax = progressMax; + + _updateProgressMeter(); + } + + + /* + * Mark request as done, and, if last request, clear all requests + * + * Also updates progress meter + */ + function _removeRequest(request) { + var info = _getRequestInfo(request); + var queue = info.queue; + var name = info.name; + + var r = _requests[queue][name]; + + //Zotero.debug("Removing " + queue + " request " + name); + if (!r) { + _error("Existing " + queue + " request not found in " + + "Zotero.Sync.Storage._removeRequest()"); + } + + switch (r.state) { + // Active + case 1: + _numRequests[queue].active--; + _numRequests[queue].done++; + //_totalProgress[queue] -= r.progressMax; + //_totalProgressMax[queue] -= r.progressMax; + break; + + // Queued + case 0: + _numRequests[queue].queued--; + _numRequests[queue].done++; + // Remove estimated size + //_totalProgressMax[queue] -= Math.round(r.size * _requestSizeMultiplier); + break; + + // Done + case 2: + _error("Trying to remove a finished request in " + + "_Zotero.Sync.Storage._removeRequest()"); + } + + //r = undefined; + //delete _requests[queue][name]; + r.state = 2; // Done + + var done = _resetRequestsIfDone(); + if (!done) { + _updateProgressMeter(); + } + } + + + /** + * Check if all requests are done, and if so reset everything + * + * Also updates progress meter + */ + function _resetRequestsIfDone() { + for (var queue in _requests) { + if (_numRequests[queue].active != 0 || _numRequests[queue].queued != 0) { + return false; + } + } + Zotero.debug("Resetting all requests"); + for (var queue in _requests) { + _requests[queue] = {}; + _numRequests[queue].done = 0; + _totalProgress[queue] = 0; + _totalProgressMax[queue] = 0; + _requestSizeMultiplier = 1; + } + _updateProgressMeter(); + + // TODO: Find a better place for this? + _syncInProgress = false; + Zotero.Sync.Runner.next(); + return true; + } + + + function _updateProgressMeter() { + var totalRequests = 0; + for (var queue in _requests) { + totalRequests += _numRequests[queue].active; + totalRequests += _numRequests[queue].queued; + } + + if (totalRequests > 0) { + var percentage = Math.round( + ( + (_totalProgress.download + _totalProgress.upload) / + (_totalProgressMax.download + _totalProgressMax.upload) + ) * 100 + ); + //Zotero.debug("Percentage is " + percentage); + + if (_totalProgressMax.download) { + var remaining = Math.round( + (_totalProgressMax.download - _totalProgress.download) / 1024 + ); + var downloadStatus = + Zotero.getString('sync.storage.kbRemaining', remaining); + } + else { + var downloadStatus = Zotero.getString('sync.storage.none'); + } + + if (_totalProgressMax.upload) { + remaining = Math.round( + (_totalProgressMax.upload - _totalProgress.upload) / 1024 + ); + var uploadStatus = + Zotero.getString('sync.storage.kbRemaining', remaining); + } + else { + var uploadStatus = Zotero.getString('sync.storage.none'); + } + } + + 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(); + var doc = win.document; + + // + // TODO: Move to overlay.js? + // + var meter = doc.getElementById("zotero-tb-syncProgress"); + + if (totalRequests == 0) { + meter.hidden = true; + continue; + } + + meter.setAttribute("value", percentage); + meter.hidden = false; + + var tooltip = doc. + getElementById("zotero-tb-syncProgress-tooltip-progress"); + tooltip.setAttribute("value", percentage + "%"); + + var tooltip = doc. + getElementById("zotero-tb-syncProgress-tooltip-downloads"); + tooltip.setAttribute("value", downloadStatus); + + var tooltip = doc. + getElementById("zotero-tb-syncProgress-tooltip-uploads"); + tooltip.setAttribute("value", uploadStatus); + } + } + + + function _getRequestInfo(request) { + request.QueryInterface(Components.interfaces.nsIHttpChannel); + switch (request.requestMethod) { + case 'GET': + var queue = 'download'; + break; + + case 'POST': + case 'PUT': + var queue = 'upload'; + break; + + default: + _error("Unsupported method '" + request.requestMethod + + "' in Zotero.Sync.Storage._updateProgress()") + } + + return { + queue: queue, + name: request.name + }; + } + + + + // + // + // + function _error(e) { + _syncInProgress = false; + Zotero.DB.rollbackAllTransactions(); + + Zotero.Sync.Server.setSyncIcon('error'); + + if (e.name) { + Zotero.Sync.Server.lastSyncError = e.name; + } + else { + Zotero.Sync.Server.lastSyncError = e; + } + Zotero.debug(e, 1); + Zotero.Sync.Runner.reset(); + throw(e); + } +} + + +Zotero.Sync.Storage.ZipWriterObserver = function (zipWriter, callback, data) { + this._zipWriter = zipWriter; + this._callback = callback; + this._data = data; +} + +Zotero.Sync.Storage.ZipWriterObserver.prototype = { + onStartRequest: function () {}, + + onStopRequest: function(req, context, status) { + var zipFileName = this._zipWriter.file.leafName; + + var originalSize = 0; + for each(var fileName in this._data.files) { + var entry = this._zipWriter.getEntry(fileName); + originalSize += entry.realSize; + } + delete this._data.files; + + this._zipWriter.close(); + + Zotero.debug("Zip of " + zipFileName + " finished with status " + status + + " (original " + Math.round(originalSize / 1024) + "KB, " + + "compressed " + Math.round(this._zipWriter.file.fileSize / 1024) + "KB, " + + Math.round( + ((originalSize - this._zipWriter.file.fileSize) / originalSize) * 100 + ) + "% reduction)"); + + Zotero.Sync.Storage.compressionTracker.compressed += this._zipWriter.file.fileSize; + Zotero.Sync.Storage.compressionTracker.uncompressed += originalSize; + Zotero.debug("Ratio so far: " + Zotero.Sync.Storage.compressionTracker.ratio); + + var item = Zotero.Items.get(this._data.itemID); + Zotero.debug("Original sum " + Zotero.Attachments.getTotalFileSize(item)); + this._callback(this._data); + } +} + + +/** + * Possible properties of data object: + * - onStart f(request) + * - onProgress f(name, progess, progressMax) + * - onStop f(request, status, response, data) + * - 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) { + //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'); + + if (status != 0) { + throw ("Request status is " + status + + " in Zotero.Sync.Storage.StreamListener.onStopRequest()"); + } + + this._onDone(request, status); + }, + + // 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('onRedirect'); + + // if redirecting, store the new channel + this._channel = newChannel; + }, + + // nsIHttpEventSink + onRedirect: function (oldChannel, newChannel) { + Zotero.debug('onRedirect'); + }, + + + // + // Private methods + // + _onStart: function (request) { + //Zotero.debug('Starting request'); + if (this._data && this._data.onStart) { + this._data.onStart(request); + } + }, + + _onProgress: function (request, progress, progressMax) { + if (this._data && this._data.onProgress) { + this._data.onProgress(request, progress, progressMax); + } + }, + + _onDone: function (request, status) { + if (request instanceof Components.interfaces.nsIHttpChannel) { + request.QueryInterface(Components.interfaces.nsIHttpChannel); + status = request.responseStatus; + request.QueryInterface(Components.interfaces.nsIRequest); + } + + if (this._data.onStop) { + // Remove callbacks before passing along + var passData = {}; + for (var i in this._data) { + switch (i) { + case "onStart": + case "onProgress": + case "onStop": + continue; + } + passData[i] = this._data[i]; + } + this._data.onStop(request, status, this._response, passData); + } + + this._channel = null; + }, + + + // 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/sync.js b/chrome/content/zotero/xpcom/sync.js @@ -241,7 +241,7 @@ Zotero.Sync = new function() { /** - * Notifier observer to add deleted objects to syncDeleteLog + * Notifier observer to add deleted objects to syncDeleteLog/storageDeleteLog * plus related methods */ Zotero.Sync.EventListener = new function () { @@ -313,17 +313,26 @@ Zotero.Sync.EventListener = new function () { return; } + var isItem = Zotero.Sync.getObjectTypeName(objectTypeID) == 'item'; + var ZU = new Zotero.Utilities; Zotero.DB.beginTransaction(); if (event == 'delete') { var sql = "INSERT INTO syncDeleteLog VALUES (?, ?, ?, ?)"; - var statement = Zotero.DB.getStatement(sql); + var syncStatement = Zotero.DB.getStatement(sql); + + if (isItem && Zotero.Sync.Storage.active) { + var storageEnabled = true; + var sql = "INSERT INTO storageDeleteLog VALUES (?, ?)"; + var storageStatement = Zotero.DB.getStatement(sql); + } + var storageBound = false; var ts = Zotero.Date.getUnixTimestamp(); - for(var i=0, len=ids.length; i<len; i++) { + for (var i=0, len=ids.length; i<len; i++) { if (_deleteBlacklist[ids[i]]) { Zotero.debug("Not logging blacklisted '" + type + "' id " + ids[i] @@ -331,24 +340,51 @@ Zotero.Sync.EventListener = new function () { continue; } - var key = extraData[ids[i]].old.primary.key; + var oldItem = extraData[ids[i]].old; + var key = oldItem.primary.key; - statement.bindInt32Parameter(0, objectTypeID); - statement.bindInt32Parameter(1, ids[i]); - statement.bindStringParameter(2, key); - statement.bindInt32Parameter(3, ts); + if (!key) { + throw("Key not provided in notifier object in " + + "Zotero.Sync.EventListener.notify()"); + } + + syncStatement.bindInt32Parameter(0, objectTypeID); + syncStatement.bindInt32Parameter(1, ids[i]); + syncStatement.bindStringParameter(2, key); + syncStatement.bindInt32Parameter(3, ts); + + if (storageEnabled && + oldItem.primary.itemType == 'attachment' && + [ + Zotero.Attachments.LINK_MODE_IMPORTED_FILE, + Zotero.Attachments.LINK_MODE_IMPORTED_URL + ].indexOf(oldItem.attachment.linkMode) != -1) { + storageStatement.bindStringParameter(0, key); + storageStatement.bindInt32Parameter(1, ts); + storageBound = true; + } try { - statement.execute(); + syncStatement.execute(); + if (storageBound) { + storageStatement.execute(); + storageBound = false; + } } catch(e) { - statement.reset(); + syncStatement.reset(); + if (storageEnabled) { + storageStatement.reset(); + } Zotero.DB.rollbackTransaction(); throw(Zotero.DB.getLastErrorString()); } } - statement.reset(); + syncStatement.reset(); + if (storageEnabled) { + storageStatement.reset(); + } } Zotero.DB.commitTransaction(); @@ -374,12 +410,154 @@ Zotero.Sync.EventListener = new function () { } +Zotero.Sync.Runner = new function () { + this.__defineGetter__("lastSyncError", function () { + return _lastSyncError; + }); + this.__defineSetter__("lastSyncError", function (val) { + _lastSyncError = val ? val : ''; + }); + + var _lastSyncError; + var _autoSyncTimer; + var _queue; + var _running; + + this.init = function () { + this.EventListener.init(); + } + + + this.sync = function () { + if (_running) { + throw ("Sync already running in Zotero.Sync.Runner.sync()"); + } + _queue = [ + Zotero.Sync.Storage.sync, + Zotero.Sync.Server.sync, + Zotero.Sync.Storage.sync + ]; + _running = true; + this.clearSyncTimeout(); + this.setSyncIcon('animate'); + this.next(); + } + + + this.next = function () { + if (!_queue.length) { + this.setSyncIcon(); + _running = false; + return; + } + var func = _queue.shift(); + func(); + } + + + this.reset = function () { + _queue = []; + } + + + this.setSyncTimeout = function () { + // check if server/auto-sync are enabled + + var autoSyncTimeout = 15; + Zotero.debug('Setting auto-sync timeout to ' + autoSyncTimeout + ' seconds'); + + if (_autoSyncTimer) { + _autoSyncTimer.cancel(); + } + else { + _autoSyncTimer = Components.classes["@mozilla.org/timer;1"]. + createInstance(Components.interfaces.nsITimer); + } + + // {} implements nsITimerCallback + _autoSyncTimer.initWithCallback({ notify: function (event, type, ids) { + if (event == 'refresh') { + return; + } + + if (Zotero.Sync.Storage.syncInProgress) { + Zotero.debug('Storage sync already in progress -- skipping auto-sync', 4); + return; + } + + if (Zotero.Sync.Server.syncInProgress) { + Zotero.debug('Sync already in progress -- skipping auto-sync', 4); + return; + } + Zotero.Sync.Runner.sync(); + }}, autoSyncTimeout * 1000, Components.interfaces.nsITimer.TYPE_ONE_SHOT); + } + + + this.clearSyncTimeout = function () { + if (_autoSyncTimer) { + _autoSyncTimer.cancel(); + } + } + + + this.setSyncIcon = function (status) { + status = status ? status : ''; + + switch (status) { + case '': + case 'animate': + case 'error': + break; + + default: + throw ("Invalid sync icon status '" + status + + "' in Zotero.Sync.Runner.setSyncIcon()"); + } + + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var win = wm.getMostRecentWindow('navigator:browser'); + var icon = win.document.getElementById('zotero-tb-sync'); + icon.setAttribute('status', status); + + switch (status) { + case 'animate': + icon.setAttribute('disabled', true); + break; + + default: + icon.setAttribute('disabled', false); + } + } +} + + +Zotero.Sync.Runner.EventListener = { + init: function () { + Zotero.Notifier.registerObserver(this); + }, + + notify: function (event, type, ids, extraData) { + // TODO: skip others + if (type == 'refresh') { + return; + } + + if (Zotero.Prefs.get('sync.autoSync') && Zotero.Sync.Server.enabled + && !Zotero.Sync.Server.syncInProgress + && !Zotero.Sync.Storage.syncInProgress) { + Zotero.Sync.Runner.setSyncTimeout(); + } + } +} + + /** * Methods for syncing with the Zotero Server */ Zotero.Sync.Server = new function () { - this.init = init; this.login = login; this.sync = sync; this.lock = lock; @@ -388,14 +566,12 @@ Zotero.Sync.Server = new function () { this.resetServer = resetServer; this.resetClient = resetClient; this.logout = logout; - this.setSyncTimeout = setSyncTimeout; - this.clearSyncTimeout = clearSyncTimeout; - this.setSyncIcon = setSyncIcon; this.__defineGetter__('enabled', function () { // Set auto-sync expiry - var expiry = new Date("September 1, 2008 00:00:00"); + var expiry = new Date("November 1, 2008 00:00:00"); if (new Date() > expiry) { + Components.utils.reportError("Build has expired -- syncing disabled"); return false; } @@ -469,6 +645,7 @@ Zotero.Sync.Server = new function () { } }); + this.__defineGetter__("syncInProgress", function () _syncInProgress); this.__defineGetter__("sessionIDComponent", function () { return 'sessionid=' + _sessionID; }); @@ -484,12 +661,6 @@ Zotero.Sync.Server = new function () { this.__defineSetter__("lastLocalSyncTime", function (val) { Zotero.DB.query("REPLACE INTO version VALUES ('lastlocalsync', ?)", { int: val }); }); - this.__defineGetter__("lastSyncError", function () { - return _lastSyncError; - }); - this.__defineSetter__("lastSyncError", function (val) { - _lastSyncError = val ? val : ''; - }); this.nextLocalSyncDate = false; this.apiVersion = 2; @@ -508,13 +679,6 @@ Zotero.Sync.Server = new function () { var _syncInProgress; var _sessionID; var _sessionLock; - var _lastSyncError; - var _autoSyncTimer; - - - function init() { - this.EventListener.init(); - } function login(callback) { @@ -572,8 +736,7 @@ Zotero.Sync.Server = new function () { function sync() { - Zotero.Sync.Server.clearSyncTimeout(); - Zotero.Sync.Server.setSyncIcon('animate'); + Zotero.Sync.Runner.setSyncIcon('animate'); if (_attempts < 0) { _error('Too many attempts in Zotero.Sync.Server.sync()'); @@ -594,6 +757,7 @@ Zotero.Sync.Server = new function () { _error("Sync operation already in progress"); } + Zotero.debug("Beginning server sync"); _syncInProgress = true; // Get updated data @@ -682,8 +846,10 @@ Zotero.Sync.Server = new function () { Zotero.Sync.Server.lastLocalSyncTime = nextLocalSyncTime; Zotero.Sync.Server.nextLocalSyncDate = false; Zotero.DB.commitTransaction(); - Zotero.Sync.Server.unlock(); - _syncInProgress = false; + Zotero.Sync.Server.unlock(function () { + _syncInProgress = false; + Zotero.Sync.Runner.next(); + }); return; } @@ -722,8 +888,10 @@ Zotero.Sync.Server = new function () { //throw('break2'); Zotero.DB.commitTransaction(); - Zotero.Sync.Server.unlock(); - _syncInProgress = false; + Zotero.Sync.Server.unlock(function () { + _syncInProgress = false; + Zotero.Sync.Runner.next(); + }); } var compress = Zotero.Prefs.get('sync.server.compressData'); @@ -894,12 +1062,6 @@ Zotero.Sync.Server = new function () { if (callback) { callback(); } - - // Reset sync icon and last error - if (syncInProgress) { - Zotero.Sync.Server.lastSyncError = ''; - Zotero.Sync.Server.setSyncIcon(); - } }); } @@ -1001,6 +1163,7 @@ Zotero.Sync.Server = new function () { Zotero.DB.query(sql); Zotero.DB.query("DELETE FROM syncDeleteLog"); + Zotero.DB.query("DELETE FROM storageDeleteLog"); sql = "INSERT INTO version VALUES ('syncdeletelog', ?)"; Zotero.DB.query(sql, Zotero.Date.getUnixTimestamp()); @@ -1037,61 +1200,6 @@ Zotero.Sync.Server = new function () { } - function setSyncTimeout() { - // check if server/auto-sync are enabled - - var autoSyncTimeout = 15; - Zotero.debug('Setting auto-sync timeout to ' + autoSyncTimeout + ' seconds'); - - if (_autoSyncTimer) { - _autoSyncTimer.cancel(); - } - else { - _autoSyncTimer = Components.classes["@mozilla.org/timer;1"]. - createInstance(Components.interfaces.nsITimer); - } - - // {} implements nsITimerCallback - _autoSyncTimer.initWithCallback({ notify: function (event, type, ids) { - if (event == 'refresh') { - return; - } - if (_syncInProgress) { - Zotero.debug('Sync already in progress -- skipping auto-sync'); - return; - } - Zotero.Sync.Server.sync(); - }}, autoSyncTimeout * 1000, Components.interfaces.nsITimer.TYPE_ONE_SHOT); - } - - - function clearSyncTimeout() { - if (_autoSyncTimer) { - _autoSyncTimer.cancel(); - } - } - - - function setSyncIcon(status) { - status = status ? status : ''; - - switch (status) { - case '': - case 'animate': - case 'error': - break; - - default: - throw ("Invalid sync icon status '" + status + "' in Zotero.Sync.Server.setSyncIcon()"); - } - - var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] - .getService(Components.interfaces.nsIWindowMediator); - var win = wm.getMostRecentWindow('navigator:browser'); - win.document.getElementById('zotero-tb-sync').setAttribute('status', status); - } - - function _checkResponse(xmlhttp) { if (!xmlhttp.responseXML || !xmlhttp.responseXML.childNodes[0] || @@ -1130,14 +1238,15 @@ Zotero.Sync.Server = new function () { Zotero.Sync.Server.unlock() } - Zotero.Sync.Server.setSyncIcon('error'); - + Zotero.Sync.Runner.setSyncIcon('error'); if (e.name) { - Zotero.Sync.Server.lastSyncError = e.name; + Zotero.Sync.Runner.lastSyncError = e.name; } else { - Zotero.Sync.Server.lastSyncError = e; + Zotero.Sync.Runner.lastSyncError = e; } + Zotero.debug(e, 1); + Zotero.Sync.Runner.reset(); throw(e); } } @@ -1182,26 +1291,6 @@ Zotero.BufferedInputListener.prototype = { } -// TODO: use prototype -Zotero.Sync.Server.EventListener = { - init: function () { - Zotero.Notifier.registerObserver(this); - }, - - notify: function (event, type, ids, extraData) { - // TODO: skip others - if (type == 'refresh') { - return; - } - - if (Zotero.Prefs.get('sync.server.autoSync') && Zotero.Sync.Server.enabled) { - Zotero.Sync.Server.setSyncTimeout(); - } - } -} - - - Zotero.Sync.Server.Data = new function() { this.processUpdatedXML = processUpdatedXML; this.buildUploadXML = buildUploadXML; @@ -1299,6 +1388,7 @@ Zotero.Sync.Server.Data = new function() { var remoteCreatorStore = {}; var relatedItemsStore = {}; + var itemStorageModTimes = {}; Zotero.DB.beginTransaction(); @@ -1527,10 +1617,10 @@ Zotero.Sync.Server.Data = new function() { // Create or overwrite locally obj = Zotero.Sync.Server.Data['xmlTo' + Type](xmlNode, obj); - // If a local tag matches the name of a different remote tag, - // delete the local tag and add items linked to it to the - // matching remote tag if (isNewObject && type == 'tag') { + // If a local tag matches the name of a different remote tag, + // delete the local tag and add items linked to it to the + // matching remote tag var tagName = xmlNode.@name.toString(); var tagType = xmlNode.@type.toString() ? parseInt(xmlNode.@type) : 0; @@ -1562,6 +1652,25 @@ Zotero.Sync.Server.Data = new function() { // Don't use assigned-but-unsaved ids for new ids Zotero.ID.skip(types, obj.id); + + if (type == 'item' && obj.isAttachment() && + (obj.attachmentLinkMode == + Zotero.Attachments.LINK_MODE_IMPORTED_FILE || + obj.attachmentLinkMode == + Zotero.Attachments.LINK_MODE_IMPORTED_URL)) { + // Mark new attachments for download + if (isNewObject) { + obj.attachmentSyncState = + Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD; + } + // Set existing attachments mtime update check + else { + var mtime = xmlNode.@storageModTime.toString(); + if (mtime) { + itemStorageModTimes[obj.id] = parseInt(mtime); + } + } + } } @@ -1738,6 +1847,19 @@ Zotero.Sync.Server.Data = new function() { Zotero[Types].erase(toDeleteParents); Zotero.Sync.EventListener.unignoreDeletions(type, toDeleteParents); } + + + // Check mod times of updated items against stored time to see + // if they've been updated elsewhere and mark for download if so + if (type == 'item') { + var ids = []; + for (var id in itemStorageModTimes) { + ids.push(id); + } + if (ids.length > 0) { + Zotero.Sync.Storage.checkForUpdatedFiles(ids, itemStorageModTimes); + } + } } var xmlstr = Zotero.Sync.Server.Data.buildUploadXML(uploadIDs); @@ -1888,10 +2010,18 @@ Zotero.Sync.Server.Data = new function() { if (item.primary.itemType == 'attachment') { xml.@linkMode = item.attachment.linkMode; xml.@mimeType = item.attachment.mimeType; - xml.@charset = item.attachment.charset; + var charset = item.attachment.charset; + if (charset) { + xml.@charset = charset; + } - // Don't include paths for links + // Include storage sync time and paths for non-links if (item.attachment.linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) { + var mtime = Zotero.Sync.Storage.getSyncedModificationTime(item.primary.itemID); + if (mtime) { + xml.@storageModTime = mtime; + } + var path = <path>{item.attachment.path}</path>; xml.path += path; } @@ -2018,8 +2148,8 @@ Zotero.Sync.Server.Data = new function() { // Attachment metadata if (item.isAttachment()) { item.attachmentLinkMode = parseInt(xmlItem.@linkMode); - item.attachmentMIMEType = xmlItem.@mimeType; - item.attachmentCharset = parseInt(xmlItem.@charsetID); + item.attachmentMIMEType = xmlItem.@mimeType.toString(); + item.attachmentCharset = xmlItem.@charset.toString(); if (item.attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) { item.attachmentPath = xmlItem.path.toString(); } diff --git a/chrome/content/zotero/xpcom/utilities.js b/chrome/content/zotero/xpcom/utilities.js @@ -297,6 +297,31 @@ Zotero.Utilities.prototype.isInt = function(x) { /** + * Generate a random integer between min and max inclusive + * + * @param {Integer} min + * @param {Integer} max + * @return {Integer} + */ +Zotero.Utilities.prototype.rand = function (min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + + +/** + * Return true according to a given probability + * + * @param {Integer} x Will return true every x times on average + * @return {Boolean} On average, TRUE every x times + * the function is called + */ +Zotero.Utilities.prototype.probability = function (x) { + return this.rand(1, x) == this.rand(1, x); +} + + + +/** * Determine the necessary data type for SQLite parameter binding * * @return int 0 for string, 32 for int32, 64 for int64 @@ -643,9 +668,9 @@ Zotero.Utilities.HTTP = new function() { this.doGet = doGet; this.doPost = doPost; this.doHead = doHead; - this.doOptions = doOptions; this.browserIsOffline = browserIsOffline; + this.WebDAV = {}; /** * Send an HTTP GET request via XMLHTTPRequest @@ -665,8 +690,9 @@ Zotero.Utilities.HTTP = new function() { var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] .createInstance(); - - var test = xmlhttp.open('GET', url, true); + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; + xmlhttp.open('GET', url, true); xmlhttp.onreadystatechange = function(){ _stateChange(xmlhttp, onDone, responseCharset); @@ -716,7 +742,8 @@ Zotero.Utilities.HTTP = new function() { var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] .createInstance(); - + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; xmlhttp.open('POST', url, true); xmlhttp.setRequestHeader("Content-Type", (requestContentType ? requestContentType : "application/x-www-form-urlencoded" )); @@ -750,8 +777,9 @@ Zotero.Utilities.HTTP = new function() { var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] .createInstance(); - - var test = xmlhttp.open('HEAD', url, true); + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; + xmlhttp.open('HEAD', url, true); xmlhttp.onreadystatechange = function(){ _stateChange(xmlhttp, onDone); @@ -776,26 +804,30 @@ Zotero.Utilities.HTTP = new function() { /** - * Send an HTTP OPTIONS request via XMLHTTPRequest - * - * doOptions can be called as: - * Zotero.Utilities.HTTP.doOptions(url, body, onDone) - * - * Returns the XMLHTTPRequest object - **/ - function doOptions(url, body, onDone) { - Zotero.debug("HTTP OPTIONS "+url); - if (this.browserIsOffline()){ + * Send an HTTP OPTIONS request via XMLHTTPRequest + * + * @param {nsIURI} url + * @param {Function} onDone + * @return {XMLHTTPRequest} + */ + this.doOptions = function (uri, callback) { + // Don't display password in console + var disp = uri.clone(); + disp.password = "********"; + Zotero.debug("HTTP OPTIONS to " + disp.spec); + + if (Zotero.Utilities.HTTP.browserIsOffline()){ return false; } var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] .createInstance(); + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; + xmlhttp.open('OPTIONS', uri.spec, true); - xmlhttp.open('OPTIONS', url, true); - - xmlhttp.onreadystatechange = function(){ - _stateChange(xmlhttp, onDone); + xmlhttp.onreadystatechange = function() { + _stateChange(xmlhttp, callback); }; // Temporarily set cookieBehavior to 0 for Firefox 3 @@ -806,7 +838,7 @@ Zotero.Utilities.HTTP = new function() { var cookieBehavior = prefService.getIntPref("network.cookie.cookieBehavior"); prefService.setIntPref("network.cookie.cookieBehavior", 0); - xmlhttp.send(body); + xmlhttp.send(null); } finally { prefService.setIntPref("network.cookie.cookieBehavior", cookieBehavior); @@ -816,36 +848,231 @@ Zotero.Utilities.HTTP = new function() { } + // + // WebDAV methods + // + + + /** + * Send a WebDAV PROP* request via XMLHTTPRequest + * + * Returns false if browser is offline + * + * @param {String} method PROPFIND or PROPPATCH + * @param {nsIURI} uri + * @param {String} body XML string + * @param {Function} callback + * @param {Object} requestHeaders e.g. { Depth: 0 } + */ + this.WebDAV.doProp = function (method, uri, body, callback, requestHeaders) { + switch (method) { + case 'PROPFIND': + case 'PROPPATCH': + break; + + default: + throw ("Invalid method '" + method + + "' in Zotero.Utilities.HTTP.doProp"); + } + + if (requestHeaders && requestHeaders.depth != undefined) { + var depth = requestHeaders.depth; + } + + // Don't display password in console + var disp = uri.clone(); + disp.password = "********"; + + var bodyStart = body.substr(0, 1024); + Zotero.debug("HTTP " + method + " " + + (depth != undefined ? "(depth " + depth + ") " : "") + + (body.length > 1024 ? + bodyStart + "... (" + body.length + " chars)" : bodyStart) + + " to " + disp.spec); + + if (Zotero.Utilities.HTTP.browserIsOffline()) { + Zotero.debug("Browser is offline", 2); + return false; + } + + var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(); + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; + xmlhttp.open(method, uri.spec, true); + + if (requestHeaders) { + for (var header in requestHeaders) { + xmlhttp.setRequestHeader(header, requestHeaders[header]); + } + } + + xmlhttp.setRequestHeader("Content-Type", 'text/xml; charset="utf-8"'); + + xmlhttp.onreadystatechange = function() { + _stateChange(xmlhttp, callback); + }; + + xmlhttp.send(body); + + return xmlhttp; + } + + + /** + * Send a WebDAV MKCOL request via XMLHTTPRequest + * + * @param {nsIURI} url + * @param {Function} onDone + * @return {XMLHTTPRequest} + */ + this.WebDAV.doMkCol = function (uri, callback) { + // Don't display password in console + var disp = uri.clone(); + disp.password = "********"; + Zotero.debug("HTTP MKCOL to " + disp.spec); + + if (Zotero.Utilities.HTTP.browserIsOffline()) { + return false; + } + + var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(); + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; + xmlhttp.open('MKCOL', uri.spec, true); + xmlhttp.onreadystatechange = function() { + _stateChange(xmlhttp, callback); + }; + xmlhttp.send(null); + return xmlhttp; + } + + + /** + * Send a WebDAV PUT request via XMLHTTPRequest + * + * @param {nsIURI} url + * @param {String} body String body to PUT + * @param {Function} onDone + * @return {XMLHTTPRequest} + */ + this.WebDAV.doPut = function (uri, body, callback) { + // Don't display password in console + var disp = uri.clone(); + disp.password = "********"; + + var bodyStart = "'" + body.substr(0, 1024) + "'"; + Zotero.debug("HTTP PUT " + + (body.length > 1024 ? + bodyStart + "... (" + body.length + " chars)" : bodyStart) + + " to " + disp.spec); + + if (Zotero.Utilities.HTTP.browserIsOffline()) { + return false; + } + + var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(); + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; + xmlhttp.open("PUT", uri.spec, true); + xmlhttp.onreadystatechange = function() { + _stateChange(xmlhttp, callback); + }; + xmlhttp.send(body); + return xmlhttp; + } + + + /** + * Send a WebDAV PUT request via XMLHTTPRequest + * + * @param {nsIURI} url + * @param {Function} onDone + * @return {XMLHTTPRequest} + */ + this.WebDAV.doDelete = function (uri, callback) { + // Don't display password in console + var disp = uri.clone(); + disp.password = "********"; + + Zotero.debug("WebDAV DELETE to " + disp.spec); + + if (Zotero.Utilities.HTTP.browserIsOffline()) { + return false; + } + + var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(); + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; + xmlhttp.open("DELETE", uri.spec, true); + xmlhttp.onreadystatechange = function() { + _stateChange(xmlhttp, callback); + }; + xmlhttp.send(null); + return xmlhttp; + } + + + /** + * Get the Authorization header used by a channel + * + * As of Firefox 3.0.1 subsequent requests to higher-level directories + * seem not to authenticate properly and just return 401s, so this + * can be used to manually include the Authorization header in a request + * + * It can also be used to check whether a request was forced to + * use authentication + * + * @param {nsIChannel} channel + * @return {String|FALSE} Authorization header, or FALSE if none + */ + this.getChannelAuthorization = function (channel) { + try { + channel.QueryInterface(Components.interfaces.nsIHttpChannel); + var authHeader = channel.getRequestHeader("Authorization"); + return authHeader; + } + catch (e) { + Zotero.debug(e); + return false; + } + } + + function browserIsOffline() { return Components.classes["@mozilla.org/network/io-service;1"] .getService(Components.interfaces.nsIIOService).offline; } - function _stateChange(xmlhttp, onDone, responseCharset){ + function _stateChange(xmlhttp, callback, responseCharset, data) { switch (xmlhttp.readyState){ // Request not yet made case 1: - break; - - // Called multiple while downloading in progress + break; + + case 2: + break; + + // Called multiple times while downloading in progress case 3: - break; - + break; + // Download complete case 4: - if(onDone){ + if (callback) { // Override the content charset if (responseCharset) { xmlhttp.channel.contentCharset = responseCharset; } - onDone(xmlhttp); + callback(xmlhttp, data); } break; } } - - } // Downloads and processes documents with processor() @@ -952,4 +1179,143 @@ Zotero.Utilities.AutoComplete = new function(){ } return false; } -} -\ No newline at end of file +} + + +/** + * Base64 encode / decode + * From http://www.webtoolkit.info/ + */ +Zotero.Utilities.Base64 = { + // private property + _keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", + + // public method for encoding + encode : function (input) { + var output = ""; + var chr1, chr2, chr3, enc1, enc2, enc3, enc4; + var i = 0; + + input = this._utf8_encode(input); + + while (i < input.length) { + + chr1 = input.charCodeAt(i++); + chr2 = input.charCodeAt(i++); + chr3 = input.charCodeAt(i++); + + enc1 = chr1 >> 2; + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); + enc4 = chr3 & 63; + + if (isNaN(chr2)) { + enc3 = enc4 = 64; + } else if (isNaN(chr3)) { + enc4 = 64; + } + + output = output + + this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) + + this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4); + + } + + return output; + }, + + // public method for decoding + decode : function (input) { + var output = ""; + var chr1, chr2, chr3; + var enc1, enc2, enc3, enc4; + var i = 0; + + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); + + while (i < input.length) { + + enc1 = this._keyStr.indexOf(input.charAt(i++)); + enc2 = this._keyStr.indexOf(input.charAt(i++)); + enc3 = this._keyStr.indexOf(input.charAt(i++)); + enc4 = this._keyStr.indexOf(input.charAt(i++)); + + chr1 = (enc1 << 2) | (enc2 >> 4); + chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + chr3 = ((enc3 & 3) << 6) | enc4; + + output = output + String.fromCharCode(chr1); + + if (enc3 != 64) { + output = output + String.fromCharCode(chr2); + } + if (enc4 != 64) { + output = output + String.fromCharCode(chr3); + } + + } + + output = this._utf8_decode(output); + + return output; + + }, + + // private method for UTF-8 encoding + _utf8_encode : function (string) { + string = string.replace(/\r\n/g,"\n"); + var utftext = ""; + + for (var n = 0; n < string.length; n++) { + + var c = string.charCodeAt(n); + + if (c < 128) { + utftext += String.fromCharCode(c); + } + else if((c > 127) && (c < 2048)) { + utftext += String.fromCharCode((c >> 6) | 192); + utftext += String.fromCharCode((c & 63) | 128); + } + else { + utftext += String.fromCharCode((c >> 12) | 224); + utftext += String.fromCharCode(((c >> 6) & 63) | 128); + utftext += String.fromCharCode((c & 63) | 128); + } + + } + + return utftext; + }, + + // private method for UTF-8 decoding + _utf8_decode : function (utftext) { + var string = ""; + var i = 0; + var c = c1 = c2 = 0; + + while ( i < utftext.length ) { + + c = utftext.charCodeAt(i); + + if (c < 128) { + string += String.fromCharCode(c); + i++; + } + else if((c > 191) && (c < 224)) { + c2 = utftext.charCodeAt(i+1); + string += String.fromCharCode(((c & 31) << 6) | (c2 & 63)); + i += 2; + } + else { + c2 = utftext.charCodeAt(i+1); + c3 = utftext.charCodeAt(i+2); + string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63)); + i += 3; + } + + } + + return string; + } + } +\ No newline at end of file diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js @@ -41,6 +41,7 @@ var Zotero = new function(){ this.getZoteroDirectory = getZoteroDirectory; this.getStorageDirectory = getStorageDirectory; this.getZoteroDatabase = getZoteroDatabase; + this.getTempDirectory = getTempDirectory; this.chooseZoteroDirectory = chooseZoteroDirectory; this.debug = debug; this.log = log; @@ -249,6 +250,9 @@ var Zotero = new function(){ if (typeof e == 'string' && e.match('newer than SQL file')) { _startupError = e; } + else { + _startupError = "Database upgrade error"; + } Components.utils.reportError(_startupError); return false; } @@ -265,7 +269,8 @@ var Zotero = new function(){ Zotero.Zeroconf.init(); Zotero.Sync.init(); - Zotero.Sync.Server.init(); + Zotero.Sync.Runner.init(); + Zotero.Sync.Storage.init(); this.initialized = true; @@ -357,6 +362,22 @@ var Zotero = new function(){ } + /** + * @return {nsIFile} + */ + function getTempDirectory() { + var tmp = this.getZoteroDirectory(); + tmp.append('tmp'); + if (!tmp.exists() || !tmp.isDirectory()) { + if (tmp.exists() && !tmp.isDirectory()) { + tmp.remove(null); + } + tmp.create(Components.interfaces.nsIFile.DIRECTORY_TYPE, 0755); + } + return tmp; + } + + function chooseZoteroDirectory(forceRestartNow, useProfileDir) { var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] .getService(Components.interfaces.nsIWindowMediator); @@ -1284,6 +1305,66 @@ Zotero.Date = new function(){ } + /** + * Convert a JS Date object to an ISO 8601 UTC date/time + * + * @param {Date} date JS Date object + * @return {String} ISO 8601 UTC date/time + * e.g. 2008-08-15T20:00:00Z + */ + this.dateToISO = function (date) { + var year = date.getUTCFullYear(); + var month = date.getUTCMonth(); + var day = date.getUTCDate(); + var hours = date.getUTCHours(); + var minutes = date.getUTCMinutes(); + var seconds = date.getUTCSeconds(); + + var utils = new Zotero.Utilities(); + year = utils.lpad(year, '0', 4); + month = utils.lpad(month + 1, '0', 2); + day = utils.lpad(day, '0', 2); + hours = utils.lpad(hours, '0', 2); + minutes = utils.lpad(minutes, '0', 2); + seconds = utils.lpad(seconds, '0', 2); + + return year + '-' + month + '-' + day + 'T' + + hours + ':' + minutes + ':' + seconds + 'Z'; + } + + + /** + * Convert an ISO 8601–formatted UTC date/time to a JS Date + * + * Adapted from http://delete.me.uk/2005/03/iso8601.html (AFL-licensed) + * + * @param {String} isoDate ISO 8601 date + * @return {Date} JS Date + */ + this.isoToDate = function (isoDate) { + var re8601 = /([0-9]{4})(-([0-9]{2})(-([0-9]{2})(T([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]+))?)?(Z|(([-+])([0-9]{2}):([0-9]{2})))?)?)?)?/; + var d = isoDate.match(re8601); + + var offset = 0; + var date = new Date(d[1], 0, 1); + + if (d[3]) { date.setMonth(d[3] - 1); } + if (d[5]) { date.setDate(d[5]); } + if (d[7]) { date.setHours(d[7]); } + if (d[8]) { date.setMinutes(d[8]); } + if (d[10]) { date.setSeconds(d[10]); } + if (d[12]) { date.setMilliseconds(Number("0." + d[12]) * 1000); } + if (d[14]) { + offset = (Number(d[16]) * 60) + Number(d[17]); + offset *= ((d[15] == '-') ? 1 : -1); + } + + offset -= date.getTimezoneOffset(); + var time = (Number(date) + (offset * 60 * 1000)); + return new Date(time); + } + + /* * converts a string to an object containing: * day: integer form of the day @@ -1494,7 +1575,7 @@ Zotero.Date = new function(){ return string; } - function strToISO(str){ + function strToISO(str) { var date = Zotero.Date.strToDate(str); if(date.year) { diff --git a/chrome/locale/en-US/zotero/zotero.dtd b/chrome/locale/en-US/zotero/zotero.dtd @@ -156,8 +156,12 @@ <!ENTITY zotero.integration.references.label "References in Bibliography"> +<!ENTITY zotero.sync.storage.progress "Progress:"> +<!ENTITY zotero.sync.storage.downloads "Downloads:"> +<!ENTITY zotero.sync.storage.uploads "Uploads:"> + <!ENTITY zotero.proxy.recognized.title "Proxy Recognized"> <!ENTITY zotero.proxy.recognized.warning "Only add proxies linked from your library, school, or corporate website"> <!ENTITY zotero.proxy.recognized.warning.secondary "Adding other proxies allows malicious sites to masquerade as sites you trust."> <!ENTITY zotero.proxy.recognized.disable.label "Do not automatically redirect requests through previously recognized proxies"> -<!ENTITY zotero.proxy.recognized.ignore.label "Ignore"> -\ No newline at end of file +<!ENTITY zotero.proxy.recognized.ignore.label "Ignore"> diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties @@ -501,6 +501,9 @@ styles.installed = The style "%S" was installed successfully. styles.installError = %S does not appear to be a valid CSL file. styles.deleteStyle = Are you sure you want to delete the style "%1$S"? +sync.storage.kbRemaining = %SKB remaining +sync.storage.none = None + proxies.multiSite = Multi-Site proxies.error = Information Validation Error proxies.error.scheme.noHTTP = Valid proxy schemes must start with "http://" or "https://" @@ -513,4 +516,4 @@ proxies.enableTransparentWarning.title = Warning proxies.enableTransparentWarning.description = Please ensure that the proxies listed below belong to a library, school, or other institution with which you are affiliated. A malicious proxy could pose a security risk. recognizePDF.couldNotRecognize.title = Could Not Retrieve Metada -recognizePDF.couldNotRecognize.message = Zotero could not retrieve metadata for "%1$S". -\ No newline at end of file +recognizePDF.couldNotRecognize.message = Zotero could not retrieve metadata for "%1$S". diff --git a/chrome/skin/default/zotero/drive_network.png b/chrome/skin/default/zotero/drive_network.png Binary files differ. diff --git a/chrome/skin/default/zotero/overlay.css b/chrome/skin/default/zotero/overlay.css @@ -191,11 +191,28 @@ list-style-image: url('chrome://zotero/skin/toolbar-advanced-search.png'); } +#zotero-tb-syncProgress +{ + min-width: 50px; + width: 50px; + height: 10px; +} + +#zotero-tb-syncProgress-tooltip row label:first-child +{ + text-align: right; + font-weight: bold; +} + +#zotero-tb-storage-sync +{ + list-style-image: url(chrome://zotero/skin/drive_network.png); +} + #zotero-tb-sync { - margin-top: -2px; + list-style-image: url(chrome://zotero/skin/arrow_rotate_static.png); margin-left: -2px; margin-right: -2px; - list-style-image: url(chrome://zotero/skin/arrow_rotate_static.png); } #zotero-tb-sync[status=animate] { diff --git a/chrome/skin/default/zotero/preferences.css b/chrome/skin/default/zotero/preferences.css @@ -3,14 +3,8 @@ prefwindow .chromeclass-toolbar display: -moz-box !important; /* Ignore toolbar collapse button on OS X */ } -/* Prevent bugs in automatic prefpane sizing in Firefox 2.0 - From http://forums.mozillazine.org/viewtopic.php?p=2883233&sid=e1285f81ea9c824363802ea5ca96c9b2 -*/ prefwindow { - width: 45em; -} -prefwindow > prefpane > vbox.content-box { - height: 42em; + min-width: 600px; } radio[pane] @@ -76,6 +70,50 @@ grid row hbox:first-child /* + * Sync pane + */ +#zotero-prefpane-sync row, #zotero-prefpane-sync row hbox +{ + -moz-box-align: center; +} +#zotero-prefpane-sync row label:first-child +{ + text-align: right; +} +#zotero-prefpane-sync row hbox +{ + margin-left: 4px; +} +#zotero-prefpane-sync row hbox label:first-child +{ + margin-left: 0; + margin-right: 0; +} +#zotero-prefpane-sync row hbox textbox +{ + margin-left: 3px; + margin-right: 3px; +} +#zotero-prefpane-sync row hbox label:last-child +{ + margin-left: 0; + margin-right: 10px; +} + +#storage-settings +{ + margin-left: 10px; + margin-right: 5px; +} + +#storage-verify, #storage-abort, #storage-clean +{ + margin-left: 0; + min-width: 8em; +} + + +/* * Search pane */ #pdfinfo-status diff --git a/components/zotero-service.js b/components/zotero-service.js @@ -51,6 +51,7 @@ var xpcomFiles = [ 'schema', 'search', 'sync', + 'storage', 'timeline', 'translate', 'utilities', diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js @@ -82,10 +82,17 @@ pref("extensions.zotero.zeroconf.server.enabled", false); // Annotation settings pref("extensions.zotero.annotations.warnOnClose", true); -// Server -pref("extensions.zotero.sync.server.autoSync", true); +// Sync +pref("extensions.zotero.sync.autoSync", true); pref("extensions.zotero.sync.server.username", ''); pref("extensions.zotero.sync.server.compressData", true); +pref("extensions.zotero.sync.storage.enabled", false); +pref("extensions.zotero.sync.storage.verified", false); +pref("extensions.zotero.sync.storage.url", ''); +pref("extensions.zotero.sync.storage.username", ''); +pref("extensions.zotero.sync.storage.maxDownloads", 4); +pref("extensions.zotero.sync.storage.maxUploads", 4); +pref("extensions.zotero.sync.storage.deleteDelayDays", 30); // Proxy pref("extensions.zotero.proxies.autoRecognize", true); diff --git a/userdata.sql b/userdata.sql @@ -1,4 +1,4 @@ --- 39 +-- 40 -- This file creates tables containing user-specific data -- any changes made -- here must be mirrored in transition steps in schema.js::_migrateSchema() @@ -62,11 +62,14 @@ CREATE TABLE itemAttachments ( charsetID INT, path TEXT, originalPath TEXT, + syncState INT DEFAULT 0, + storageModTime INT, FOREIGN KEY (itemID) REFERENCES items(itemID), FOREIGN KEY (sourceItemID) REFERENCES items(sourceItemID) ); CREATE INDEX itemAttachments_sourceItemID ON itemAttachments(sourceItemID); CREATE INDEX itemAttachments_mimeType ON itemAttachments(mimeType); +CREATE INDEX itemAttachments_syncState ON itemAttachments(syncState); -- Individual entries for each tag CREATE TABLE tags ( @@ -202,6 +205,12 @@ CREATE TABLE syncDeleteLog ( ); CREATE INDEX syncDeleteLog_timestamp ON syncDeleteLog(timestamp); +CREATE TABLE storageDeleteLog ( + key TEXT PRIMARY KEY, + timestamp INT NOT NULL +); +CREATE INDEX storageDeleteLog_timestamp ON storageDeleteLog(timestamp); + CREATE TABLE translators ( translatorID TEXT PRIMARY KEY, minVersion TEXT,