www

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

commit 884e5474fec96324026e5e8409cb4ce664587004
parent a611706f869cf1458aa21486cf266cb4f28d0816
Author: Dan Stillman <dstillman@zotero.org>
Date:   Sun, 13 Sep 2009 07:23:29 +0000

Zotero File Storage megacommit

- Group file sync via Zotero File Storage
- Split file syncing into separate modules for ZFS and WebDAV
- Dragging items between libraries copies child notes, snapshots/files, and links based on checkboxes for each (enabled by default) in the Zotero preferences
- Sync errors now trigger an exclamation/error icon separate from the sync icon, with a popup window displaying the error and an option to report it
- Various errors that could cause perpetual sync icon spinning now stop the sync properly
- Zotero.Utilities.md5(str) is now md5(strOrFile, base64)
- doPost(), doHead(), and retrieveSource() now takes a headers parameter instead of requestContentType
- doHead() can now accept an nsIURI (with login credentials), is a background request, and isn't cached
- When library access or file writing access is denied during sync, display a warning and then reset local group to server version
- Perform additional steps (e.g., removing local groups) when switching sync users to prevent errors
- Compare hash as well as mod time when checking for modified local files
- Don't trigger notifications when removing groups from the client
- Clear relation links to items in removed groups
- Zotero.Item.attachmentHash property to get file MD5
- importFromFile() now takes libraryID as a third parameter
- Zotero.Attachments.getNumFiles() returns the number of files in the attachment directory
- Zotero.Attachments.copyAttachmentToLibrary() copies an attachment item, including files, to another library
- Removed Zotero.File.getFileHash() in favor of updated Zotero.Utilities.md5()
- Zotero.File.copyDirectory(dir, newDir) copies all files from dir into newDir
- Preferences shuffling: OpenURL to Advanced, import/export character set options to Export, "Include URLs of paper articles in references" to Styles
- Other stuff I don't remember

Suffice it to say, this could use testing.



Diffstat:
Mchrome/content/zotero/overlay.js | 55+++++++++++++++++++++++++++++++++++++++++--------------
Mchrome/content/zotero/overlay.xul | 5+++--
Mchrome/content/zotero/preferences/preferences.js | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mchrome/content/zotero/preferences/preferences.xul | 210++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mchrome/content/zotero/xpcom/attachments.js | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mchrome/content/zotero/xpcom/collectionTreeView.js | 156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mchrome/content/zotero/xpcom/data/dataObjects.js | 2+-
Mchrome/content/zotero/xpcom/data/group.js | 11+++++++++++
Mchrome/content/zotero/xpcom/data/item.js | 48++++++++++++++++++++++++++++++++----------------
Mchrome/content/zotero/xpcom/data/items.js | 1-
Mchrome/content/zotero/xpcom/data/relations.js | 39+++++++++++++++++++++++++++++++++++++++
Mchrome/content/zotero/xpcom/db.js | 4+++-
Mchrome/content/zotero/xpcom/error.js | 8+++++++-
Mchrome/content/zotero/xpcom/file.js | 32+++++++++++++-------------------
Mchrome/content/zotero/xpcom/itemTreeView.js | 35+++++++++++++----------------------
Mchrome/content/zotero/xpcom/schema.js | 15+++++++++++++++
Mchrome/content/zotero/xpcom/storage.js | 2163++++++++++++++++++++++---------------------------------------------------------
Achrome/content/zotero/xpcom/storage/session.js | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achrome/content/zotero/xpcom/storage/webdav.js | 1401+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achrome/content/zotero/xpcom/storage/zfs.js | 852+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mchrome/content/zotero/xpcom/sync.js | 608+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mchrome/content/zotero/xpcom/uri.js | 48+++++++++++++++++++++++++++++++++++++++++-------
Mchrome/content/zotero/xpcom/utilities.js | 149++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mchrome/content/zotero/xpcom/zotero.js | 19++++++++++++++++---
Mchrome/locale/en-US/zotero/zotero.properties | 1+
Achrome/skin/default/zotero/error.png | 0
Achrome/skin/default/zotero/exclamation.png | 0
Mchrome/skin/default/zotero/overlay.css | 14++++++++------
Mcomponents/zotero-service.js | 3+++
Mdefaults/preferences/zotero.js | 10++++++++--
Muserdata.sql | 3++-
31 files changed, 4260 insertions(+), 1996 deletions(-)

diff --git a/chrome/content/zotero/overlay.js b/chrome/content/zotero/overlay.js @@ -177,11 +177,25 @@ var ZoteroPane = new function() ); if (index == 0) { - Zotero.Sync.Server.sync(function () { - pr.alert( - "Restore Completed", - "The local Zotero database has been successfully restored." - ); + Zotero.Sync.Server.sync({ + onSuccess: function () { + Zotero.Sync.Runner.setSyncIcon(); + + pr.alert( + "Restore Completed", + "The local Zotero database has been successfully restored." + ); + }, + + onError: function (msg) { + pr.alert( + "Restore Failed", + "An error occurred while restoring from the server:\n\n" + + msg + ); + + Zotero.Sync.Runner.error(msg); + } }); } }, 1000); @@ -898,7 +912,7 @@ var ZoteroPane = new function() if (!tagSelector.getAttribute('collapsed') || tagSelector.getAttribute('collapsed') == 'false') { Zotero.debug('Updating tag selector with current tags'); - if (itemGroup.isEditable()) { + if (itemGroup.editable) { tagSelector.mode = 'edit'; } else { @@ -2281,7 +2295,7 @@ var ZoteroPane = new function() var disabled = Zotero.locked; if (!disabled && self.collectionsView.selection && self.collectionsView.selection.count) { var itemGroup = self.collectionsView._getItemAtRow(self.collectionsView.selection.currentIndex); - disabled = !itemGroup.isEditable() + disabled = !itemGroup.editable; } for each(var menuitem in menu.firstChild.childNodes) { menuitem.disabled = disabled; @@ -2837,7 +2851,26 @@ var ZoteroPane = new function() } var itemGroup = this.collectionsView._getItemAtRow(row); - return itemGroup.isEditable(); + return itemGroup.editable; + } + + + /** + * Test if the user can edit the currently selected library/collection, + * and display an error if not + * + * @param {Integer} [row] + * + * @return {Boolean} TRUE if user can edit, FALSE if not + */ + this.canEditFiles = function (row) { + // Currently selected row + if (row === undefined) { + row = this.collectionsView.selection.currentIndex; + } + + var itemGroup = this.collectionsView._getItemAtRow(row); + return itemGroup.filesEditable; } @@ -2999,12 +3032,6 @@ var ZoteroPane = new function() this.setLastSyncStatus = function (tooltip) { var label = tooltip.firstChild.nextSibling; - var msg = Zotero.Sync.Runner.lastSyncError; - if (msg) { - label.value = 'Last error: ' + msg; // TODO: localize - return; - } - var lastSyncTime = Zotero.Sync.Server.lastLocalSyncTime; // TODO: localize msg = 'Last sync: '; diff --git a/chrome/content/zotero/overlay.xul b/chrome/content/zotero/overlay.xul @@ -164,8 +164,8 @@ <menuitem hidden="true" label=" Reset Client" oncommand="Zotero.Sync.Server.resetClient()"/> <menuitem label="Storage Debugging" disabled="true"/> <menuitem hidden="true" 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); })"/> + <menuitem label=" Purge Deleted Storage Files" oncommand="Zotero.Sync.Storage.purgeDeletedStorageFiles('webdav', function(results) { Zotero.debug(results); })"/> + <menuitem label=" Purge Orphaned Storage Files" oncommand="Zotero.Sync.Storage.purgeOrphanedStorageFiles('webdav', function(results) { Zotero.debug(results); })"/> <menuseparator id="zotero-tb-actions-separator"/> <menuitem id="zotero-tb-actions-prefs" label="&zotero.toolbar.preferences.label;" oncommand="ZoteroPane.openPreferences()"/> @@ -375,6 +375,7 @@ </grid> </tooltip> </hbox> + <toolbarbutton id="zotero-tb-sync-warning" hidden="true"/> <toolbarbutton id="zotero-tb-sync" class="zotero-tb-button" tooltip="_child" oncommand="Zotero.Sync.Server.canAutoResetClient = true; Zotero.Sync.Runner.sync()"> <tooltip diff --git a/chrome/content/zotero/preferences/preferences.js b/chrome/content/zotero/preferences/preferences.js @@ -42,7 +42,7 @@ function init() rows[i].firstChild.nextSibling.value = Zotero.isMac ? 'Cmd+Shift+' : 'Ctrl+Alt+'; } - updateStorageSettings(); + updateStorageSettings(null, null, true); refreshStylesList(); refreshProxyList(); populateQuickCopyList(); @@ -171,43 +171,85 @@ function populateOpenURLResolvers() { // // Sync // -/* -function updateSyncStatus() { - var disabled = !Zotero.Sync.Server.enabled; +function updateStorageSettings(enabled, protocol, skipWarnings) { + if (enabled === null) { + enabled = document.getElementById('pref-storage-enabled').value; + } - var radioGroup = document.getElementById('zotero-reset').firstChild; - radioGroup.disabled = disabled; - var labels = radioGroup.getElementsByTagName('label'); - for each(var label in labels) { - label.disabled = disabled; + var oldProtocol = document.getElementById('pref-storage-protocol').value; + if (protocol === null) { + protocol = oldProtocol; } - var labels = radioGroup.getElementsByTagName('description'); - for each(var label in labels) { - label.disabled = disabled; + + var protocolMenu = document.getElementById('storage-protocol'); + var settings = document.getElementById('storage-webdav-settings'); + var sep = document.getElementById('storage-separator'); + + if (!enabled || protocol == 'zotero') { + settings.hidden = true; + sep.hidden = false; } - document.getElementById('zotero-reset-button').disabled = disabled; -} -*/ - -function updateStorageSettings(value) { - if (!value) { - value = document.getElementById('pref-storage-protocol').value; + else { + settings.hidden = false; + sep.hidden = true; } - var prefix = document.getElementById('storage-url-prefix'); - switch (value) { - case 'webdav': - prefix.value = 'http://'; - break; - - case 'webdavs': - prefix.value = 'https://'; - break; + + protocolMenu.disabled = !enabled; + + if (!skipWarnings) { + // WARN if going between + } + + if (oldProtocol == 'zotero' && protocol == 'webdav') { + var sql = "SELECT COUNT(*) FROM version WHERE schema='storage_zfs'"; + if (Zotero.DB.valueQuery(sql)) { + var pr = Components.classes["@mozilla.org/network/default-prompt;1"] + .getService(Components.interfaces.nsIPrompt); + var buttonFlags = (pr.BUTTON_POS_0) * (pr.BUTTON_TITLE_IS_STRING) + + (pr.BUTTON_POS_1) * (pr.BUTTON_TITLE_IS_STRING) + + pr.BUTTON_DELAY_ENABLE; + var account = Zotero.Sync.Server.username; + var index = pr.confirmEx( + // TODO: localize + "Purge Attachment Files on Zotero Servers?", + + "If you plan to use WebDAV for file syncing and you previously synced attachment files in My Library " + + "to the Zotero servers, you can purge those files from the Zotero servers to give you more " + + "storage space for groups.\n\n" + + "You can purge files at any time from your account settings on zotero.org.", + buttonFlags, + "Purge Files Now", + "Do Not Purge", null, null, {} + ); + + if (index == 0) { + var sql = "INSERT OR IGNORE INTO settings VALUES (?,?,?)"; + Zotero.DB.query(sql, ['storage', 'zfsPurge', 'user']); + + Zotero.Sync.Storage.purgeDeletedStorageFiles('zfs', function (success) { + if (success) { + pr.alert( + Zotero.getString("general.success"), + "Attachment files from your personal library have been removed from the Zotero servers." + ); + } + else { + pr.alert( + Zotero.getString("general.error"), + "An error occurred. Please try again later." + ); + } + }); + } + } } } + + function unverifyStorageServer() { - Zotero.Sync.Storage.clearSettingsCache(); Zotero.Prefs.set('sync.storage.verified', false); + Zotero.Sync.Storage.resetAllSyncStates(null, true, false); } function verifyStorageServer() { @@ -220,7 +262,7 @@ function verifyStorageServer() { var usernameField = document.getElementById("storage-username"); var passwordField = document.getElementById("storage-password"); - var callback = function (uri, status, authRequired) { + var callback = function (uri, status) { verifyButton.hidden = false; abortButton.hidden = true; progressMeter.hidden = true; @@ -245,13 +287,13 @@ function verifyStorageServer() { break; } - Zotero.Sync.Storage.checkServerCallback(uri, status, authRequired, window); + Zotero.Sync.Storage.checkServerCallback(uri, status, window); } verifyButton.hidden = true; abortButton.hidden = false; progressMeter.hidden = false; - var requestHolder = Zotero.Sync.Storage.checkServer(callback); + var requestHolder = Zotero.Sync.Storage.checkServer('webdav', callback); abortButton.onclick = function () { if (requestHolder.request) { requestHolder.request.onreadystatechange = undefined; diff --git a/chrome/content/zotero/preferences/preferences.xul b/chrome/content/zotero/preferences/preferences.xul @@ -38,7 +38,8 @@ To add a new preference: --> <prefwindow id="zotero-prefs" title="&zotero.preferences.title;" onload="moveToAlertPosition(); init()" onunload="Zotero_Preferences.onUnload()" - windowtype="zotero:pref" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + windowtype="zotero:pref" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + style="min-height: 600px"> <prefpane id="zotero-prefpane-general" label="&zotero.preferences.prefpane.general;" @@ -54,8 +55,10 @@ To add a new preference: <preference id="pref-automaticSnapshots" name="extensions.zotero.automaticSnapshots" type="bool"/> <preference id="pref-downloadAssociatedFiles" name="extensions.zotero.downloadAssociatedFiles" type="bool"/> <preference id="pref-automaticTags" name="extensions.zotero.automaticTags" type="bool"/> - <preference id="pref-openURL-resolver" name="extensions.zotero.openURL.resolver" type="string"/> - <preference id="pref-openURL-version" name="extensions.zotero.openURL.version" type="string"/> + + <preference id="pref-groups-copyChildNotes" name="extensions.zotero.groups.copyChildNotes" type="bool"/> + <preference id="pref-groups-copyChildFileAttachments" name="extensions.zotero.groups.copyChildFileAttachments" type="bool"/> + <preference id="pref-groups-copyChildLinks" name="extensions.zotero.groups.copyChildLinks" type="bool"/> </preferences> <groupbox> @@ -138,31 +141,18 @@ To add a new preference: </groupbox> <groupbox> - <caption label="&zotero.preferences.openurl.caption;"/> + <caption label="Groups"/> - <button id="openURLSearchButton" label="&zotero.preferences.openurl.search;" oncommand="populateOpenURLResolvers()"/> - <menulist id="openURLMenu" oncommand="onOpenURLSelected();"> - <menupopup> - <menuseparator/> - <menuitem label="&zotero.preferences.openurl.custom;" value="custom" selected="true"/> - </menupopup> - </menulist> - - <hbox align="center"> - <label value="&zotero.preferences.openurl.server;"/> - <textbox id="openURLServerField" flex="1" oninput="onOpenURLCustomized();" preference="pref-openURL-resolver"/> - </hbox> - - <hbox align="center"> - <label value="&zotero.preferences.openurl.version;" control="openURLVersionMenu"/> - <menulist id="openURLVersionMenu" oncommand="onOpenURLCustomized();" preference="pref-openURL-version"> - <menupopup> - <menuitem label="0.1" value="0.1"/> - <menuitem label="1.0" value="1.0"/> - </menupopup> - </menulist> - </hbox> + <!-- TODO: localize --> + <label value="When copying items between libraries, include:"/> + <vbox style="margin-left: 2em"> + <checkbox label="child notes" preference="pref-groups-copyChildNotes"/> + <checkbox label="child snapshots and imported files" preference="pref-groups-copyChildFileAttachments"/> + <checkbox label="child links" preference="pref-groups-copyChildLinks"/> + </vbox> </groupbox> + + <separator/> </prefpane> @@ -174,10 +164,12 @@ To add a new preference: <preferences> <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-protocol" name="extensions.zotero.sync.storage.protocol" type="string"/> <preference id="pref-storage-enabled" name="extensions.zotero.sync.storage.enabled" type="bool"/> + <preference id="pref-storage-protocol" name="extensions.zotero.sync.storage.protocol" type="string" onchange="unverifyStorageServer()"/> + <preference id="pref-storage-scheme" name="extensions.zotero.sync.storage.scheme" type="string"/> <preference id="pref-storage-url" name="extensions.zotero.sync.storage.url" type="string" instantApply="true"/> <preference id="pref-storage-username" name="extensions.zotero.sync.storage.username" type="string" instantApply="true"/> + <preference id="pref-group-storage-enabled" name="extensions.zotero.sync.storage.groups.enabled" type="bool"/> </preferences> <!-- This doesn't wrap without an explicit width, for some reason --> @@ -250,26 +242,26 @@ To add a new preference: </groupbox> + <!-- TODO: localize --> <groupbox> - <caption label="Storage Server"/> + <caption label="File Syncing"/> - <hbox> - <checkbox label="Enable file syncing" preference="pref-storage-enabled"/> - </hbox> - - <separator class="thin"/> - <hbox align="center"> - <label value="A" style="margin-right: 0"/> - <label value="WebDAV server" class="text-link" href="http://zotero.org/support/sync#file_syncing" style="margin-left: .25em; margin-right: .25em"/> - <label value="is currently required to sync attachment files." style="margin-left: 0"/> + <!-- My Library --> + <hbox style="margin: 0"> + <checkbox label="Sync attachment files in My Library using" preference="pref-storage-enabled" oncommand="updateStorageSettings(this.checked, null)"/> + <menulist id="storage-protocol" style="margin-left: .5em" preference="pref-storage-protocol" oncommand="updateStorageSettings(null, this.value)"> + <menupopup> + <menuitem label="Zotero" value="zotero"/> + <menuitem label="WebDAV" value="webdav"/> + </menupopup> + </menulist> </hbox> - <separator class="thin"/> - <label value="Syncing of attachment files in group libraries is not currently supported."/> - - <separator/> + <stack id="storage-webdav-settings" style="margin-top: .5em; margin-bottom: .8em; border: 1px gray solid; -moz-border-radius: 3px"> + <!-- Background shading --> + <box style="background: black; opacity:.03"/> - <grid id="storage-settings"> + <grid style="padding: .7em .4em .7em 0"> <columns> <column/> <column flex="1"/> @@ -277,23 +269,17 @@ To add a new preference: <rows> <row> - <label value="Protocol:"/> + <label value="URL:"/> <hbox> - <menulist id="storage-url-protocol" - preference="pref-storage-protocol" - onsynctopreference="updateStorageSettings(this.value); unverifyStorageServer();"> + <menulist id="storage-url-prefix" + preference="pref-storage-scheme" + onsynctopreference="unverifyStorageServer()" style="padding: 0; width: 7em"> <menupopup> - <menuitem label="WebDAV" value="webdav"/> - <!-- TODO: localize --> - <menuitem label="WebDAV (Secure)" value="webdavs"/> + <menuitem label="http" value="http" style="padding: 0"/> + <menuitem label="https" value="https" style="padding: 0"/> </menupopup> </menulist> - </hbox> - </row> - <row> - <label value="URL:"/> - <hbox> - <label id="storage-url-prefix"/> + <label value="://"/> <textbox id="storage-url" flex="1" preference="pref-storage-url" onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { this.blur(); verifyStorageServer(); }" @@ -309,7 +295,7 @@ To add a new preference: preference="pref-storage-username" onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { this.blur(); setTimeout('verifyStorageServer();', 1); }" onsynctopreference="unverifyStorageServer();" - onchange="var pass = document.getElementById('storage-password'); if (pass.value) { Zotero.Sync.Storage.password = pass.value; }"/> + onchange="var pass = document.getElementById('storage-password'); if (pass.value) { Zotero.Sync.Storage.Session.WebDAV.prototype.password = pass.value; }"/> </hbox> </row> <row> @@ -318,7 +304,7 @@ To add a new preference: <textbox id="storage-password" flex="0" type="password" onkeypress="if (Zotero.isMac &amp;&amp; event.keyCode == 13) { this.blur(); setTimeout('verifyStorageServer();', 1); }" oninput="unverifyStorageServer()" - onchange="Zotero.Sync.Storage.password = this.value"/> + onchange="Zotero.Sync.Storage.Session.WebDAV.prototype.password = this.value"/> </hbox> </row> <row> @@ -333,6 +319,20 @@ To add a new preference: </row> </rows> </grid> + + </stack> + + <separator id="storage-separator" class="thin"/> + + <!-- Group Libraries --> + <checkbox label="Sync attachment files in group libraries using Zotero File Storage" + preference="pref-group-storage-enabled"/> + + <separator class="thin"/> + + <hbox style="margin-top:.3em"> + <label class="text-link" style="margin-left: 0" value="About File Syncing" href="http://zotero.org/support/file_sync"/> + </hbox> </groupbox> </tabpanel> @@ -377,7 +377,7 @@ To add a new preference: </groupbox> <groupbox> - <caption label="Storage Server"/> + <caption label="File Syncing"/> <grid> <columns> @@ -404,6 +404,8 @@ To add a new preference: </tabpanel> </tabpanels> </tabbox> + + <separator/> </prefpane> @@ -492,6 +494,8 @@ To add a new preference: </rows> </grid> </groupbox> + + <separator/> </prefpane> @@ -499,22 +503,11 @@ To add a new preference: label="&zotero.preferences.prefpane.export;" image="chrome://zotero/skin/prefs-export.png"> <preferences> - <preference id="pref-export-citePaperJournalArticleURL" name="extensions.zotero.export.citePaperJournalArticleURL" type="bool"/> <preference id="pref-quickCopy-setting" name="extensions.zotero.export.quickCopy.setting" type="string"/> <preference id="pref-quickCopy-dragLimit" name="extensions.zotero.export.quickCopy.dragLimit" type="int"/> </preferences> <groupbox> - <caption label="&zotero.preferences.citationOptions.caption;"/> - - <checkbox label="&zotero.preferences.export.citePaperJournalArticleURL;" preference="pref-export-citePaperJournalArticleURL"/> - <!-- This doesn't wrap without an explicit width, for some reason --> - <label id="export-citePaperJournalArticleURL" width="45em"> - &zotero.preferences.export.citePaperJournalArticleURL.description; - </label> - </groupbox> - - <groupbox> <caption label="&zotero.preferences.quickCopy.caption;"/> <label id="quickCopy-instructions"/> @@ -548,23 +541,38 @@ To add a new preference: <button label="+" onclick="showQuickCopySiteEditor()"/> </hbox> - <separator/> - <!-- TODO: localize --> <hbox align="center"> <label value="Disable Quick Copy when dragging more than"/> <textbox preference="pref-quickCopy-dragLimit" size="3"/> <label value="items" flex="1"/> </hbox> + </groupbox> + + <groupbox> + <caption label="&zotero.preferences.charset;"/> - <separator/> + <checkbox id="zotero-export-displayCharsetOption" label="&zotero.preferences.charset.displayExportOption;" + preference="pref-export-displayCharsetOption"/> + + <hbox align="center"> + <label value="&zotero.preferences.charset.importCharset;:" control="zotero-import-charsetMenu"/> + <menulist id="zotero-import-charsetMenu" preference="pref-import-charset"/> + </hbox> </groupbox> + + <separator/> </prefpane> <prefpane id="zotero-prefpane-styles" label="&zotero.preferences.prefpane.styles;" image="chrome://zotero/skin/prefs-styles.png"> + + <preferences> + <preference id="pref-styles-citePaperJournalArticleURL" name="extensions.zotero.export.citePaperJournalArticleURL" type="bool"/> + </preferences> + <groupbox flex="1"> <caption label="&zotero.preferences.styles.styleManager;"/> @@ -579,13 +587,24 @@ To add a new preference: <treechildren id="styleManager-rows"/> </tree> <separator class="thin"/> - <hbox pack="end"> + <hbox align="center" flex="1"> + <label class="text-link" href="http://www.zotero.org/styles/" value="&zotero.preferences.export.getAdditionalStyles;" flex="1"/> <button disabled="true" id="styleManager-delete" label="-" onclick="deleteStyle()"/> <button label="+" onclick="addStyle()"/> </hbox> - <separator/> - <label class="text-link" href="http://www.zotero.org/styles/" value="&zotero.preferences.export.getAdditionalStyles;"/> </groupbox> + + <groupbox> + <caption label="&zotero.preferences.citationOptions.caption;"/> + + <checkbox label="&zotero.preferences.export.citePaperJournalArticleURL;" preference="pref-styles-citePaperJournalArticleURL"/> + <!-- This doesn't wrap without an explicit width, for some reason --> + <label id="export-citePaperJournalArticleURL" width="45em"> + &zotero.preferences.export.citePaperJournalArticleURL.description; + </label> + </groupbox> + + <separator/> </prefpane> @@ -621,6 +640,8 @@ To add a new preference: <button label="+" onclick="showProxyEditor()"/> </hbox> </groupbox> + + <separator/> </prefpane> @@ -714,6 +735,8 @@ To add a new preference: <checkbox label="&zotero.preferences.keys.overrideGlobal;" preference="pref-keys-overrideGlobal"/> <label class="statusLine" value="&zotero.preferences.keys.changesTakeEffect;"/> + + <separator/> </prefpane> @@ -726,6 +749,8 @@ To add a new preference: <preference id="pref-export-displayCharsetOption" name="extensions.zotero.export.displayCharsetOption" type="bool"/> <preference id="pref-debug-output-enableAfterRestart" name="extensions.zotero.debug.store" type="bool"/> <preference id="pref-import-charset" name="extensions.zotero.import.charset" type="string"/> + <preference id="pref-openURL-resolver" name="extensions.zotero.openURL.resolver" type="string"/> + <preference id="pref-openURL-version" name="extensions.zotero.openURL.version" type="string"/> </preferences> <groupbox> @@ -750,9 +775,6 @@ To add a new preference: <hbox> <button label="&zotero.preferences.dbMaintenance.integrityCheck;" oncommand="runIntegrityCheck()"/> - </hbox> - - <hbox> <button label="&zotero.preferences.dbMaintenance.resetTranslatorsAndStyles;" oncommand="resetTranslatorsAndStyles()"/> </hbox> </groupbox> @@ -782,16 +804,38 @@ To add a new preference: </groupbox> <groupbox> - <caption label="&zotero.preferences.charset;"/> + <caption label="&zotero.preferences.openurl.caption;"/> - <checkbox id="zotero-export-displayCharsetOption" label="&zotero.preferences.charset.displayExportOption;" - preference="pref-export-displayCharsetOption"/> + <hbox align="center"> + <!-- vbox prevents some weird vertical stretching of the menulist --> + <vbox flex="1"> + <menulist id="openURLMenu" oncommand="onOpenURLSelected();"> + <menupopup> + <menuseparator/> + <menuitem label="&zotero.preferences.openurl.custom;" value="custom" selected="true"/> + </menupopup> + </menulist> + </vbox> + <button id="openURLSearchButton" label="&zotero.preferences.openurl.search;" oncommand="populateOpenURLResolvers()"/> + </hbox> <hbox align="center"> - <label value="&zotero.preferences.charset.importCharset;:" control="zotero-import-charsetMenu"/> - <menulist id="zotero-import-charsetMenu" preference="pref-import-charset"/> + <label value="&zotero.preferences.openurl.server;"/> + <textbox id="openURLServerField" flex="1" oninput="onOpenURLCustomized();" preference="pref-openURL-resolver"/> + </hbox> + + <hbox align="center"> + <label value="&zotero.preferences.openurl.version;" control="openURLVersionMenu"/> + <menulist id="openURLVersionMenu" oncommand="onOpenURLCustomized();" preference="pref-openURL-version"> + <menupopup> + <menuitem label="0.1" value="0.1"/> + <menuitem label="1.0" value="1.0"/> + </menupopup> + </menulist> </hbox> </groupbox> + + <separator/> </prefpane> <!-- These mess up the prefwindow (more) if they come before the prefpanes diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js @@ -43,7 +43,7 @@ Zotero.Attachments = new function(){ var self = this; - function importFromFile(file, sourceItemID){ + function importFromFile(file, sourceItemID, libraryID) { Zotero.debug('Importing attachment from file'); var title = file.leafName; @@ -61,6 +61,9 @@ Zotero.Attachments = new function(){ var parentItem = Zotero.Items.get(sourceItemID); attachmentItem.libraryID = parentItem.libraryID; } + else if (libraryID) { + attachmentItem.libraryID = libraryID; + } attachmentItem.setField('title', title); attachmentItem.setSource(sourceItemID); attachmentItem.attachmentLinkMode = this.LINK_MODE_IMPORTED_FILE; @@ -980,6 +983,54 @@ Zotero.Attachments = new function(){ /** + * Returns the number of files in the attachment directory + * + * Only counts if MIME type is text/html + * + * @param {Zotero.Item} item Attachment item + */ + this.getNumFiles = function (item) { + var funcName = "Zotero.Attachments.getNumFiles()"; + + 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: + break; + + default: + throw ("Invalid attachment link mode in " + funcName); + } + + if (item.attachmentMIMEType != 'text/html') { + return 1; + } + + var file = item.getFile(); + if (!file) { + throw ("File not found in " + funcName); + } + + var numFiles = 0; + var parentDir = file.parent; + var files = parentDir.directoryEntries; + while (files.hasMoreElements()) { + file = files.getNext(); + file.QueryInterface(Components.interfaces.nsIFile); + if (file.leafName.indexOf('.') == 0) { + continue; + } + numFiles++; + } + return numFiles; + } + + + /** * @param {Zotero.Item} item * @param {Boolean} [skipHidden=FALSE] Don't count hidden files * @return {Integer} Total file size in bytes @@ -1026,6 +1077,45 @@ Zotero.Attachments = new function(){ } + /** + * Copy attachment item, including files, to another library + */ + this.copyAttachmentToLibrary = function (attachment, libraryID, sourceItemID) { + var linkMode = attachment.attachmentLinkMode; + + if (attachment.libraryID == libraryID) { + throw ("Attachment is already in library " + libraryID); + } + + var newAttachment = new Zotero.Item('attachment'); + newAttachment.libraryID = libraryID; + // Link mode needs to be set when saving new attachment + newAttachment.attachmentLinkMode = linkMode; + if (attachment.isImportedAttachment()) { + // Attachment path isn't copied over by clone() if libraryID is different + newAttachment.attachmentPath = attachment.attachmentPath; + } + // DEBUG: save here because clone() doesn't currently work on unsaved tagged items + var id = newAttachment.save(); + newAttachment = Zotero.Items.get(id); + attachment.clone(false, newAttachment); + if (sourceItemID) { + newAttachment.setSource(sourceItemID); + } + newAttachment.save(); + + // Copy over files + if (newAttachment.isImportedAttachment()) { + var dir = Zotero.Attachments.getStorageDirectory(attachment.id); + var newDir = Zotero.Attachments.createDirectoryForItem(newAttachment.id); + Zotero.File.copyDirectory(dir, newDir); + } + + attachment.addLinkedItem(newAttachment); + return newAttachment.id; + } + + 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/collectionTreeView.js b/chrome/content/zotero/xpcom/collectionTreeView.js @@ -326,9 +326,14 @@ Zotero.CollectionTreeView.prototype.notify = function(action, type, ids) break; case 'group': - if (this._groupRowMap[ids[i]] != null) { - rows.push(this._groupRowMap[ids[i]]); - } + //if (this._groupRowMap[ids[i]] != null) { + // rows.push(this._groupRowMap[ids[i]]); + //} + + // For now, just reload if a group is removed, since otherwise + // we'd have to remove collections too + this.reload(); + this.rememberSelection(savedSelection); break; } } @@ -635,7 +640,7 @@ Zotero.CollectionTreeView.prototype.isSelectable = function (row, col) { Zotero.CollectionTreeView.prototype.__defineGetter__('editable', function () { - return this._getItemAtRow(this.selection.currentIndex).isEditable(); + return this._getItemAtRow(this.selection.currentIndex).editable; }); @@ -1029,16 +1034,15 @@ Zotero.CollectionTreeView.prototype.canDrop = function(row, orient, dragData) { var itemGroup = this._getItemAtRow(row); //the collection we are dragging over - if (!itemGroup.isEditable()) { + if (!itemGroup.editable) { return false; } if (dataType == 'zotero/item') { - if(itemGroup.isBucket()) { return true; } - + var ids = data; var items = Zotero.Items.get(ids); var skip = true; @@ -1048,10 +1052,13 @@ Zotero.CollectionTreeView.prototype.canDrop = function(row, orient, dragData) return false } - // TODO: for now, only allow regular items to be dragged to groups - if (itemGroup.isWithinGroup() && itemGroup.ref.libraryID != item.libraryID - && !item.isRegularItem()) { - return false; + if (itemGroup.isWithinGroup() && item.isAttachment()) { + // Linked files can't be added to groups + if (item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { + return false; + } + skip = false; + continue; } // TODO: for now, skip items that are already linked @@ -1070,7 +1077,7 @@ Zotero.CollectionTreeView.prototype.canDrop = function(row, orient, dragData) continue; } - // Allow drag of group items to library + // Allow drag of group items to personal library if (item.libraryID && (itemGroup.isLibrary() || itemGroup.isCollection() && !itemGroup.isWithinGroup())) { // TODO: for now, skip items that are already linked @@ -1106,10 +1113,17 @@ Zotero.CollectionTreeView.prototype.canDrop = function(row, orient, dragData) if (itemGroup.isSearch()) { return false; } - // Don't allow folder drag - if (dataType == 'application/x-moz-file' && data[0].isDirectory()) { - return false; + if (dataType == 'application/x-moz-file') { + // Don't allow folder drag + if (data[0].isDirectory()) { + return false; + } + // Don't allow drop if no permissions + if (!itemGroup.filesEditable) { + return false; + } } + return true; } else if (dataType == 'zotero/collection') { @@ -1179,7 +1193,7 @@ Zotero.CollectionTreeView.prototype.drop = function(row, orient) else { var targetLibraryID = null; } - + if(itemGroup.isBucket()) { itemGroup.ref.uploadItems(ids); return; @@ -1263,12 +1277,19 @@ Zotero.CollectionTreeView.prototype.drop = function(row, orient) */ } + // Standalone attachment + if (item.isAttachment()) { + var id = Zotero.Attachments.copyAttachmentToLibrary(item, targetLibraryID); + newIDs.push(id); + continue; + } + // Create new unsaved clone item in target library var newItem = new Zotero.Item(item.itemTypeID); newItem.libraryID = targetLibraryID; // DEBUG: save here because clone() doesn't currently work on unsaved tagged items var id = newItem.save(); - var newItem = Zotero.Items.get(id); + newItem = Zotero.Items.get(id); item.clone(false, newItem); newItem.save(); //var id = newItem.save(); @@ -1277,6 +1298,62 @@ Zotero.CollectionTreeView.prototype.drop = function(row, orient) // Record link item.addLinkedItem(newItem); newIDs.push(id); + + if (item.isNote()) { + continue; + } + + // For regular items, add child items if prefs and permissions allow + + // Child notes + if (Zotero.Prefs.get('groups.copyChildNotes')) { + var noteIDs = item.getNotes(); + var notes = Zotero.Items.get(noteIDs); + for each(var note in notes) { + var newNote = new Zotero.Item('note'); + newNote.libraryID = targetLibraryID; + // DEBUG: save here because clone() doesn't currently work on unsaved tagged items + var id = newNote.save(); + newNote = Zotero.Items.get(id); + note.clone(false, newNote); + newNote.setSource(newItem.id); + newNote.save(); + + note.addLinkedItem(newNote); + } + } + + // Child attachments + var copyChildLinks = Zotero.Prefs.get('groups.copyChildLinks'); + var copyChildFileAttachments = Zotero.Prefs.get('groups.copyChildFileAttachments'); + if (copyChildLinks || copyChildFileAttachments) { + var attachmentIDs = item.getAttachments(); + var attachments = Zotero.Items.get(attachmentIDs); + for each(var attachment in attachments) { + var linkMode = attachment.attachmentLinkMode; + + // Skip linked files + if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { + continue; + } + + // Skip imported files if we don't have pref and permissions + if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { + if (!copyChildLinks) { + Zotero.debug("Skipping child link attachment on drag"); + continue; + } + } + else { + if (!copyChildFileAttachments || !itemGroup.filesEditable) { + Zotero.debug("Skipping child file attachment on drag"); + continue; + } + } + + var id = Zotero.Attachments.copyAttachmentToLibrary(attachment, targetLibraryID, newItem.id); + } + } } if (toReconcile.length) { @@ -1342,12 +1419,11 @@ Zotero.CollectionTreeView.prototype.drop = function(row, orient) return; } else if (dataType == 'text/x-moz-url' || dataType == 'application/x-moz-file') { - // FIXME: temporarily disable dragging in of files - if (dataType == 'application/x-moz-file' && itemGroup.isWithinGroup()) { - var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] - .getService(Components.interfaces.nsIPromptService); - ps.alert(null, "", "Files cannot currently be added to group libraries."); - return; + if (itemGroup.isWithinGroup()) { + var targetLibraryID = itemGroup.ref.libraryID; + } + else { + var targetLibraryID = null; } if (itemGroup.isCollection()) { @@ -1398,7 +1474,7 @@ Zotero.CollectionTreeView.prototype.drop = function(row, orient) try { Zotero.DB.beginTransaction(); - var itemID = Zotero.Attachments.importFromFile(file, false); + var itemID = Zotero.Attachments.importFromFile(file, false, targetLibraryID); if (parentCollectionID) { var col = Zotero.Collections.get(parentCollectionID); if (col) { @@ -1545,11 +1621,10 @@ Zotero.ItemGroup.prototype.isWithinGroup = function () { return this.ref && !!this.ref.libraryID; } -Zotero.ItemGroup.prototype.isEditable = function () { +Zotero.ItemGroup.prototype.__defineGetter__('editable', function () { if (this.isTrash() || this.isShare()) { return false; } - if (!this.isWithinGroup()) { return true; } @@ -1564,12 +1639,33 @@ Zotero.ItemGroup.prototype.isEditable = function () { var group = Zotero.Groups.get(groupID); return group.editable; } - else { - throw ("Unknown library type '" + type + "' in Zotero.ItemGroup.isEditable()"); - } + throw ("Unknown library type '" + type + "' in Zotero.ItemGroup.editable"); } -} + return false; +}); +Zotero.ItemGroup.prototype.__defineGetter__('filesEditable', function () { + if (this.isTrash() || this.isShare()) { + return false; + } + if (!this.isWithinGroup()) { + return true; + } + var libraryID = this.ref.libraryID; + if (this.isGroup()) { + return this.ref.filesEditable; + } + if (this.isCollection()) { + var type = Zotero.Libraries.getType(libraryID); + if (type == 'group') { + var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID); + var group = Zotero.Groups.get(groupID); + return group.filesEditable; + } + throw ("Unknown library type '" + type + "' in Zotero.ItemGroup.filesEditable"); + } + return false; +}); Zotero.ItemGroup.prototype.getName = function() { diff --git a/chrome/content/zotero/xpcom/data/dataObjects.js b/chrome/content/zotero/xpcom/data/dataObjects.js @@ -327,7 +327,7 @@ Zotero.DataObjects = function (object, objectPlural, id, table) { this.editCheck = function (obj) { - if (!Zotero.Sync.Server.syncInProgress && !this.isEditable(obj)) { + if (!Zotero.Sync.Server.syncInProgress && !Zotero.Sync.Storage.syncInProgress && !this.isEditable(obj)) { throw ("Cannot edit " + this._ZDO_object + " in read-only Zotero library"); } } diff --git a/chrome/content/zotero/xpcom/data/group.js b/chrome/content/zotero/xpcom/data/group.js @@ -286,6 +286,10 @@ Zotero.Group.prototype.save = function () { * Deletes group and all descendant objects **/ Zotero.Group.prototype.erase = function() { + // Don't send notifications for items and other groups objects that are deleted, + // since we're really only removing the group from the client + var notifierDisabled = Zotero.Notifier.disable(); + Zotero.DB.beginTransaction(); var sql, ids, obj; @@ -331,6 +335,9 @@ Zotero.Group.prototype.erase = function() { sql = "DELETE FROM syncDeleteLog WHERE libraryID=?"; Zotero.DB.query(sql, this.libraryID); + var prefix = "groups/" + this.id; + Zotero.Relations.eraseByPathPrefix(prefix); + // Delete group sql = "DELETE FROM groups WHERE groupID=?"; ids = Zotero.DB.query(sql, this.id) @@ -342,6 +349,10 @@ Zotero.Group.prototype.erase = function() { Zotero.DB.commitTransaction(); + if (notifierDisabled) { + Zotero.Notifier.enable(); + } + Zotero.Notifier.trigger('delete', 'group', this.id, notifierData); } diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js @@ -2540,28 +2540,16 @@ Zotero.Item.prototype.getFile = function(row, skipExistsCheck) { /** * _row_ is optional itemAttachments row if available to skip queries */ -Zotero.Item.prototype.getFilename = function (row) { +Zotero.Item.prototype.getFilename = function () { if (!this.isAttachment()) { throw ("getFileName() can only be called on attachment items in Zotero.Item.getFilename()"); } - if (!row) { - var row = { - linkMode: this.attachmentLinkMode, - path: this.attachmentPath - }; - } - - if (row.linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { + if (this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { throw ("getFilename() cannot be called on link attachments in Zotero.Item.getFilename()"); } - if (this.isImportedAttachment()) { - var matches = row.path.match("^storage:(.+)$"); - return matches[1]; - } - - var file = this.getFile(); + var file = this.getFile(null, true); if (!file) { return false; } @@ -2602,10 +2590,12 @@ Zotero.Item.prototype.renameAttachmentFile = function(newName, overwrite) { } file.moveTo(null, newName); - // Update mod time so the file syncs + // Update mod time and clear hash so the file syncs dest.lastModifiedTime = new Date(); this.relinkAttachmentFile(dest); + Zotero.Sync.Storage.setSyncedHash(this.id, null, false); + return true; } catch (e) { @@ -2948,6 +2938,32 @@ Zotero.Item.prototype.__defineGetter__('attachmentModificationTime', function () /** + * MD5 hash of an attachment file + * + * Note: This is the hash of the file itself, not the last-known hash + * of the file on the storage server as stored in the database + * + * @return {String} MD5 hash of file as hex string + */ +Zotero.Item.prototype.__defineGetter__('attachmentHash', function () { + if (!this.isAttachment()) { + return undefined; + } + + if (!this.id) { + return undefined; + } + + var file = this.getFile(); + if (!file) { + return undefined; + } + + return Zotero.Utilities.prototype.md5(file); +}); + + +/** * Return plain text of attachment content * * - Currently works on HTML, PDF and plaintext attachments diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js @@ -421,7 +421,6 @@ Zotero.Items = new function() { var item = this.get(id); if (!item) { Zotero.debug('Item ' + id + ' does not exist in Items.erase()!', 1); - Zotero.Notifier.trigger('delete', 'item', id); continue; } item.erase(eraseChildren); // calls unload() diff --git a/chrome/content/zotero/xpcom/data/relations.js b/chrome/content/zotero/xpcom/data/relations.js @@ -6,6 +6,7 @@ Zotero.Relations = new function () { owl: 'http://www.w3.org/2002/07/owl#' }; + var _prefix = "http://zotero.org/"; this.get = function (id) { if (typeof id != 'number') { @@ -76,6 +77,37 @@ Zotero.Relations = new function () { } + this.updateUser = function (fromUserID, fromLibraryID, toUserID, toLibraryID) { + if (!fromUserID) { + throw ("Invalid source userID " + fromUserID + " in Zotero.Relations.updateUserID"); + } + if (!fromLibraryID) { + throw ("Invalid source libraryID " + fromLibraryID + " in Zotero.Relations.updateUserID"); + } + if (!toUserID) { + throw ("Invalid target userID " + toUserID + " in Zotero.Relations.updateUserID"); + } + if (!toLibraryID) { + throw ("Invalid target libraryID " + toLibraryID + " in Zotero.Relations.updateUserID"); + } + + Zotero.DB.beginTransaction(); + + var sql = "UPDATE relations SET libraryID=? WHERE libraryID=?"; + Zotero.DB.query(sql, [fromLibraryID, toLibraryID]); + + sql = "UPDATE relations SET " + + "subject=REPLACE(subject, 'zotero.org/users/" + fromUserID + "', " + + "'zotero.org/users/" + toUserID + "'), " + + "object=REPLACE(object, 'zotero.org/users/" + fromUserID + "', " + + "'zotero.org/users/" + toUserID + "') " + + "WHERE predicate='owl:sameAs'"; + Zotero.DB.query(sql); + + Zotero.DB.commitTransaction(); + } + + this.add = function (libraryID, subject, predicate, object) { predicate = _getPrefixAndValue(predicate).join(':'); @@ -109,6 +141,13 @@ Zotero.Relations = new function () { } + this.eraseByPathPrefix = function (prefix) { + prefix = _prefix + prefix + '%'; + sql = "DELETE FROM relations WHERE subject LIKE ? OR object LIKE ?"; + Zotero.DB.query(sql, [prefix, prefix]); + } + + this.xmlToRelation = function (xml) { var relation = new Zotero.Relation; var libraryID = xml.@libraryID.toString(); diff --git a/chrome/content/zotero/xpcom/db.js b/chrome/content/zotero/xpcom/db.js @@ -421,7 +421,9 @@ Zotero.DBConnection.prototype.getLastErrorString = function () { Zotero.DBConnection.prototype.beginTransaction = function () { // TODO: limit to Zotero.DB, not all Zotero.DBConnections? if (Zotero.waiting) { - throw ("Cannot access database layer during active Zotero.wait()"); + var msg = "Cannot access database layer during active Zotero.wait()"; + Zotero.debug(msg, 2); + throw (msg); } var db = this._getDBConnection(); diff --git a/chrome/content/zotero/xpcom/error.js b/chrome/content/zotero/xpcom/error.js @@ -1,6 +1,7 @@ -Zotero.Error = function (message, error) { +Zotero.Error = function (message, error, data) { this.name = "ZOTERO_ERROR"; this.message = message; + this.data = data; if (parseInt(error) == error) { this.error = error; } @@ -14,6 +15,11 @@ Zotero.Error.ERROR_MISSING_OBJECT = 1; Zotero.Error.ERROR_FULL_SYNC_REQUIRED = 2; Zotero.Error.ERROR_SYNC_USERNAME_NOT_SET = 3; Zotero.Error.ERROR_INVALID_SYNC_LOGIN = 4; +Zotero.Error.ERROR_ZFS_OVER_QUOTA = 5; +Zotero.Error.ERROR_ZFS_UPLOAD_QUEUE_LIMIT = 6; +Zotero.Error.ERROR_ZFS_FILE_EDITING_DENIED = 7; +//Zotero.Error.ERROR_SYNC_EMPTY_RESPONSE_FROM_SERVER = 6; +//Zotero.Error.ERROR_SYNC_INVALID_RESPONSE_FROM_SERVER = 7; Zotero.Error.prototype.toString = function () { return this.message; diff --git a/chrome/content/zotero/xpcom/file.js b/chrome/content/zotero/xpcom/file.js @@ -148,25 +148,6 @@ Zotero.File = new function(){ } - /** - * @param {nsIFile} file - * @return {String} Base-64 representation of MD5 hash - */ - this.getFileHash = function (file) { - var fis = Components.classes["@mozilla.org/network/file-input-stream;1"] - .createInstance(Components.interfaces.nsIFileInputStream); - fis.init(file, -1, -1, false); - - var hash = Components.classes["@mozilla.org/security/hash;1"]. - createInstance(Components.interfaces.nsICryptoHash); - hash.init(Components.interfaces.nsICryptoHash.MD5); - hash.updateFromStream(fis, 4294967295); // PR_UINT32_MAX - hash = hash.finish(true); - fis.close(); - return hash; - } - - /* * Write string to a file, overwriting existing file if necessary */ @@ -199,6 +180,19 @@ Zotero.File = new function(){ } + /** + * Copies all files from dir into newDir + */ + this.copyDirectory = function (dir, newDir) { + var otherFiles = dir.directoryEntries; + while (otherFiles.hasMoreElements()) { + var file = otherFiles.getNext(); + file.QueryInterface(Components.interfaces.nsIFile); + file.copyTo(newDir, null); + } + } + + this.createDirectoryIfMissing = function (dir) { if (!dir.exists() || !dir.isDirectory()) { if (dir.exists() && !dir.isDirectory()) { diff --git a/chrome/content/zotero/xpcom/itemTreeView.js b/chrome/content/zotero/xpcom/itemTreeView.js @@ -2218,16 +2218,8 @@ Zotero.ItemTreeView.prototype.drop = function(row, orient) } } else if (dataType == 'text/x-moz-url' || dataType == 'application/x-moz-file') { - // FIXME: temporarily disable dragging in of files - if (dataType == 'application/x-moz-file' && itemGroup.isWithinGroup()) { - var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] - .getService(Components.interfaces.nsIPromptService); - ps.alert(null, "", "Files cannot currently be added to group libraries."); - return; - } - // Disallow drop into read-only libraries - if (!itemGroup.isEditable()) { + if (!itemGroup.editable) { var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] .getService(Components.interfaces.nsIWindowMediator); var win = wm.getMostRecentWindow("navigator:browser"); @@ -2235,6 +2227,13 @@ Zotero.ItemTreeView.prototype.drop = function(row, orient) return; } + if (itemGroup.isWithinGroup()) { + var targetLibraryID = itemGroup.ref.libraryID; + } + else { + var targetLibraryID = null; + } + var sourceItemID = false; var parentCollectionID = false; @@ -2245,9 +2244,6 @@ Zotero.ItemTreeView.prototype.drop = function(row, orient) else if (itemGroup.isCollection()) { var parentCollectionID = itemGroup.ref.id; } - else if (itemGroup.isLibrary(true)) { - var libraryID = itemGroup.ref.libraryID; - } var unlock = Zotero.Notifier.begin(true); try { @@ -2278,15 +2274,10 @@ Zotero.ItemTreeView.prototype.drop = function(row, orient) // Still string, so remote URL if (typeof file == 'string') { - if (sourceItemID) { - Zotero.Attachments.importFromURL(url, sourceItemID); - } - else { - var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] - .getService(Components.interfaces.nsIWindowMediator); - var win = wm.getMostRecentWindow("navigator:browser"); - win.ZoteroPane.addItemFromURL(url, 'temporaryPDFHack'); // TODO: don't do this - } + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var win = wm.getMostRecentWindow("navigator:browser"); + win.ZoteroPane.addItemFromURL(url, 'temporaryPDFHack', row); // TODO: don't do this continue; } @@ -2295,7 +2286,7 @@ Zotero.ItemTreeView.prototype.drop = function(row, orient) try { Zotero.DB.beginTransaction(); - var itemID = Zotero.Attachments.importFromFile(file, sourceItemID); + var itemID = Zotero.Attachments.importFromFile(file, sourceItemID, targetLibraryID); if (parentCollectionID) { var col = Zotero.Collections.get(parentCollectionID); if (col) { diff --git a/chrome/content/zotero/xpcom/schema.js b/chrome/content/zotero/xpcom/schema.js @@ -2451,6 +2451,21 @@ Zotero.Schema = new function(){ Zotero.DB.query("CREATE INDEX IF NOT EXISTS itemData_fieldID ON itemData(fieldID)"); } + if (i==63) { + Zotero.DB.query("ALTER TABLE itemAttachments ADD COLUMN storageHash TEXT"); + + var protocol = Zotero.Prefs.get('sync.storage.protocol'); + if (protocol == 'webdav') { + Zotero.Prefs.set('sync.storage.scheme', 'http'); + } + else { + Zotero.Prefs.set('sync.storage.protocol', 'webdav'); + Zotero.Prefs.set('sync.storage.scheme', 'https'); + } + + Zotero.DB.query("UPDATE version SET schema='storage_webdav' WHERE schema='storage'"); + } + Zotero.wait(); } diff --git a/chrome/content/zotero/xpcom/storage.js b/chrome/content/zotero/xpcom/storage.js @@ -28,155 +28,6 @@ Zotero.Sync.Storage = new function () { // 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; - var password = Zotero.Sync.Storage.password; - if (username && !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; } - }); - } - - var protocol = Zotero.Prefs.get('sync.storage.protocol'); - switch (protocol) { - case 'webdav': - var scheme = "http"; - break; - - case 'webdavs': - var scheme = "https"; - break; - - default: - throw ("Invalid storage protocol '" + protocol - + "' in Zotero.Sync.Storage.rootURI"); - } - - spec = scheme + '://' + spec + '/zotero/'; - - var ios = Components.classes["@mozilla.org/network/io-service;1"]. - getService(Components.interfaces.nsIIOService); - try { - var uri = ios.newURI(spec, null, null); - if (username) { - 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__('enabled', function () { - return Zotero.Prefs.get("sync.storage.enabled"); - }); - - this.__defineGetter__('verified', function () { - return Zotero.Prefs.get("sync.storage.verified"); - }); - - this.__defineGetter__('active', function () { - return this.enabled && this.verified; - }); this.__defineGetter__("syncInProgress", function () _syncInProgress); @@ -194,67 +45,87 @@ Zotero.Sync.Storage = new function () { // // Private properties // - var _loginManagerHost = 'chrome://zotero'; - var _loginManagerURL = 'Zotero Storage Server'; - var _cachedCredentials = { username: null, password: null, authHeader: null }; - var _rootURI; var _syncInProgress; var _changesMade; - var _finishCallback; + var _session; + + var _callbacks = { + onSuccess: function () {}, + onSkip: function () {}, + onStop: function () {}, + onError: function () {}, + onWarning: function () {} + }; // // Public methods // - this.sync = function () { - if (!Zotero.Sync.Storage.enabled) { - Zotero.debug("Storage sync is not enabled"); - Zotero.Sync.Runner.reset(); - Zotero.Sync.Runner.next(); + this.sync = function (module, callbacks) { + for (var func in callbacks) { + _callbacks[func] = callbacks[func]; + } + + _session = new Zotero.Sync.Storage.Session(module, { + onChangesMade: function () { + _changesMade = true; + }, + onError: _error + }); + + if (!_session.enabled) { + Zotero.debug(_session.name + " file sync is not enabled"); + _callbacks.onSkip(); return; } + if (!_session.initFromPrefs()) { + _error("Module not initialized"); + } - if (!Zotero.Sync.Storage.active) { - Zotero.debug("Storage sync is not active"); + if (!_session.active) { + Zotero.debug(_session.name + " file sync is not active"); - var callback = function (uri, status, authRequired) { + var callback = function (uri, status) { var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] .getService(Components.interfaces.nsIWindowMediator); var lastWin = wm.getMostRecentWindow("navigator:browser"); - var success = Zotero.Sync.Storage.checkServerCallback(uri, status, authRequired, lastWin, true); + var success = _session.checkServerCallback(uri, status, lastWin, true); if (success) { - Zotero.debug("Storage sync is successfully set up"); - Zotero.Sync.Storage.sync(); + Zotero.debug(_session.name + " file sync is successfully set up"); + Zotero.Sync.Storage.sync(module, callbacks); } else { - Zotero.debug("Storage sync verification failed"); - Zotero.Sync.Runner.reset(); - Zotero.Sync.Runner.next(); + Zotero.debug(_session.name + " verification failed"); + _callbacks.onSkip(); } } - Zotero.Sync.Storage.checkServer(callback); + _session.checkServer(callback); return; } if (_syncInProgress) { - _error("Storage sync operation already in progress"); + _error("File sync operation already in progress"); } - Zotero.debug("Beginning storage sync"); + Zotero.debug("Beginning " + _session.name + " file sync"); _syncInProgress = true; _changesMade = false; - Zotero.Sync.Storage.checkForUpdatedFiles(); + Zotero.Sync.Storage.checkForUpdatedFiles(null, null, _session.includeUserFiles, _session.includeGroupFiles); - var successFileCheckCallback = function (lastSyncTime) { + var lastSyncCheckCallback = function (lastSyncTime) { var downloadFiles = true; - if (lastSyncTime) { - var sql = "SELECT version FROM version WHERE schema='storage'"; + + var sql = "SELECT COUNT(*) FROM itemAttachments WHERE syncState=?"; + var force = !!Zotero.DB.valueQuery(sql, Zotero.Sync.Storage.SYNC_STATE_FORCE_DOWNLOAD); + + if (!force && lastSyncTime) { + var sql = "SELECT version FROM version WHERE schema='storage_" + module + "'"; var version = Zotero.DB.valueQuery(sql); if (version == lastSyncTime) { - Zotero.debug("Last storage sync time hasn't changed -- skipping file download step"); + Zotero.debug("Last " + _session.name + " sync time hasn't changed -- skipping file download step"); downloadFiles = false; } } @@ -263,27 +134,11 @@ Zotero.Sync.Storage = new function () { var activeUp = Zotero.Sync.Storage.uploadFiles(); if (!activeDown && !activeUp) { _syncInProgress = false; - Zotero.Sync.Runner.reset(); - Zotero.Sync.Runner.next(); + _callbacks.onSkip(); } }; - // 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; - } - - _getSuccessFileTimestamp(successFileCheckCallback); - }); - return; - } - - _getSuccessFileTimestamp(successFileCheckCallback); + _session.getLastSyncTime(lastSyncCheckCallback); } @@ -368,69 +223,44 @@ Zotero.Sync.Storage = new function () { /** - * Get mod time of file on storage server - * - * @param {Zotero.Item} item - * @param {Function} callback Callback f(item, mdate) + * @param {Integer} itemID + * @return {String|NULL} File hash, or NULL if never synced */ - this.getStorageModificationTime = function (item, callback) { - var uri = _getItemPropertyURI(item); - var headers = _cachedCredentials.authHeader ? - { Authorization: _cachedCredentials.authHeader } : null; - - Zotero.Utilities.HTTP.doGet(uri, function (req) { - var funcName = "Zotero.Sync.Storage.getStorageModificationTime()"; - - // mod_speling can return 300s for 404s with base name matches - if (req.status == 404 || req.status == 300) { - callback(item, false); - return; - } - else if (req.status != 200) { - Zotero.debug(req.responseText); - _error("Unexpected status code " + req.status + " in " + funcName); - } - - Zotero.debug(req.responseText); - - var mtime = req.responseText; - // No modification time set - if (!mtime) { - callback(item, false); - return; - } - - var mdate = new Date(mtime * 1000); - callback(item, mdate); - }, headers); + this.getSyncedHash = function (itemID) { + var sql = "SELECT storageHash FROM itemAttachments WHERE itemID=?"; + var hash = Zotero.DB.valueQuery(sql, itemID); + if (hash === false) { + _error("Item " + itemID + + " not found in Zotero.Sync.Storage.getSyncedHash()"); + } + return hash; } /** - * Set mod time of file on storage server - * - * @param {Zotero.Item} item - * @param {Function} callback Callback f(item, mtime) + * @param {Integer} itemID + * @param {String} hash File hash + * @param {Boolean} [updateItem=FALSE] Update dateModified field of + * attachment item */ - this.setStorageModificationTime = function (item, callback) { - var uri = _getItemPropertyURI(item); - var headers = _cachedCredentials.authHeader ? - { Authorization: _cachedCredentials.authHeader } : null; - - Zotero.Utilities.HTTP.WebDAV.doPut(uri, item.attachmentModificationTime + '', function (req) { - switch (req.status) { - case 200: - case 201: - case 204: - break; - - default: - Zotero.debug(req.responseText); - throw ("Unexpected status code " + req.status + " in " - + "Zotero.Sync.Storage.setStorageModificationTime()"); - } - callback(item, item.attachmentModificationTime); - }, headers); + this.setSyncedHash = function (itemID, hash, updateItem) { + if (hash !== null && hash.length != 32) { + throw ("Invalid file hash '" + hash + "' in Zotero.Storage.setSyncedHash()"); + } + + Zotero.DB.beginTransaction(); + + var sql = "UPDATE itemAttachments SET storageHash=? WHERE itemID=?"; + Zotero.DB.valueQuery(sql, [hash, itemID]); + + if (updateItem) { + // Update item date modified so the new hash will be synced + var item = Zotero.Items.get(itemID); + item.setField('dateModified', Zotero.DB.transactionDateTime); + item.save(); + } + + Zotero.DB.commitTransaction(); } @@ -443,7 +273,8 @@ Zotero.Sync.Storage = new function () { */ this.isFileModified = function (itemID) { var item = Zotero.Items.get(itemID); - if (!item.getFile()) { + var file = item.getFile(); + if (!file) { return false; } @@ -454,6 +285,17 @@ Zotero.Sync.Storage = new function () { var syncModTime = Zotero.Sync.Storage.getSyncedModificationTime(itemID); if (fileModTime != syncModTime) { + var syncHash = Zotero.Sync.Storage.getSyncedHash(itemID); + if (syncHash) { + var fileHash = item.attachmentHash; + Zotero.debug('================'); + Zotero.debug(fileHash); + Zotero.debug(syncHash); + if (fileHash && fileHash == syncHash) { + Zotero.debug("Mod time didn't match but hash did for " + file.leafName + " -- ignoring"); + return false; + } + } return true; } @@ -467,24 +309,38 @@ Zotero.Sync.Storage = new function () { * * 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 + * @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 + * @param {Boolean} [includePersonalItems=false] + * @param {Boolean} [includeGroupItems=false] * @return {Boolean} TRUE if any items changed state, * FALSE otherwise */ - this.checkForUpdatedFiles = function (itemIDs, itemModTimes) { + this.checkForUpdatedFiles = function (itemIDs, itemModTimes, includeUserFiles, includeGroupFiles) { + var funcName = "Zotero.Sync.Storage.checkForUpdatedFiles()"; + Zotero.debug("Checking for locally changed attachment files"); // check for current ops? + if (itemIDs) { + if (includeUserFiles || includeGroupFiles) { + _error("includeUserFiles and includeGroupFiles are not allowed when itemIDs is set in " + funcName); + } + } + else { + if (!includeUserFiles && !includeGroupFiles) { + _error("At least one of includeUserFiles or includeGroupFiles must be set in " + funcName); + } + } + if (itemModTimes && !itemIDs) { - _error("itemModTimes can only be set if itemIDs is an array " - + "in Zotero.Sync.Storage.checkForUpdatedFiles()"); + _error("itemModTimes can only be set if itemIDs is an array in " + funcName); } var changed = false; @@ -503,9 +359,15 @@ Zotero.Sync.Storage = new function () { do { var chunk = itemIDs.splice(0, maxIDs); - var sql = "SELECT itemID, linkMode, path, storageModTime, syncState " + var sql = "SELECT itemID, linkMode, path, storageModTime, storageHash, syncState " + "FROM itemAttachments JOIN items USING (itemID) " - + "WHERE linkMode IN (?,?) AND syncState IN (?,?) AND libraryID IS NULL"; + + "WHERE linkMode IN (?,?) AND syncState IN (?,?)"; + if (includeUserFiles && !includeGroupFiles) { + sql += " AND libraryID IS NULL"; + } + else if (!includeUserFiles && includeGroupFiles) { + sql += " AND libraryID IS NOT NULL"; + } var params = [ Zotero.Attachments.LINK_MODE_IMPORTED_FILE, Zotero.Attachments.LINK_MODE_IMPORTED_URL, @@ -530,25 +392,17 @@ Zotero.Sync.Storage = new function () { return changed; } - // Index mtimes by item id + // Index data by item id var itemIDs = []; 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); attachmentData[id] = { linkMode: row.linkMode, path: row.path, mtime: row.storageModTime, + hash: row.storageHash, state: row.syncState }; } @@ -565,8 +419,7 @@ Zotero.Sync.Storage = new function () { 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; + updatedStates[item.id] = Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD; continue; } @@ -575,25 +428,43 @@ Zotero.Sync.Storage = new function () { //Zotero.debug("Stored mtime is " + attachmentData[item.id].mtime); //Zotero.debug("File mtime is " + fileModTime); + // Download-marking mode if (itemModTimes) { Zotero.debug("Item mod time is " + itemModTimes[item.id]); - } - - if (attachmentData[item.id].mtime != fileModTime) { - if (attachmentData[item.id].state == - Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD) { + + // Ignore attachments whose storage mod times haven't changed + if (row.storageModTime == itemModTimes[id]) { + Zotero.debug("Storage mod time (" + row.storageModTime + ") " + + "hasn't changed for attachment " + id); continue; } - Zotero.debug("Marking attachment " + item.id + " as changed (" - + attachmentData[item.id].mtime + " != " + fileModTime + ")"); - 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; + updatedStates[item.id] = Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD; + + continue; } + + // If stored time matches file, it hasn't changed + if (attachmentData[item.id].mtime == fileModTime) { + continue; + } + + // If file is already marked for upload, skip + if (attachmentData[item.id].state == Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD) { + continue; + } + + // If file hash matches stored hash, only the mod time changed, so skip + var fileHash = item.attachmentHash; + if (attachmentData[item.id].hash && attachmentData[item.id].hash == fileHash) { + Zotero.debug("Mod time didn't match but hash did for " + file.leafName + " -- ignoring"); + continue; + } + + Zotero.debug("Marking attachment " + item.id + " as changed (" + + attachmentData[item.id].mtime + " != " + fileModTime + ")"); + updatedStates[item.id] = Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD; } for (var itemID in updatedStates) { @@ -629,7 +500,7 @@ Zotero.Sync.Storage = new function () { _syncInProgress = true; } - var downloadFileIDs = _getFilesToDownload(); + var downloadFileIDs = _getFilesToDownload(_session.includeUserFiles, _session.includeGroupFiles); if (!downloadFileIDs) { Zotero.debug("No files to download"); return false; @@ -654,7 +525,7 @@ Zotero.Sync.Storage = new function () { } var request = new Zotero.Sync.Storage.Request( - item.key, Zotero.Sync.Storage.downloadFile + item.libraryID + '/' + item.key, function (request) { _session.downloadFile(request); } ); queue.addRequest(request); } @@ -666,93 +537,72 @@ Zotero.Sync.Storage = new function () { /** - * Begin download process for individual file + * Extract a downloaded file and update the database metadata * - * @param {Zotero.Sync.Storage.Request} [request] + * This is called from Zotero.Sync.Server.StreamListener.onStopRequest() + * + * @return {Object} data Properties 'request', 'item', 'compressed', 'syncModTime', 'syncHash' */ - this.downloadFile = function (request) { - var key = request.name; - var item = Zotero.Items.getByLibraryAndKey(null, key); - if (!item) { - _error("Item '" + key - + "' not found in Zotero.Sync.Storage.downloadFile()"); - } - - // Retrieve modification time from server to store locally afterwards - Zotero.Sync.Storage.getStorageModificationTime(item, function (item, mdate) { - if (!request.isRunning()) { - Zotero.debug("Download request '" + request.name - + "' is no longer running after getting mod time"); - return; - } - - if (!mdate) { - Zotero.debug("Remote file not found for item " + item.key); - request.finish(); - return; - } - - try { - var syncModTime = Zotero.Date.toUnixTimestamp(mdate); - - // Skip download if local file exists and matches mod time - var file = item.getFile(); - if (file && file.exists() - && syncModTime == Math.round(file.lastModifiedTime / 1000)) { - Zotero.debug("File mod time matches remote file -- skipping download"); - - Zotero.DB.beginTransaction(); - var syncState = Zotero.Sync.Storage.getSyncState(item.id); - var updateItem = syncState != 1; - Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); - Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); - Zotero.DB.commitTransaction(); - _changesMade = true; - request.finish(); - return; - } - - var uri = _getItemURI(item); - var destFile = Zotero.getTempDirectory(); - destFile.append(item.key + '.zip.tmp'); - if (destFile.exists()) { - destFile.remove(false); - } - - var listener = new Zotero.Sync.Storage.StreamListener( - { - onProgress: function (a, b, c) { - request.onProgress(a, b, c) - }, - onStop: _processDownload, - request: request, - 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); - } - catch (e) { - request.error(e); - } - }); + this.processDownload = function (data) { + var funcName = "Zotero.Sync.Storage.processDownload()"; + + if (!data) { + _error("|data| not set in " + funcName); + } + + if (!data.item) { + _error("|data.item| not set in " + funcName); + } + + if (!data.syncModTime) { + _error("|data.syncModTime| not set in " + funcName); + } + + if (!data.compressed && !data.syncHash) { + _error("|data.storageHash| is required if |data.compressed| is false in " + funcName); + } + + var item = data.item; + var syncModTime = data.syncModTime; + var syncHash = data.syncHash; + + // TODO: Test file hash + + if (data.compressed) { + _processZipDownload(item); + } + else { + _processDownload(item); + } + + var file = item.getFile(); + if (!file) { + throw ("File not found for item " + item.id + " after processing download in " + funcName); + } + file.lastModifiedTime = syncModTime * 1000; + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + + + var updateItem = syncState != 1; + var updateItem = false; + + + // Only save hash if file isn't compressed + if (!data.compressed) { + Zotero.Sync.Storage.setSyncedHash(item.id, syncHash, false); + } + Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + _changesMade = true; } /** * 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 () { @@ -760,7 +610,7 @@ Zotero.Sync.Storage = new function () { _syncInProgress = true; } - var uploadFileIDs = _getFilesToUpload(); + var uploadFileIDs = _getFilesToUpload(_session.includeUserFiles, _session.includeGroupFiles); if (!uploadFileIDs) { Zotero.debug("No files to upload"); return false; @@ -780,7 +630,7 @@ Zotero.Sync.Storage = new function () { var item = Zotero.Items.get(itemID); var request = new Zotero.Sync.Storage.Request( - item.key, Zotero.Sync.Storage.uploadFile + item.libraryID + '/' + item.key, function (request) { _session.uploadFile(request); } ); request.progressMax = Zotero.Attachments.getTotalFileSize(item, true); queue.addRequest(request); @@ -792,212 +642,35 @@ Zotero.Sync.Storage = new function () { } - this.uploadFile = function (request) { - _createUploadFile(request); - } - - - /** - * 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; + this.checkServer = function (module, callback) { + _session = new Zotero.Sync.Storage.Session(module, { onError: _error }); + if (!_session.initFromPrefs()) { + _error("Module not initialized"); } - - // Add .zip extension - var files = files.map(function (file) file + ".zip"); - - _deleteStorageFiles(files, function (results) { - // Remove deleted and nonexistent files from storage delete log - var toPurge = results.deleted.concat(results.missing); - if (toPurge.length > 0) { - var done = 0; - var maxFiles = 999; - var numFiles = toPurge.length; - - Zotero.DB.beginTransaction(); - - do { - var chunk = toPurge.splice(0, maxFiles); - var sql = "DELETE FROM storageDeleteLog WHERE key IN (" - + chunk.map(function () '?').join() + ")"; - Zotero.DB.query(sql, chunk); - done += chunk.length; - } - while (done < numFiles); - - Zotero.DB.commitTransaction(); - } - - if (callback) { - callback(results.deleted.length); - } - }); + _session.checkServer(callback); } - /** - * 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) { - Zotero.debug(req.responseText); - - var funcName = "Zotero.Sync.Storage.purgeOrphanedStorageFiles()"; - - // Strip XML declaration and convert to E4X - var xml = new XML(req.responseText.replace(/<\?xml.*\?>/, '')); - - var deleteFiles = []; - var trailingSlash = !!path.match(/\/$/); - for each(var response in xml.D::response) { - var href = response.D::href.toString(); - - // Strip trailing slash if there isn't one on the root path - if (!trailingSlash) { - href = href.replace(/\/$/, "") - } - - // Absolute - if (href.match(/^https?:\/\//)) { - var ios = Components.classes["@mozilla.org/network/io-service;1"]. - getService(Components.interfaces.nsIIOService); - var href = ios.newURI(href, null, null); - href = href.path; - } - - if (href.indexOf(path) == -1 - // Try URL-encoded as well, in case there's a '~' or similar - // character in the URL and the server (e.g., Sakai) is - // encoding the value - && decodeURIComponent(href).indexOf(path) == -1) { - _error("DAV:href '" + href - + "' does not begin with path '" + path + "' in " + funcName); - } - - // Skip root URI - if (href == path - // Try URL-encoded as well, as above - || decodeURIComponent(href) == path) { - continue; - } - - var matches = href.match(/[^\/]+$/); - if (!matches) { - _error("Unexpected href '" + href + "' in " + funcName) - } - var file = matches[0]; - - if (file.indexOf('.') == 0) { - Zotero.debug("Skipping hidden file " + file); - continue; - } - if (!file.match(/\.zip$/) && !file.match(/\.prop$/)) { - Zotero.debug("Skipping file " + file); - continue; - } - - var key = file.replace(/\.(zip|prop)$/, ''); - var item = Zotero.Items.getByLibraryAndKey(null, key); - if (item) { - Zotero.debug("Skipping existing file " + file); - continue; - } - - Zotero.debug("Checking orphaned file " + file); - - // TODO: Parse HTTP date properly - var lastModified = response..*::getlastmodified.toString(); - lastModified = Zotero.Date.strToISO(lastModified); - lastModified = Zotero.Date.sqlToDate(lastModified); - - // Delete files older than a day before last sync time - var days = (lastSyncDate - lastModified) / 1000 / 60 / 60 / 24; - - // DEBUG!!!!!!!!!!!! - // - // For now, delete all orphaned files immediately - if (true) { - deleteFiles.push(file); - } else - - if (days > daysBeforeSyncTime) { - deleteFiles.push(file); - } - } - - _deleteStorageFiles(deleteFiles, callback); - }, - { Depth: 1 }); + this.checkServerCallback = function (uri, status, window, skipSuccessMessage) { + return _session.checkServerCallback(uri, status, window, skipSuccessMessage); } - /** - * 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.purgeDeletedStorageFiles = function (module, callback) { + _session = new Zotero.Sync.Storage.Session(module, { onError: _error }); + if (!_session.initFromPrefs()) { + _error("Module not initialized"); + } + _session.purgeDeletedStorageFiles(callback); } - this.resetAllSyncStates = function (syncState) { + this.resetAllSyncStates = function (syncState, includeUserFiles, includeGroupFiles) { + if (!includeUserFiles && !includeGroupFiles) { + includeUserFiles = true; + includeGroupFiles = true; + } + if (!syncState) { syncState = this.SYNC_STATE_TO_UPLOAD; } @@ -1013,172 +686,185 @@ Zotero.Sync.Storage = new function () { + "Zotero.Sync.Storage.resetAllSyncStates()"); } - var sql = "UPDATE itemAttachments SET syncState=? WHERE itemID IN " - + "(SELECT itemID FROM items WHERE libraryID IS NULL)"; + //var sql = "UPDATE itemAttachments SET syncState=?, storageModTime=NULL, storageHash=NULL"; + var sql = "UPDATE itemAttachments SET syncState=?"; + if (includeUserFiles && !includeGroupFiles) { + sql += " WHERE itemID IN (SELECT itemID FROM items WHERE libraryID IS NULL)"; + } + else if (!includeUserFiles && includeGroupFiles) { + sql += " WHERE itemID IN (SELECT itemID FROM items WHERE libraryID IS NOT NULL)"; + } Zotero.DB.query(sql, [syncState]); - var sql = "DELETE FROM version WHERE schema='storage'"; + var sql = "DELETE FROM version WHERE schema LIKE 'storage_%'"; Zotero.DB.query(sql); } - this.clearSettingsCache = function () { - _rootURI = undefined; + this.getItemFromRequestName = function (name) { + var [libraryID, key] = name.split('/'); + if (libraryID == "null") { + libraryID = null; + } + return Zotero.Items.getByLibraryAndKey(libraryID, key); } // // Private methods // + function _processDownload(item) { + var funcName = "Zotero.Sync.Storage._processDownload()"; + + var tempFile = Zotero.getTempDirectory(); + tempFile.append(item.key + '.tmp'); + + if (!tempFile.exists()) { + Zotero.debug(tempFile.path); + throw ("Downloaded file not found in " + funcName); + } + + var parentDir = Zotero.Attachments.getStorageDirectory(item.id); + if (!parentDir.exists()) { + Zotero.Attachments.createDirectoryForItem(item.id); + } + + _deleteExistingAttachmentFiles(item); + + var file = item.getFile(null, true); + if (!file) { + throw ("Empty path for item " + item.key + " in " + funcName); + } + var newName = file.leafName; + + Zotero.debug("Moving download file " + tempFile.leafName + " into attachment directory"); + tempFile.moveTo(parentDir, newName); + } - /** - * 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 'request', 'item', 'syncModTime' - */ - function _processDownload(request, status, response, data) { + function _processZipDownload(item) { + var funcName = "Zotero.Sync.Storage._processDownloadedZip()"; + + var zipFile = Zotero.getTempDirectory(); + zipFile.append(item.key + '.zip.tmp'); + + if (!zipFile.exists()) { + throw ("Downloaded ZIP file not found in " + funcName); + } + + var zipReader = Components.classes["@mozilla.org/libjar/zip-reader;1"]. + createInstance(Components.interfaces.nsIZipReader); try { - var funcName = "Zotero.Sync.Storage._processDownload()"; + zipReader.open(zipFile); + zipReader.test(null); - var request = data.request; - 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"); + Zotero.debug("ZIP file is OK"); + } + catch (e) { + Zotero.debug(zipFile.leafName + " is not a valid ZIP file", 2); + if (zipFile.exists()) { + zipFile.remove(false); } - catch (e) { - Zotero.debug(zipFile.leafName + " is not a valid ZIP file", 2); - if (zipFile.exists()) { - zipFile.remove(false); - } - return; + return; + } + + var parentDir = Zotero.Attachments.getStorageDirectory(item.id); + if (!parentDir.exists()) { + Zotero.Attachments.createDirectoryForItem(item.id); + } + + _deleteExistingAttachmentFiles(item); + + 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, '') + ); } - - var parentDir = Zotero.Attachments.getStorageDirectory(item.id); - if (!parentDir.exists()) { - Zotero.Attachments.createDirectoryForItem(item.id); + else { + var fileName = entryName; } - // Delete existing files - var otherFiles = parentDir.directoryEntries; - while (otherFiles.hasMoreElements()) { - var file = otherFiles.getNext(); - file.QueryInterface(Components.interfaces.nsIFile); - if (file.leafName[0] == '.' || file.equals(zipFile)) { - continue; - } - - // Firefox (as of 3.0.1) can't detect symlinks (at least on OS X), - // so use pre/post-normalized path to check - var origPath = file.path; - var origFileName = file.leafName; - file.normalize(); - if (origPath != file.path) { - var msg = "Not deleting symlink '" + origFileName + "'"; - Zotero.debug(msg, 2); - Components.utils.reportError(msg + " in " + funcName); - continue; - } - // This should be redundant with above check, but let's do it anyway - if (!parentDir.contains(file, false)) { - var msg = "Storage directory doesn't contain '" + file.leafName + "'"; - Zotero.debug(msg, 2); - Components.utils.reportError(msg + " in " + funcName); - continue; - } - - if (file.isFile()) { - Zotero.debug("Deleting existing file " + file.leafName); - file.remove(false); - } - else if (file.isDirectory()) { - Zotero.debug("Deleting existing directory " + file.leafName); - file.remove(true); - } + if (fileName.indexOf('.') == 0) { + Zotero.debug("Skipping " + fileName); + continue; } - 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('.') == 0) { - Zotero.debug("Skipping " + fileName); - continue; - } - - Zotero.debug("Extracting " + fileName); - var destFile = parentDir.clone(); - destFile.QueryInterface(Components.interfaces.nsILocalFile); - destFile.setRelativeDescriptor(parentDir, fileName); - if (destFile.exists()) { - var msg = "ZIP entry '" + fileName + "' " - + " already exists"; - Zotero.debug(msg, 2); - Components.utils.reportError(msg + " in " + funcName); - continue; - } - destFile.create(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644); - zipReader.extract(entryName, destFile); - - var origPath = destFile.path; - var origFileName = destFile.leafName; - destFile.normalize(); - if (origPath != destFile.path) { - var msg = "ZIP file " + zipFile.leafName + " contained symlink '" - + origFileName + "'"; - Zotero.debug(msg, 1); - Components.utils.reportError(msg + " in " + funcName); - continue; - } - destFile.permissions = 0644; + Zotero.debug("Extracting " + fileName); + var destFile = parentDir.clone(); + destFile.QueryInterface(Components.interfaces.nsILocalFile); + destFile.setRelativeDescriptor(parentDir, fileName); + if (destFile.exists()) { + var msg = "ZIP entry '" + fileName + "' " + + " already exists"; + Zotero.debug(msg, 2); + Components.utils.reportError(msg + " in " + funcName); + continue; } - zipReader.close(); - zipFile.remove(false); + destFile.create(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, 0644); + zipReader.extract(entryName, destFile); - var file = item.getFile(); - if (!file) { - var msg = "File not found for item " + item.id + " after extracting ZIP"; + var origPath = destFile.path; + var origFileName = destFile.leafName; + destFile.normalize(); + if (origPath != destFile.path) { + var msg = "ZIP file " + zipFile.leafName + " contained symlink '" + + origFileName + "'"; Zotero.debug(msg, 1); Components.utils.reportError(msg + " in " + funcName); - return; + continue; } - 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(); - _changesMade = true; + destFile.permissions = 0644; } - finally { - request.finish(); + zipReader.close(); + zipFile.remove(false); + } + + + function _deleteExistingAttachmentFiles(item) { + var funcName = "Zotero.Sync.Storage._deleteExistingAttachmentFiles()"; + + var parentDir = Zotero.Attachments.getStorageDirectory(item.id); + + // Delete existing files + var otherFiles = parentDir.directoryEntries; + while (otherFiles.hasMoreElements()) { + var file = otherFiles.getNext(); + file.QueryInterface(Components.interfaces.nsIFile); + if (file.leafName[0] == '.') { + continue; + } + + // Firefox (as of 3.0.1) can't detect symlinks (at least on OS X), + // so use pre/post-normalized path to check + var origPath = file.path; + var origFileName = file.leafName; + file.normalize(); + if (origPath != file.path) { + var msg = "Not deleting symlink '" + origFileName + "'"; + Zotero.debug(msg, 2); + Components.utils.reportError(msg + " in " + funcName); + continue; + } + // This should be redundant with above check, but let's do it anyway + if (!parentDir.contains(file, false)) { + var msg = "Storage directory doesn't contain '" + file.leafName + "'"; + Zotero.debug(msg, 2); + Components.utils.reportError(msg + " in " + funcName); + continue; + } + + if (file.isFile()) { + Zotero.debug("Deleting existing file " + file.leafName); + file.remove(false); + } + else if (file.isDirectory()) { + Zotero.debug("Deleting existing directory " + file.leafName); + file.remove(true); + } } } @@ -1187,13 +873,13 @@ Zotero.Sync.Storage = new function () { * Create zip file of attachment directory * * @param {Zotero.Sync.Storage.Request} request + * @param {Function} callback * @return {Boolean} TRUE if zip process started, * FALSE if storage was empty */ - function _createUploadFile(request) { - var key = request.name; - var item = Zotero.Items.getByLibraryAndKey(null, key); - Zotero.debug("Creating zip file for item " + item.key); + this.createUploadFile = function (request, callback) { + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + Zotero.debug("Creating zip file for item " + item.libraryID + "/" + item.key); try { switch (item.attachmentLinkMode) { @@ -1205,7 +891,7 @@ Zotero.Sync.Storage = new function () { )); } - var dir = Zotero.Attachments.getStorageDirectoryByKey(key); + var dir = Zotero.Attachments.getStorageDirectoryByKey(item.key); var tmpFile = Zotero.getTempDirectory(); tmpFile.append(item.key + '.zip'); @@ -1224,7 +910,7 @@ Zotero.Sync.Storage = new function () { Zotero.debug('Creating ' + tmpFile.leafName + ' with ' + fileList.length + ' file(s)'); var observer = new Zotero.Sync.Storage.ZipWriterObserver( - zw, _processUploadFile, { request: request, files: fileList } + zw, callback, { request: request, files: fileList } ); zw.processQueue(observer, null); return true; @@ -1244,229 +930,50 @@ Zotero.Sync.Storage = new function () { if (file.isDirectory()) { //Zotero.debug("Recursing into directory " + file.leafName); fileList.concat(_zipDirectory(rootDir, file, zipWriter)); - continue; - } - var fileName = file.getRelativeDescriptor(rootDir); - if (fileName.indexOf('.') == 0) { - Zotero.debug('Skipping file ' + fileName); - continue; - } - - //Zotero.debug("Adding file " + fileName); - - fileName = Zotero.Utilities.Base64.encode(fileName) + "%ZB64"; - zipWriter.addEntryFile( - fileName, - Components.interfaces.nsIZipWriter.COMPRESSION_DEFAULT, - file, - true - ); - fileList.push(fileName); - } - return fileList; - } - - - /** - * Upload the generated ZIP file to the server - * - * @param {Object} Object with 'request' property - * @return {void} - */ - function _processUploadFile(data) { - /* - _updateSizeMultiplier( - (100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100 - ); - */ - - var request = data.request; - var item = Zotero.Items.getByLibraryAndKey(null, request.name); - - Zotero.Sync.Storage.getStorageModificationTime(item, function (item, mdate) { - if (!request.isRunning()) { - Zotero.debug("Upload request '" + request.name - + "' is no longer running after getting mod time"); - return; - } - - try { - // Check for conflict - if (Zotero.Sync.Storage.getSyncState(item.id) - != Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD) { - if (mdate) { - // Remote prop time - var mtime = Zotero.Date.toUnixTimestamp(mdate); - // Local file time - var fmtime = item.attachmentModificationTime; - - if (fmtime == mtime) { - Zotero.debug("File mod time matches remote file -- skipping upload"); - - Zotero.DB.beginTransaction(); - var syncState = Zotero.Sync.Storage.getSyncState(item.id); - Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime, true); - Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); - Zotero.DB.commitTransaction(); - _changesMade = true; - request.finish(); - return; - } - - var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id); - if (smtime != mtime) { - var localData = { modTime: fmtime }; - var remoteData = { modTime: mtime }; - Zotero.Sync.Storage.QueueManager.addConflict( - request.name, localData, remoteData - ); - Zotero.debug("Conflict -- last synced file mod time " - + "does not match time on storage server" - + " (" + smtime + " != " + mtime + ")"); - request.finish(); - return; - } - } - else { - Zotero.debug("Remote file not found for item " + item.id); - } - } - - var file = Zotero.getTempDirectory(); - file.append(item.key + '.zip'); - - var fis = Components.classes["@mozilla.org/network/file-input-stream;1"] - .createInstance(Components.interfaces.nsIFileInputStream); - fis.init(file, 0x01, 0, 0); - - var bis = Components.classes["@mozilla.org/network/buffered-input-stream;1"] - .createInstance(Components.interfaces.nsIBufferedInputStream) - bis.init(fis, 64 * 1024); - - var uri = _getItemURI(item); - - var ios = Components.classes["@mozilla.org/network/io-service;1"]. - getService(Components.interfaces.nsIIOService); - var channel = ios.newChannelFromURI(uri); - channel.QueryInterface(Components.interfaces.nsIUploadChannel); - channel.setUploadStream(bis, 'application/octet-stream', -1); - channel.QueryInterface(Components.interfaces.nsIHttpChannel); - channel.requestMethod = 'PUT'; - channel.allowPipelining = false; - 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: function (a, b, c) { - request.onProgress(a, b, c); - }, - onStop: _onUploadComplete, - onCancel: _onUploadCancel, - request: request, - item: item, - streams: [fis, bis] - } - ); - channel.notificationCallbacks = listener; - - var dispURI = uri.clone(); - if (dispURI.password) { - dispURI.password = '********'; - } - Zotero.debug("HTTP PUT of " + file.leafName + " to " + dispURI.spec); - - channel.asyncOpen(listener, null); - } - catch (e) { - request.error(e); - } - }); - } - - - function _onUploadComplete(httpRequest, status, response, data) { - var request = data.request; - var item = data.item; - var url = httpRequest.name; - - Zotero.debug("Upload of attachment " + item.key - + " finished with status code " + status); - - switch (status) { - case 200: - case 201: - case 204: - break; - - default: - _error("Unexpected file upload status " + status - + " in Zotero.Sync.Storage._onUploadComplete()"); - } - - Zotero.Sync.Storage.setStorageModificationTime(item, function (item, mtime) { - if (!request.isRunning()) { - Zotero.debug("Upload request '" + request.name - + "' is no longer running after getting mod time"); - return; - } - - 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(); - - try { - var file = Zotero.getTempDirectory(); - file.append(item.key + '.zip'); - file.remove(false); + continue; } - catch (e) { - Components.utils.reportError(e); + var fileName = file.getRelativeDescriptor(rootDir); + if (fileName.indexOf('.') == 0) { + Zotero.debug('Skipping file ' + fileName); + continue; } - _changesMade = true; - request.finish(); - }); - } - - - function _onUploadCancel(httpRequest, status, data) { - var request = data.request; - var item = data.item; - - Zotero.debug("Upload of attachment " + item.key - + " cancelled with status code " + status); - - try { - var file = Zotero.getTempDirectory(); - file.append(item.key + '.zip'); - file.remove(false); - } - catch (e) { - Components.utils.reportError(e); + //Zotero.debug("Adding file " + fileName); + + fileName = Zotero.Utilities.Base64.encode(fileName) + "%ZB64"; + zipWriter.addEntryFile( + fileName, + Components.interfaces.nsIZipWriter.COMPRESSION_DEFAULT, + file, + true + ); + fileList.push(fileName); } - - request.finish(); + return fileList; } + /** * Get files marked as ready to upload * * @inner * @return {Number[]} Array of attachment itemIDs */ - function _getFilesToDownload() { + function _getFilesToDownload(includeUserFiles, includeGroupFiles) { + if (!includeUserFiles && !includeGroupFiles) { + _error("At least one of includeUserFiles or includeGroupFiles must be set " + + "in Zotero.Sync.Storage._getFilesToDownload()"); + } + var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) " - + "WHERE syncState IN (?,?) AND libraryID IS NULL"; + + "WHERE syncState IN (?,?)"; + if (includeUserFiles && !includeGroupFiles) { + sql += " AND libraryID IS NULL"; + } + else if (!includeUserFiles && includeGroupFiles) { + sql += " AND libraryID IS NOT NULL"; + } return Zotero.DB.columnQuery(sql, [ Zotero.Sync.Storage.SYNC_STATE_TO_DOWNLOAD, @@ -1482,9 +989,20 @@ Zotero.Sync.Storage = new function () { * @inner * @return {Number[]} Array of attachment itemIDs */ - function _getFilesToUpload() { + function _getFilesToUpload(includeUserFiles, includeGroupFiles) { + if (!includeUserFiles && !includeGroupFiles) { + _error("At least one of includeUserFiles or includeGroupFiles must be set " + + "in Zotero.Sync.Storage._getFilesToUpload()"); + } + var sql = "SELECT itemID FROM itemAttachments JOIN items USING (itemID) " - + "WHERE syncState IN (?,?) AND linkMode IN (?,?) AND libraryID IS NULL"; + + "WHERE syncState IN (?,?) AND linkMode IN (?,?)"; + if (includeUserFiles && !includeGroupFiles) { + sql += " AND libraryID IS NULL"; + } + else if (!includeUserFiles && includeGroupFiles) { + sql += " AND libraryID IS NOT NULL"; + } return Zotero.DB.columnQuery(sql, [ Zotero.Sync.Storage.SYNC_STATE_TO_UPLOAD, @@ -1502,7 +1020,7 @@ Zotero.Sync.Storage = new function () { * Number of days old files have to be * @return {String[]|FALSE} Array of keys, or FALSE if none */ - function _getDeletedFiles(days) { + this.getDeletedFiles = function (days) { if (!days) { days = Zotero.Prefs.get("sync.storage.deleteDelayDays"); } @@ -1515,673 +1033,113 @@ Zotero.Sync.Storage = new function () { } - /** - * @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]; - - let 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: - // IIS 5.1 and Sakai return 200 - case 200: - var fileDeleted = true; - break; - - case 404: - var fileDeleted = false; - break; - - default: - if (last && callback) { - callback(results); - } - - results.error.push(fileName); - var msg = "An error occurred attempting to delete " - + "'" + fileName - + "' (" + req.status + " " + req.statusText + ")."; - _error(msg); - return; - } - - // If an item file URI, get the property URI - var deletePropURI = _getPropertyURIFromItemURI(deleteURI); - if (!deletePropURI) { - if (fileDeleted) { - results.deleted.push(fileName); - } - else { - results.missing.push(fileName); - } - if (last && callback) { - callback(results); - } - return; - } - - // If property file appears separately in delete queue, - // remove it, since we're taking care of it here - var propIndex = files.indexOf(deletePropURI.fileName); - if (propIndex > i) { - delete files[propIndex]; - i--; - last = (i == files.length - 1); - } - - // Delete property file - Zotero.Utilities.HTTP.WebDAV.doDelete(deletePropURI, function (req) { - switch (req.status) { - case 204: - // IIS 5.1 and Sakai return 200 - case 200: - results.deleted.push(fileName); - break; - - case 404: - if (fileDeleted) { - results.deleted.push(fileName); - } - else { - results.missing.push(fileName); - } - break; - - default: - var error = true; - } - - if (last && callback) { - callback(results); - } - - if (error) { - results.error.push(fileName); - var msg = "An error occurred attempting to delete " - + "'" + fileName - + "' (" + req.status + " " + req.statusText + ")."; - _error(msg); - } - }); - }); - } - } - - - /** - * @param {Function} callback Function to pass URI and result value to - * @param {Object} errorCallbacks - */ - 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_PASSWORD': - callback(null, Zotero.Sync.Storage.ERROR_NO_PASSWORD); - return; - - default: - Zotero.debug(e); - Components.utils.reportError(e); - callback(null, Zotero.Sync.Storage.ERROR_UNKNOWN); - return; - } - } - - var requestHolder = { request: null }; - - var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; - var D = new Namespace("D", "DAV:"); - var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"'; - - var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); - requestXML.D::prop = ''; - // IIS 5.1 requires at least one property in PROPFIND - requestXML.D::prop.D::getcontentlength = ''; - - var xmlstr = prolog + requestXML.toXMLString(); - - // Test whether URL is WebDAV-enabled - var request = Zotero.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 200: - case 201: - case 204: - // Delete test file - Zotero.Utilities.HTTP.WebDAV.doDelete( - testFileURI, - function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - - switch (req.status) { - case 200: // IIS 5.1 and Sakai return 200 - case 204: - callback( - uri, - Zotero.Sync.Storage.SUCCESS, - !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 see 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 400: - callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST); - return; - - case 401: - callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); - return; - - // Parent directory wasn't found either - case 404: - callback(uri, Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND); - return; - - default: - callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); - return; - } - }, 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; - } - - - this.checkServerCallback = function (uri, status, authRequired, window, skipSuccessMessage) { - var promptService = - Components.classes["@mozilla.org/embedcomp/prompt-service;1"]. - createInstance(Components.interfaces.nsIPromptService); - if (uri) { - var spec = uri.scheme + '://' + uri.hostPort + uri.path; - } - - switch (status) { - case Zotero.Sync.Storage.SUCCESS: - if (!skipSuccessMessage) { - promptService.alert( - window, - "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 WebDAV URL."; - break; - - case Zotero.Sync.Storage.ERROR_NO_PASSWORD: - var errorMessage = "Please enter a password."; - 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 WebDAV server did not accept the " - + "username and password you entered." + " " - + "Please check your storage 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 the WebDAV server." + " " - + "Please check your file sync 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( - window, - // 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: - if (!skipSuccessMessage) { - promptService.alert( - window, - "Server configuration verified", - "File sync 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 file sync settings or " - + "contact your server administrator."; - break; - } - - // TEMP - if (!errorMessage) { - var errorMessage = status; - } - promptService.alert(window, errorTitle, errorMessage); - }); - - return false; - } - - if (!skipSuccessMessage) { - if (!errorTitle) { - var errorTitle = Zotero.getString("general.error"); - } - // TEMP - if (!errorMessage) { - var errorMessage = status; - } - promptService.alert(window, errorTitle, errorMessage); - } - return false; - } - - this.finish = function (cancelled, skipSuccessFile) { if (!_syncInProgress) { throw ("Sync not in progress in Zotero.Sync.Storage.finish()"); } // Upload success file when done - if (!cancelled && !this.resyncOnFinish && !skipSuccessFile) { - _uploadSuccessFile(function () { - Zotero.Sync.Storage.finish(false, true); - }); + if (!this.resyncOnFinish && !skipSuccessFile) { + // If we finished successfully and didn't upload any files, save the + // last sync time locally rather than setting a new one on the server, + // since we don't want other clients to check for new files + var uploadQueue = Zotero.Sync.Storage.QueueManager.get('upload'); + var useLastSyncTime = !cancelled && uploadQueue.totalRequests == 0; + + _session.setLastSyncTime(function () { + Zotero.Sync.Storage.finish(cancelled, true); + }, useLastSyncTime); return; } - Zotero.debug("Storage sync is complete"); + Zotero.debug(_session.name + " sync is complete"); _syncInProgress = false; - if (!cancelled && this.resyncOnFinish) { + if (this.resyncOnFinish) { Zotero.debug("Force-resyncing items in conflict"); this.resyncOnFinish = false; - this.sync(); + this.sync(_session.module, _callbacks); return; } - if (cancelled || !_changesMade) { - if (!_changesMade) { - Zotero.debug("No changes made during storage sync"); - } - Zotero.Sync.Runner.reset(); - } + _session = null; - Zotero.Sync.Runner.next(); - } - - - function _getSuccessFileTimestamp(callback) { - try { - var uri = Zotero.Sync.Storage.rootURI; - var successFileURI = uri.clone(); - successFileURI.spec += "lastsync"; - Zotero.Utilities.HTTP.doGet(successFileURI, function (req) { - var ts = undefined; - try { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - var lastModified = req.getResponseHeader("Last-modified"); - var date = new Date(lastModified); - Zotero.debug("Last successful storage sync was " + date); - var ts = Zotero.Date.toUnixTimestamp(date); - } - finally { - callback(ts); - } - }); - return; + if (!_changesMade) { + Zotero.debug("No changes made during storage sync"); } - catch (e) { - Zotero.debug(e); - Components.utils.reportError(e); - callback(); + + if (cancelled) { + _callbacks.onStop(); return; } - } - - - function _uploadSuccessFile(callback) { - try { - var uri = Zotero.Sync.Storage.rootURI; - var successFileURI = uri.clone(); - successFileURI.spec += "lastsync"; - Zotero.Utilities.HTTP.WebDAV.doPut(successFileURI, "", function (req) { - Zotero.debug(req.responseText); - Zotero.debug(req.status); - - switch (req.status) { - case 200: - case 201: - case 204: - _getSuccessFileTimestamp(function (ts) { - if (ts) { - var sql = "REPLACE INTO version VALUES ('storage', ?)"; - Zotero.DB.query(sql, { int: ts }); - } - if (callback) { - callback(); - } - }); - return; - } - - var msg = "Unexpected error code " + req.status + " uploading storage success file"; - Zotero.debug(msg, 2); - Components.utils.reportError(msg); - if (callback) { - callback(); - } - }); - } - catch (e) { - Zotero.debug(e); - Components.utils.reportError(e); - if (callback) { - callback(); - } + + if (!_changesMade) { + _callbacks.onSkip(); return; } + + _callbacks.onSuccess(); } - /** - * 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; - } - - - /** - * Get the storage property file URI for an item - * - * @inner - * @param {Zotero.Item} - * @return {nsIURI} URI of property file on storage server - */ - function _getItemPropertyURI(item) { - var uri = Zotero.Sync.Storage.rootURI; - uri.spec = uri.spec + item.key + '.prop'; - return uri; - } - - - /** - * Get the storage property file URI corresponding to a given item storage URI - * - * @param {nsIURI} Item storage URI - * @return {nsIURI|FALSE} Property file URI, or FALSE if not an item storage URI - */ - function _getPropertyURIFromItemURI(uri) { - if (!uri.spec.match(/\.zip$/)) { - return false; - } - var propURI = uri.clone(); - propURI.QueryInterface(Components.interfaces.nsIURL); - propURI.fileName = uri.fileName.replace(/\.zip$/, '.prop'); - propURI.QueryInterface(Components.interfaces.nsIURI); - return propURI; - } - - - /** - * @inner - * @param {XMLHTTPRequest} req - * @throws - */ - function _checkResponse(req) { - if (!req.responseText) { - _error('Empty response from server'); - } - if (!req.responseXML || - !req.responseXML.firstChild || - !(req.responseXML.firstChild.namespaceURI == 'DAV:' && - req.responseXML.firstChild.localName == 'multistatus') || - !req.responseXML.childNodes[0].firstChild) { - Zotero.debug(req.responseText); - _error('Invalid response from storage server'); - } - } - - - // // Stop requests, log error, and // function _error(e) { if (_syncInProgress) { - Zotero.Sync.Storage.QueueManager.cancel(); + Zotero.Sync.Storage.QueueManager.cancel(true); _syncInProgress = false; + _session = null; } Zotero.DB.rollbackAllTransactions(); Zotero.debug(e, 1); - Zotero.Sync.Runner.setError(e.message ? e.message : e); - Zotero.Sync.Runner.reset(); - throw (e); + + // If we get a quota error, log and continue + if (e.error && e.error == Zotero.Error.ERROR_ZFS_OVER_QUOTA && _callbacks.onWarning) { + _callbacks.onWarning(e); + _callbacks.onSuccess(); + } + else if (e.error && e.error == Zotero.Error.ERROR_ZFS_FILE_EDITING_DENIED) { + setTimeout(function () { + var group = Zotero.Groups.get(e.data.groupID); + + var pr = Components.classes["@mozilla.org/network/default-prompt;1"] + .createInstance(Components.interfaces.nsIPrompt); + var buttonFlags = (pr.BUTTON_POS_0) * (pr.BUTTON_TITLE_IS_STRING) + + (pr.BUTTON_POS_1) * (pr.BUTTON_TITLE_CANCEL) + + pr.BUTTON_DELAY_ENABLE; + var index = pr.confirmEx( + Zotero.getString('general.warning'), + // TODO: localize + "You no longer have file editing access to the Zotero group '" + group.name + "', " + + "and files you've added or edited cannot be synced to the server.\n\n" + + "If you continue, your copy of the group will be reset to its state " + + "on the server, and local modifications to items and files will be lost.\n\n" + + "If you would like a chance to copy changed items and files elsewhere, " + + "cancel the sync now.", + buttonFlags, + "Reset Group and Sync", + null, null, null, {} + ); + + if (index == 0) { + group.erase(); + Zotero.Sync.Server.resetClient(); + Zotero.Sync.Storage.resetAllSyncStates(); + Zotero.Sync.Runner.sync(); + return; + } + }, 1); + _callbacks.onError(e); + } + else if (_callbacks.onError) { + _callbacks.onError(e); + } + else { + throw (e); + } } } @@ -2212,6 +1170,9 @@ Zotero.Sync.Storage.QueueManager = new function () { queue.maxConcurrentRequests = Zotero.Prefs.get('sync.storage.maxUploads') break; + + default: + throw ("Invalid queue '" + queueName + "' in Zotero.Sync.Storage.QueueManager.get()"); } _queues[queueName] = queue; } @@ -2222,11 +1183,20 @@ Zotero.Sync.Storage.QueueManager = new function () { /** * Stop all queues + * + * @param {Boolean} [skipStorageFinish=false] Don't call Zotero.Sync.Storage.finish() + * when done (used when we stopped because of + * an error) */ - this.cancel = function () { + this.cancel = function (skipStorageFinish) { this._cancelled = true; + if (skipStorageFinish) { + this._skipStorageFinish = true; + } for each(var queue in _queues) { - queue.stop(); + if (!queue.isFinished() && !queue.isStopping()) { + queue.stop(); + } } _conflicts = []; } @@ -2244,6 +1214,12 @@ Zotero.Sync.Storage.QueueManager = new function () { _conflicts = []; } + if (this._skipStorageFinish) { + this._cancelled = false; + this._skipStorageFinish = false; + return; + } + Zotero.Sync.Storage.finish(this._cancelled); this._cancelled = false; } @@ -2336,6 +1312,10 @@ Zotero.Sync.Storage.QueueManager = new function () { this.addConflict = function (requestName, localData, remoteData) { + Zotero.debug('==========='); + Zotero.debug(localData); + Zotero.debug(remoteData); + _conflicts.push({ name: requestName, localData: localData, @@ -2380,7 +1360,7 @@ Zotero.Sync.Storage.QueueManager = new function () { function _reconcileConflicts() { var objectPairs = []; for each(var conflict in _conflicts) { - var item = Zotero.Items.getByLibraryAndKey(null, conflict.name); + var item = Zotero.Sync.Storage.getItemFromRequestName(conflict.name); var item1 = item.clone(false, false, true); item1.setField('dateModified', Zotero.Date.dateToSQL(new Date(conflict.localData.modTime * 1000), true)); @@ -2415,7 +1395,7 @@ Zotero.Sync.Storage.QueueManager = new function () { // Since we're only putting cloned items into the merge window, // we have to manually set the ids for (var i=0; i<_conflicts.length; i++) { - io.dataOut[i].id = Zotero.Items.getByLibraryAndKey(null, _conflicts[i].name).id; + io.dataOut[i].id = Zotero.Sync.Storage.getItemFromRequestName(_conflicts[i].name).id; } return io.dataOut; @@ -2467,6 +1447,8 @@ Zotero.Sync.Storage.Queue = function (name) { }); this.maxConcurrentRequests = 1; + this.__defineGetter__('running', function () _running); + this.__defineGetter__('stopping', function () _stopping); this.activeRequests = 0; this.__defineGetter__('finishedRequests', function () { return _finishedReqs; @@ -2486,8 +1468,10 @@ Zotero.Sync.Storage.Queue = function (name) { Zotero.debug(this.Name + " queue is done"); // DEBUG info Zotero.debug("Active requests: " + this.activeRequests); - Zotero.debug("Errors:"); - Zotero.debug(this._errors); + if (this._errors) { + Zotero.debug("Errors:"); + Zotero.debug(this._errors); + } if (this.activeRequests) { throw (this.Name + " queue can't be finished if there " @@ -2745,7 +1729,7 @@ function _updateSizeMultiplier(mult) { /** * Transfer request for storage sync * - * @param {String} name Identifier for request (e.g., item key) + * @param {String} name Identifier for request (e.g., "[libraryID]/[key]") * @param {Function} onStart Callback when request is started */ Zotero.Sync.Storage.Request = function (name, onStart) { @@ -2805,11 +1789,11 @@ Zotero.Sync.Storage.Request.prototype.__defineGetter__('remaining', function () } else if (remaining > this._remaining) { Zotero.debug(remaining + " is greater than the last remaining amount of " - + this._remaining); + + this._remaining + " for request " + this.name); remaining = this._remaining; } else if (remaining < 0) { - Zotero.debug(); + Zotero.debug(remaining + " is less than 0 for request " + this.name); } else { this._remaining = remaining; @@ -2819,6 +1803,11 @@ Zotero.Sync.Storage.Request.prototype.__defineGetter__('remaining', function () }); +Zotero.Sync.Storage.Request.prototype.setChannel = function (channel) { + this.channel = channel; +} + + Zotero.Sync.Storage.Request.prototype.start = function () { if (!this.queue) { throw ("Request '" + this.name + "' must be added to a queue before starting"); @@ -2858,7 +1847,7 @@ Zotero.Sync.Storage.Request.prototype.isFinished = function () { */ Zotero.Sync.Storage.Request.prototype.onProgress = function (channel, progress, progressMax) { if (!this._running) { - throw ("Trying to update a finished request in " + throw ("Trying to update finished request " + this.name + " in " + "Zotero.Sync.Storage.Request.onProgress()"); } @@ -2899,7 +1888,24 @@ Zotero.Sync.Storage.Request.prototype.error = function (msg) { * Stop the request's underlying network request, if there is one */ Zotero.Sync.Storage.Request.prototype.stop = function () { - if (!this._running || !this.channel) { + var finishNow = false; + try { + // If upload already finished, finish() will never be called otherwise + if (this.channel) { + this.channel.QueryInterface(Components.interfaces.nsIHttpChannel); + // Throws error if request not finished + this.channel.requestSucceeded; + Zotero.debug("Channel is no longer running for request " + this.name); + Zotero.debug(this.channel.requestSucceeded); + finishNow = true; + } + else { + Zotero.debug("No channel to stop for request " + this.name); + } + } + catch (e) { Zotero.debug(e); } + + if (!this._running || !this.channel || finishNow) { this.finish(); return; } @@ -3097,7 +2103,8 @@ Zotero.Sync.Storage.StreamListener.prototype = { _onStart: function (request) { //Zotero.debug('Starting request'); if (this._data && this._data.onStart) { - this._data.onStart(request); + var data = this._getPassData(); + this._data.onStart(request, data); } }, @@ -3122,33 +2129,37 @@ Zotero.Sync.Storage.StreamListener.prototype = { } } - // Make copy of data without callbacks to pass along - var passData = {}; - for (var i in this._data) { - switch (i) { - case "onStart": - case "onProgress": - case "onStop": - case "onCancel": - continue; - } - passData[i] = this._data[i]; - } + var data = this._getPassData(); if (cancelled) { if (this._data.onCancel) { - this._data.onCancel(request, status, passData); + this._data.onCancel(request, status, data); } } else { if (this._data.onStop) { - this._data.onStop(request, status, this._response, passData); + this._data.onStop(request, status, this._response, data); } } this._channel = null; }, + _getPassData: function () { + // Make copy of data without callbacks to pass along + var passData = {}; + for (var i in this._data) { + switch (i) { + case "onStart": + case "onProgress": + case "onStop": + case "onCancel": + continue; + } + passData[i] = this._data[i]; + } + return passData; + }, // nsIInterfaceRequestor getInterface: function (iid) { diff --git a/chrome/content/zotero/xpcom/storage/session.js b/chrome/content/zotero/xpcom/storage/session.js @@ -0,0 +1,166 @@ +Zotero.Sync.Storage.Session = function (module, callbacks) { + switch (module) { + case 'webdav': + this._session = new Zotero.Sync.Storage.Session.WebDAV(callbacks); + break; + + case 'zfs': + this._session = new Zotero.Sync.Storage.Session.ZFS(callbacks); + break; + + default: + throw ("Invalid storage session module '" + module + "'"); + } + + this.module = module; + this.onError = callbacks.onError; +} + +Zotero.Sync.Storage.Session.prototype.__defineGetter__('name', function () this._session.name); +Zotero.Sync.Storage.Session.prototype.__defineGetter__('includeUserFiles', function () this._session.includeUserFiles); +Zotero.Sync.Storage.Session.prototype.__defineGetter__('includeGroupFiles', function () this._session.includeGroupFiles); + +Zotero.Sync.Storage.Session.prototype.__defineGetter__('enabled', function () { + try { + return this._session.enabled; + } + catch (e) { + this.onError(e); + } +}); + +Zotero.Sync.Storage.Session.prototype.__defineGetter__('verified', function () { + try { + return this._session.verified; + } + catch (e) { + this.onError(e); + } +}); + +Zotero.Sync.Storage.Session.prototype.__defineGetter__('active', function () { + try { + return this._session.active; + } + catch (e) { + this.onError(e); + } +}); + +Zotero.Sync.Storage.Session.prototype.__defineGetter__('username', function () { + try { + return this._session.username; + } + catch (e) { + this.onError(e); + } +}); + +Zotero.Sync.Storage.Session.prototype.__defineGetter__('password', function () { + try { + return this._session.password; + } + catch (e) { + this.onError(e); + } +}); + +Zotero.Sync.Storage.Session.prototype.__defineSetter__('password', function (val) { + try { + this._session.password = val; + } + catch (e) { + this.onError(e); + } +}); + + +Zotero.Sync.Storage.Session.prototype.initFromPrefs = function () { + try { + return this._session.init(); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.initFromPrefs = function () { + try { + return this._session.initFromPrefs(); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.downloadFile = function (request) { + try { + this._session.downloadFile(request); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.uploadFile = function (request) { + try { + this._session.uploadFile(request); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.getLastSyncTime = function (callback) { + try { + this._session.getLastSyncTime(callback); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.setLastSyncTime = function (callback, useLastSyncTime) { + try { + this._session.setLastSyncTime(callback, useLastSyncTime); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.checkServer = function (callback) { + try { + this._session.checkServer(callback); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.checkServerCallback = function (uri, status, authRequired, window, skipSuccessMessage) { + try { + return this._session.checkServerCallback(uri, status, authRequired, window, skipSuccessMessage); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.purgeDeletedStorageFiles = function (callback) { + try { + this._session.purgeDeletedStorageFiles(callback); + } + catch (e) { + this.onError(e); + } +} + +Zotero.Sync.Storage.Session.prototype.purgeOrphanedStorageFiles = function (callback) { + try { + this._session.purgeOrphanedStorageFiles(callback); + } + catch (e) { + this.onError(e); + } +} diff --git a/chrome/content/zotero/xpcom/storage/webdav.js b/chrome/content/zotero/xpcom/storage/webdav.js @@ -0,0 +1,1401 @@ +Zotero.Sync.Storage.Session.WebDAV = function (callbacks) { + this.onChangesMade = callbacks.onChangesMade ? callbacks.onChangesMade : function () {}; + this.onError = callbacks.onError ? callbacks.onError : function () {}; + + this._parentURI; + this._rootURI; + this._cachedCredentials = false; +} + +Zotero.Sync.Storage.Session.WebDAV.prototype.name = "WebDAV"; + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('includeUserFiles', function () { + return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'webdav'; +}); + +Zotero.Sync.Storage.Session.WebDAV.prototype.includeGroupItems = false; + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('enabled', function () { + return this.includeUserFiles; +}); + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('verified', function () { + return Zotero.Prefs.get("sync.storage.verified"); +}); + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('active', function () { + return this.enabled && this.verified; +}); + +Zotero.Sync.Storage.Session.WebDAV.prototype._loginManagerHost = 'chrome://zotero'; +Zotero.Sync.Storage.Session.WebDAV.prototype._loginManagerURL = 'Zotero Storage Server'; + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('username', function () { + return Zotero.Prefs.get('sync.storage.username'); +}); + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('password', function () { + var username = this.username; + + if (!username) { + Zotero.debug('Username not set before setting Zotero.Sync.Storage.password'); + return ''; + } + + Zotero.debug('Getting WebDAV password'); + var loginManager = Components.classes["@mozilla.org/login-manager;1"] + .getService(Components.interfaces.nsILoginManager); + var logins = loginManager.findLogins({}, this._loginManagerHost, this._loginManagerURL, null); + + // Find user from returned array of nsILoginInfo objects + for (var i = 0; i < logins.length; i++) { + if (logins[i].username == username) { + return logins[i].password; + } + } + + return ''; +}); + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineSetter__('password', function (password) { + var username = this.username; + if (!username) { + Zotero.debug('Username not set before setting Zotero.Sync.Server.password'); + return; + } + + this._cachedCredentials = false; + + var loginManager = Components.classes["@mozilla.org/login-manager;1"] + .getService(Components.interfaces.nsILoginManager); + var logins = loginManager.findLogins({}, this._loginManagerHost, this._loginManagerURL, null); + + for (var i = 0; i < logins.length; i++) { + Zotero.debug('Clearing Zotero storage passwords'); + loginManager.removeLogin(logins[i]); + break; + } + + if (password) { + Zotero.debug(this._loginManagerURL); + var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Components.interfaces.nsILoginInfo, "init"); + var loginInfo = new nsLoginInfo(this._loginManagerHost, this._loginManagerURL, + null, username, password, "", ""); + loginManager.addLogin(loginInfo); + } +}); + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('rootURI', function () { + if (!this._rootURI) { + throw ("Root URI not initialized in Zotero.Sync.Storage.Session.WebDAV.rootURI"); + } + return this._rootURI.clone(); +}); + +Zotero.Sync.Storage.Session.WebDAV.prototype.__defineGetter__('parentURI', function () { + if (!this._parentURI) { + throw ("Parent URI not initialized in Zotero.Sync.Storage.Session.WebDAV.parentURI"); + } + return this._parentURI.clone(); +}); + + +Zotero.Sync.Storage.Session.WebDAV.prototype.init = function (url, dir, username, password) { + if (!url) { + var msg = "Zotero storage URL not provided"; + Zotero.debug(msg); + throw ({ + message: msg, + name: "Z_ERROR_NO_URL", + filename: "webdav.js", + toString: function () { return this.message; } + }); + } + + if (username && !password) { + var msg = "Zotero storage password not provided"; + Zotero.debug(msg); + throw ({ + message: msg, + name: "Z_ERROR_NO_PASSWORD", + filename: "webdav.js", + toString: function () { return this.message; } + }); + } + + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + try { + var uri = ios.newURI(url, null, null); + if (username) { + uri.username = username; + uri.password = password; + } + } + catch (e) { + Zotero.debug(e); + Components.utils.reportError(e); + return false; + } + if (!uri.spec.match(/\/$/)) { + uri.spec += "/"; + } + this._parentURI = uri; + + var uri = uri.clone(); + uri.spec += "zotero/"; + this._rootURI = uri; + return true; +} + + +Zotero.Sync.Storage.Session.WebDAV.prototype.initFromPrefs = function () { + var scheme = Zotero.Prefs.get('sync.storage.scheme'); + switch (scheme) { + case 'http': + case 'https': + break; + + default: + throw ("Invalid storage scheme '" + scheme + + "' in Zotero.Sync.Storage.Session.WebDAV.rootURI"); + } + + var url = Zotero.Prefs.get('sync.storage.url'); + + url = scheme + '://' + url; + var dir = "zotero"; + var username = this.username; + var password = this.password; + + return this.init(url, dir, username, password); +} + + +/** + * Get mod time of file on storage server + * + * @param {Zotero.Item} item + * @param {Function} callback Callback f(item, mdate) + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._getStorageModificationTime = function (item, callback) { + var uri = this._getItemPropertyURI(item); + + var self = this; + + Zotero.Utilities.HTTP.doGet(uri, function (req) { + var funcName = "Zotero.Sync.Storage._getStorageModificationTime()"; + + // mod_speling can return 300s for 404s with base name matches + if (req.status == 404 || req.status == 300) { + callback(item, false); + return; + } + else if (req.status != 200) { + Zotero.debug(req.responseText); + self.onError("Unexpected status code " + req.status + " in " + funcName); + return; + } + + Zotero.debug(req.responseText); + + var mtime = req.responseText; + // No modification time set + if (!mtime) { + callback(item, false); + return; + } + + var mdate = new Date(mtime * 1000); + callback(item, mdate); + }); +} + + +/** + * Set mod time of file on storage server + * + * @param {Zotero.Item} item + * @param {Function} callback Callback f(item, mtime) + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._setStorageModificationTime = function (item, callback) { + var uri = this._getItemPropertyURI(item); + + Zotero.Utilities.HTTP.WebDAV.doPut(uri, item.attachmentModificationTime + '', function (req) { + switch (req.status) { + case 200: + case 201: + case 204: + break; + + default: + Zotero.debug(req.responseText); + throw ("Unexpected status code " + req.status + " in " + + "Zotero.Sync.Storage._setStorageModificationTime()"); + } + callback(item, item.attachmentModificationTime); + }); +} + + + +/** + * Begin download process for individual file + * + * @param {Zotero.Sync.Storage.Request} [request] + */ +Zotero.Sync.Storage.Session.WebDAV.prototype.downloadFile = function (request) { + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + if (!item) { + throw ("Item '" + request.name + "' not found in Zotero.Sync.Storage.Session.WebDAV.downloadFile()"); + } + + var self = this; + + // Retrieve modification time from server to store locally afterwards + this._getStorageModificationTime(item, function (item, mdate) { + if (!request.isRunning()) { + Zotero.debug("Download request '" + request.name + + "' is no longer running after getting mod time"); + return; + } + + if (!mdate) { + Zotero.debug("Remote file not found for item " + item.key); + request.finish(); + return; + } + + try { + var syncModTime = Zotero.Date.toUnixTimestamp(mdate); + + // Skip download if local file exists and matches mod time + var file = item.getFile(); + if (file && file.exists() + && syncModTime == Math.round(file.lastModifiedTime / 1000)) { + Zotero.debug("File mod time matches remote file -- skipping download"); + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + var updateItem = syncState != 1; + Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + self.onChangesMade(); + request.finish(); + return; + } + + var uri = self._getItemURI(item); + var destFile = Zotero.getTempDirectory(); + destFile.append(item.key + '.zip.tmp'); + if (destFile.exists()) { + destFile.remove(false); + } + + var listener = new Zotero.Sync.Storage.StreamListener( + { + onStart: function (request, data) { + if (data.request.isFinished()) { + Zotero.debug("Download request " + data.request.name + + " stopped before download started -- closing channel"); + request.cancel(0x804b0002); // NS_BINDING_ABORTED + return; + } + }, + onProgress: function (a, b, c) { + request.onProgress(a, b, c) + }, + onStop: function (request, status, response, data) { + if (status != 200) { + self.onError("Unexpected status code " + status + + " for request " + data.request.name + " in Zotero.Sync.Storage.Session.WebDAV.downloadFile()"); + return; + } + + // Don't try to process if the request has been cancelled + if (data.request.isFinished()) { + Zotero.debug("Download request " + data.request.name + + " is no longer running after file download"); + return; + } + + Zotero.debug("Finished download of " + destFile.path); + + try { + Zotero.Sync.Storage.processDownload(data); + data.request.finish(); + } + catch (e) { + self.onError(e); + } + }, + request: request, + item: item, + compressed: true, + syncModTime: syncModTime + } + ); + + 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); + } + catch (e) { + request.error(e); + } + }); +} + + +Zotero.Sync.Storage.Session.WebDAV.prototype.uploadFile = function (request) { + var self = this; + Zotero.Sync.Storage.createUploadFile(request, function (data) { self._processUploadFile(data); }); +} + +/** + * Upload the generated ZIP file to the server + * + * @param {Object} Object with 'request' property + * @return {void} + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._processUploadFile = function (data) { + /* + _updateSizeMultiplier( + (100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100 + ); + */ + var request = data.request; + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + + var self = this; + + this._getStorageModificationTime(item, function (item, mdate) { + try { + if (!request.isRunning()) { + Zotero.debug("Upload request '" + request.name + + "' is no longer running after getting mod time"); + return; + } + + // Check for conflict + if (Zotero.Sync.Storage.getSyncState(item.id) + != Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD) { + if (mdate) { + // Remote prop time + var mtime = Zotero.Date.toUnixTimestamp(mdate); + // Local file time + var fmtime = item.attachmentModificationTime; + + if (fmtime == mtime) { + Zotero.debug("File mod time matches remote file -- skipping upload"); + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime, true); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + self.onChangesMade(); + request.finish(); + return; + } + + var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id); + if (smtime != mtime) { + var localData = { modTime: fmtime }; + var remoteData = { modTime: mtime }; + Zotero.Sync.Storage.QueueManager.addConflict( + request.name, localData, remoteData + ); + Zotero.debug("Conflict -- last synced file mod time " + + "does not match time on storage server" + + " (" + smtime + " != " + mtime + ")"); + request.finish(); + return; + } + } + 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 = self._getItemURI(item); + + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + var channel = ios.newChannelFromURI(uri); + channel.QueryInterface(Components.interfaces.nsIUploadChannel); + channel.setUploadStream(bis, 'application/octet-stream', -1); + channel.QueryInterface(Components.interfaces.nsIHttpChannel); + channel.requestMethod = 'PUT'; + channel.allowPipelining = false; + + channel.setRequestHeader('Keep-Alive', '', false); + channel.setRequestHeader('Connection', '', false); + + var listener = new Zotero.Sync.Storage.StreamListener( + { + onProgress: function (a, b, c) { + request.onProgress(a, b, c); + }, + onStop: function (httpRequest, status, response, data) { self._onUploadComplete(httpRequest, status, response,data); }, + onCancel: function (httpRequest, status, data) { self._onUploadCancel(httpRequest, status, data); }, + request: request, + item: item, + streams: [fis, bis] + } + ); + channel.notificationCallbacks = listener; + + var dispURI = uri.clone(); + if (dispURI.password) { + dispURI.password = '********'; + } + Zotero.debug("HTTP PUT of " + file.leafName + " to " + dispURI.spec); + + channel.asyncOpen(listener, null); + } + catch (e) { + self.onError(e); + } + }); +} + + +Zotero.Sync.Storage.Session.WebDAV.prototype._onUploadComplete = function (httpRequest, status, response, data) { + var request = data.request; + var item = data.item; + var url = httpRequest.name; + + Zotero.debug("Upload of attachment " + item.key + + " finished with status code " + status); + + switch (status) { + case 200: + case 201: + case 204: + break; + + default: + this.onError("Unexpected file upload status " + status + + " in Zotero.Sync.Storage._onUploadComplete()"); + return; + } + + var self = this; + + this._setStorageModificationTime(item, function (item, mtime) { + if (!request.isRunning()) { + Zotero.debug("Upload request '" + request.name + + "' is no longer running after getting mod time"); + return; + } + + Zotero.DB.beginTransaction(); + + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.Sync.Storage.setSyncedModificationTime(item.id, mtime, true); + + var hash = item.attachmentHash; + Zotero.Sync.Storage.setSyncedHash(item.id, hash, true); + + Zotero.DB.commitTransaction(); + + try { + var file = Zotero.getTempDirectory(); + file.append(item.key + '.zip'); + file.remove(false); + } + catch (e) { + Components.utils.reportError(e); + } + + self.onChangesMade(); + request.finish(); + }); +} + + +Zotero.Sync.Storage.Session.WebDAV.prototype._onUploadCancel = function (httpRequest, status, data) { + var request = data.request; + var item = data.item; + + Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status); + + try { + var file = Zotero.getTempDirectory(); + file.append(item.key + '.zip'); + file.remove(false); + } + catch (e) { + Components.utils.reportError(e); + } + + request.finish(); +} + + +Zotero.Sync.Storage.Session.WebDAV.prototype.getLastSyncTime = function (callback) { + // Cache the credentials at the root URI + if (!this._cachedCredentials) { + var self = this; + + Zotero.Utilities.HTTP.doOptions(this.rootURI, function (req) { + if (req.status != 200) { + self.onError("Unexpected status code " + req.status + " caching " + + "authentication credentials in Zotero.Sync.Storage.Session.WebDAV.getLastSyncTime()"); + return; + } + self._cachedCredentials = true; + self.getLastSyncTime(callback); + }); + return; + } + + try { + var uri = this.rootURI; + var successFileURI = uri.clone(); + successFileURI.spec += "lastsync"; + Zotero.Utilities.HTTP.doHead(successFileURI, function (req) { + var ts = undefined; + try { + if (req.responseText) { + Zotero.debug(req.responseText); + } + Zotero.debug(req.status); + + if (req.status != 200 && req.status != 404) { + self.onError("Unexpected status code " + req.status + " getting " + + "last file sync time"); + return; + } + + var lastModified = req.getResponseHeader("Last-Modified"); + var date = new Date(lastModified); + Zotero.debug("Last successful storage sync was " + date); + ts = Zotero.Date.toUnixTimestamp(date); + } + finally { + callback(ts); + } + }); + return; + } + catch (e) { + Zotero.debug(e); + Components.utils.reportError(e); + callback(); + return; + } +} + + +Zotero.Sync.Storage.Session.WebDAV.prototype.setLastSyncTime = function (callback) { + try { + var uri = this.rootURI; + var successFileURI = uri.clone(); + successFileURI.spec += "lastsync"; + + var self = this; + + Zotero.Utilities.HTTP.WebDAV.doPut(successFileURI, "", function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 200: + case 201: + case 204: + self.getLastSyncTime(function (ts) { + if (ts) { + var sql = "REPLACE INTO version VALUES ('storage_webdav', ?)"; + Zotero.DB.query(sql, { int: ts }); + } + if (callback) { + callback(); + } + }); + return; + } + + var msg = "Unexpected error code " + req.status + " uploading storage success file"; + Zotero.debug(msg, 2); + Components.utils.reportError(msg); + if (callback) { + callback(); + } + }); + } + catch (e) { + Zotero.debug(e); + Components.utils.reportError(e); + if (callback) { + callback(); + } + return; + } +} + + +/** + * @param {Function} callback Function to pass URI and result value to + * @param {Object} errorCallbacks + */ +Zotero.Sync.Storage.Session.WebDAV.prototype.checkServer = function (callback) { + try { + var parentURI = this.parentURI; + var uri = this.rootURI; + } + catch (e) { + switch (e.name) { + case 'Z_ERROR_NO_URL': + callback(null, Zotero.Sync.Storage.ERROR_NO_URL); + return; + + case 'Z_ERROR_NO_PASSWORD': + callback(null, Zotero.Sync.Storage.ERROR_NO_PASSWORD); + return; + + default: + Zotero.debug(e); + Components.utils.reportError(e); + callback(null, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + } + + var requestHolder = { request: null }; + + var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; + var D = new Namespace("D", "DAV:"); + var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"'; + + var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); + requestXML.D::prop = ''; + // IIS 5.1 requires at least one property in PROPFIND + requestXML.D::prop.D::getcontentlength = ''; + + var xmlstr = prolog + requestXML.toXMLString(); + + var self = this; + + // Test whether URL is WebDAV-enabled + var request = Zotero.Utilities.HTTP.doOptions(parentURI, 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 }; + + // 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 200: + case 201: + case 204: + // Delete test file + Zotero.Utilities.HTTP.WebDAV.doDelete( + testFileURI, + function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + switch (req.status) { + case 200: // IIS 5.1 and Sakai return 200 + case 204: + callback( + uri, + Zotero.Sync.Storage.SUCCESS + ); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + case 403: + callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); + return; + + default: + callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + } + ); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + case 403: + callback(uri, Zotero.Sync.Storage.ERROR_FORBIDDEN); + return; + + 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: + // Zotero directory wasn't found, so see 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 400: + callback(uri, Zotero.Sync.Storage.ERROR_BAD_REQUEST); + return; + + case 401: + callback(uri, Zotero.Sync.Storage.ERROR_AUTH_FAILED); + return; + + // Parent directory wasn't found either + case 404: + callback(uri, Zotero.Sync.Storage.ERROR_PARENT_DIR_NOT_FOUND); + return; + + default: + callback(uri, Zotero.Sync.Storage.ERROR_UNKNOWN); + return; + } + }, 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; +} + + +Zotero.Sync.Storage.Session.WebDAV.prototype.checkServerCallback = function (uri, status, window, skipSuccessMessage) { + var promptService = + Components.classes["@mozilla.org/embedcomp/prompt-service;1"]. + createInstance(Components.interfaces.nsIPromptService); + if (uri) { + var spec = uri.scheme + '://' + uri.hostPort + uri.path; + } + + switch (status) { + case Zotero.Sync.Storage.SUCCESS: + if (!skipSuccessMessage) { + promptService.alert( + window, + "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 WebDAV URL."; + break; + + case Zotero.Sync.Storage.ERROR_NO_PASSWORD: + var errorMessage = "Please enter a password."; + 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 WebDAV server did not accept the " + + "username and password you entered." + " " + + "Please check your storage 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 the WebDAV server." + " " + + "Please check your file sync 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( + window, + // 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; + } + + this._createServerDirectory(function (uri, status) { + switch (status) { + case Zotero.Sync.Storage.SUCCESS: + if (!skipSuccessMessage) { + promptService.alert( + window, + "Server configuration verified", + "File sync 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 file sync settings or " + + "contact your server administrator."; + break; + } + + // TEMP + if (!errorMessage) { + var errorMessage = status; + } + promptService.alert(window, errorTitle, errorMessage); + }); + + return false; + } + + if (!skipSuccessMessage) { + if (!errorTitle) { + var errorTitle = Zotero.getString("general.error"); + } + // TEMP + if (!errorMessage) { + var errorMessage = status; + } + promptService.alert(window, errorTitle, errorMessage); + } + return false; +} + + +/** + * Remove files on storage server that were deleted locally more than + * sync.storage.deleteDelayDays days ago + * + * @param {Function} callback Passed number of files deleted + */ +Zotero.Sync.Storage.Session.WebDAV.prototype.purgeDeletedStorageFiles = function (callback) { + if (!this.active) { + return; + } + + Zotero.debug("Purging deleted storage files"); + var files = Zotero.Sync.Storage.getDeletedFiles(); + if (!files) { + Zotero.debug("No files to delete remotely"); + if (callback) { + callback(); + } + return; + } + + // Add .zip extension + var files = files.map(function (file) file + ".zip"); + + this._deleteStorageFiles(files, function (results) { + // Remove deleted and nonexistent files from storage delete log + var toPurge = results.deleted.concat(results.missing); + if (toPurge.length > 0) { + var done = 0; + var maxFiles = 999; + var numFiles = toPurge.length; + + Zotero.DB.beginTransaction(); + + do { + var chunk = toPurge.splice(0, maxFiles); + var sql = "DELETE FROM storageDeleteLog WHERE key IN (" + + chunk.map(function () '?').join() + ")"; + Zotero.DB.query(sql, chunk); + done += chunk.length; + } + while (done < numFiles); + + Zotero.DB.commitTransaction(); + } + + if (callback) { + callback(results.deleted.length); + } + }); +} + + +/** + * Delete orphaned storage files older than a day before last sync time + * + * @param {Function} callback + */ +Zotero.Sync.Storage.Session.WebDAV.prototype.purgeOrphanedStorageFiles = function (callback) { + const daysBeforeSyncTime = 1; + + Zotero.debug("Purging orphaned storage files"); + var uri = this.rootURI; + var path = uri.path; + + var prolog = '<?xml version="1.0" encoding="utf-8" ?>\n'; + var D = new Namespace("D", "DAV:"); + var nsDeclarations = 'xmlns:' + D.prefix + '=' + '"' + D.uri + '"'; + + var requestXML = new XML('<D:propfind ' + nsDeclarations + '/>'); + requestXML.D::prop = ''; + requestXML.D::prop.D::getlastmodified = ''; + + var xmlstr = prolog + requestXML.toXMLString(); + + var lastSyncDate = new Date(Zotero.Sync.Server.lastLocalSyncTime * 1000); + + Zotero.Utilities.HTTP.WebDAV.doProp("PROPFIND", uri, xmlstr, function (req) { + Zotero.debug(req.responseText); + + var funcName = "Zotero.Sync.Storage.purgeOrphanedStorageFiles()"; + + // Strip XML declaration and convert to E4X + var xml = new XML(req.responseText.replace(/<\?xml.*\?>/, '')); + + var deleteFiles = []; + var trailingSlash = !!path.match(/\/$/); + for each(var response in xml.D::response) { + var href = response.D::href.toString(); + + // Strip trailing slash if there isn't one on the root path + if (!trailingSlash) { + href = href.replace(/\/$/, "") + } + + // Absolute + if (href.match(/^https?:\/\//)) { + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + var href = ios.newURI(href, null, null); + href = href.path; + } + + if (href.indexOf(path) == -1 + // Try URL-encoded as well, in case there's a '~' or similar + // character in the URL and the server (e.g., Sakai) is + // encoding the value + && decodeURIComponent(href).indexOf(path) == -1) { + _error("DAV:href '" + href + + "' does not begin with path '" + path + "' in " + funcName); + } + + // Skip root URI + if (href == path + // Try URL-encoded as well, as above + || decodeURIComponent(href) == path) { + continue; + } + + var matches = href.match(/[^\/]+$/); + if (!matches) { + _error("Unexpected href '" + href + "' in " + funcName) + } + var file = matches[0]; + + if (file.indexOf('.') == 0) { + Zotero.debug("Skipping hidden file " + file); + continue; + } + if (!file.match(/\.zip$/) && !file.match(/\.prop$/)) { + Zotero.debug("Skipping file " + file); + continue; + } + + var key = file.replace(/\.(zip|prop)$/, ''); + var item = Zotero.Items.getByLibraryAndKey(null, key); + if (item) { + Zotero.debug("Skipping existing file " + file); + continue; + } + + Zotero.debug("Checking orphaned file " + file); + + // TODO: Parse HTTP date properly + var lastModified = response..*::getlastmodified.toString(); + lastModified = Zotero.Date.strToISO(lastModified); + lastModified = Zotero.Date.sqlToDate(lastModified); + + // Delete files older than a day before last sync time + var days = (lastSyncDate - lastModified) / 1000 / 60 / 60 / 24; + + // DEBUG!!!!!!!!!!!! + // + // For now, delete all orphaned files immediately + if (true) { + deleteFiles.push(file); + } else + + if (days > daysBeforeSyncTime) { + deleteFiles.push(file); + } + } + + this._deleteStorageFiles(deleteFiles, callback); + }, + { Depth: 1 }); +} + + +/** + * Create a Zotero directory on the storage server + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._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; + } + }); +} + + + +// +// Private methods +// + +/** + * Get the storage URI for an item + * + * @inner + * @param {Zotero.Item} + * @return {nsIURI} URI of file on storage server + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._getItemURI = function (item) { + var uri = this.rootURI; + uri.spec = uri.spec + item.key + '.zip'; + return uri; +} + + +/** + * Get the storage property file URI for an item + * + * @inner + * @param {Zotero.Item} + * @return {nsIURI} URI of property file on storage server + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._getItemPropertyURI = function (item) { + var uri = this.rootURI; + uri.spec = uri.spec + item.key + '.prop'; + return uri; +} + + +/** + * Get the storage property file URI corresponding to a given item storage URI + * + * @param {nsIURI} Item storage URI + * @return {nsIURI|FALSE} Property file URI, or FALSE if not an item storage URI + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._getPropertyURIFromItemURI = function (uri) { + if (!uri.spec.match(/\.zip$/)) { + return false; + } + var propURI = uri.clone(); + propURI.QueryInterface(Components.interfaces.nsIURL); + propURI.fileName = uri.fileName.replace(/\.zip$/, '.prop'); + propURI.QueryInterface(Components.interfaces.nsIURI); + return propURI; +} + + +/** + * @inner + * @param {String[]} files Remote filenames to delete (e.g., ZIPs) + * @param {Function} callback Passed object containing three arrays: + * 'deleted', 'missing', and 'error', + * each containing filenames + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._deleteStorageFiles = function (files, callback) { + 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]; + + let 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: + // IIS 5.1 and Sakai return 200 + case 200: + var fileDeleted = true; + break; + + case 404: + var fileDeleted = false; + break; + + default: + if (last && callback) { + callback(results); + } + + results.error.push(fileName); + var msg = "An error occurred attempting to delete " + + "'" + fileName + + "' (" + req.status + " " + req.statusText + ")."; + _error(msg); + return; + } + + // If an item file URI, get the property URI + var deletePropURI = _getPropertyURIFromItemURI(deleteURI); + if (!deletePropURI) { + if (fileDeleted) { + results.deleted.push(fileName); + } + else { + results.missing.push(fileName); + } + if (last && callback) { + callback(results); + } + return; + } + + // If property file appears separately in delete queue, + // remove it, since we're taking care of it here + var propIndex = files.indexOf(deletePropURI.fileName); + if (propIndex > i) { + delete files[propIndex]; + i--; + last = (i == files.length - 1); + } + + // Delete property file + Zotero.Utilities.HTTP.WebDAV.doDelete(deletePropURI, function (req) { + switch (req.status) { + case 204: + // IIS 5.1 and Sakai return 200 + case 200: + results.deleted.push(fileName); + break; + + case 404: + if (fileDeleted) { + results.deleted.push(fileName); + } + else { + results.missing.push(fileName); + } + break; + + default: + var error = true; + } + + if (last && callback) { + callback(results); + } + + if (error) { + results.error.push(fileName); + var msg = "An error occurred attempting to delete " + + "'" + fileName + + "' (" + req.status + " " + req.statusText + ")."; + _error(msg); + } + }); + }); + } +} + + +/** + * Unused + * + * @inner + * @param {XMLHTTPRequest} req + * @throws + */ +Zotero.Sync.Storage.Session.WebDAV.prototype._checkResponse = function (req) { + if (!req.responseText) { + this.onError('Empty response from server'); + return; + } + if (!req.responseXML || + !req.responseXML.firstChild || + !(req.responseXML.firstChild.namespaceURI == 'DAV:' && + req.responseXML.firstChild.localName == 'multistatus') || + !req.responseXML.childNodes[0].firstChild) { + Zotero.debug(req.responseText); + this.onError('Invalid response from storage server'); + return; + } +} diff --git a/chrome/content/zotero/xpcom/storage/zfs.js b/chrome/content/zotero/xpcom/storage/zfs.js @@ -0,0 +1,852 @@ +Zotero.Sync.Storage.Session.ZFS = function (callbacks) { + this.onChangesMade = callbacks.onChangesMade ? callbacks.onChangesMade : function () {}; + this.onError = callbacks.onError ? callbacks.onError : function () {}; + + this._rootURI; + this._userURI; + this._cachedCredentials = false; + this._lastSyncTime = null; +} + +Zotero.Sync.Storage.Session.ZFS.prototype.name = "ZFS"; + +Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('includeUserFiles', function () { + return Zotero.Prefs.get("sync.storage.enabled") && Zotero.Prefs.get("sync.storage.protocol") == 'zotero'; +}); + +Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('includeGroupFiles', function () { + return Zotero.Prefs.get("sync.storage.groups.enabled"); +}); + +Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('enabled', function () { + return this.includeUserFiles || this.includeGroupFiles; +}); + +Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('active', function () { + return this.enabled; +}); + + +Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('rootURI', function () { + if (!this._rootURI) { + throw ("Root URI not initialized in Zotero.Sync.Storage.Session.ZFS.rootURI"); + } + return this._rootURI.clone(); +}); + +Zotero.Sync.Storage.Session.ZFS.prototype.__defineGetter__('userURI', function () { + if (!this._userURI) { + throw ("User URI not initialized in Zotero.Sync.Storage.Session.ZFS.userURI"); + } + return this._userURI.clone(); +}); + +Zotero.Sync.Storage.Session.ZFS.prototype.init = function (url, username, password) { + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + try { + var uri = ios.newURI(url, null, null); + if (username) { + uri.username = username; + uri.password = password; + } + } + catch (e) { + Zotero.debug(e); + Components.utils.reportError(e); + return false; + } + this._rootURI = uri; + + uri = uri.clone(); + uri.spec += 'users/' + Zotero.userID + '/'; + this._userURI = uri; + + return true; +} + + +Zotero.Sync.Storage.Session.ZFS.prototype.initFromPrefs = function () { + var url = ZOTERO_CONFIG.API_URL; + var username = Zotero.Sync.Server.username; + var password = Zotero.Sync.Server.password; + return this.init(url, username, password); +} + + +/** + * Get file metadata on storage server + * + * @param {Zotero.Item} item + * @param {Function} callback Callback f(item, etag) + */ +Zotero.Sync.Storage.Session.ZFS.prototype._getStorageFileInfo = function (item, callback) { + var uri = this._getItemURI(item); + + var self = this; + + Zotero.Utilities.HTTP.doHead(uri, function (req) { + var funcName = "Zotero.Sync.Storage.Session.ZFS._getStorageFileInfo()"; + + if (req.status == 404) { + callback(item, false); + return; + } + else if (req.status != 200) { + Zotero.debug(req.responseText); + self.onError("Unexpected status code " + req.status + " in " + funcName); + return; + } + + var info = {}; + info.hash = req.getResponseHeader('ETag'); + info.filename = req.getResponseHeader('X-Zotero-Filename'); + info.mtime = req.getResponseHeader('X-Zotero-Modification-Time'); + info.compressed = req.getResponseHeader('X-Zotero-Compressed') == 'Yes'; + Zotero.debug(info); + + if (!info) { + callback(item, false); + return; + } + + callback(item, info); + }); +} + + +/** + * Begin download process for individual file + * + * @param {Zotero.Sync.Storage.Request} [request] + */ +Zotero.Sync.Storage.Session.ZFS.prototype.downloadFile = function (request) { + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + if (!item) { + throw ("Item '" + request.name + "' not found in Zotero.Sync.Storage.Session.ZFS.downloadFile()"); + } + + var self = this; + + // Retrieve file info from server to store locally afterwards + this._getStorageFileInfo(item, function (item, info) { + if (!request.isRunning()) { + Zotero.debug("Download request '" + request.name + + "' is no longer running after getting remote file info"); + return; + } + + if (!info) { + Zotero.debug("Remote file not found for item " + item.libraryID + "/" + item.key); + request.finish(); + return; + } + + try { + var syncModTime = info.mtime; + var syncHash = info.hash; + + var file = item.getFile(); + // Skip download if local file exists and matches mod time + if (file && file.exists()) { + if (syncModTime == Math.round(file.lastModifiedTime / 1000)) { + Zotero.debug("File mod time matches remote file -- skipping download"); + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + //var updateItem = syncState != 1; + var updateItem = false; + Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + self.onChangesMade(); + request.finish(); + return; + } + // If not compressed, check hash, in case only timestamp changed + else if (!info.compressed && Zotero.Utilities.prototype.md5(file) == syncHash) { + Zotero.debug("File hash matches remote file -- skipping download"); + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + //var updateItem = syncState != 1; + var updateItem = false; + if (!info.compressed) { + Zotero.Sync.Storage.setSyncedHash(item.id, syncHash, false); + } + Zotero.Sync.Storage.setSyncedModificationTime(item.id, syncModTime, updateItem); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + self.onChangesMade(); + request.finish(); + return; + } + } + + var destFile = Zotero.getTempDirectory(); + if (info.compressed) { + destFile.append(item.key + '.zip.tmp'); + } + else { + destFile.append(item.key + '.tmp'); + if (destFile.exists()) { + destFile.remove(false); + } + } + if (destFile.exists()) { + destFile.remove(false); + } + + var listener = new Zotero.Sync.Storage.StreamListener( + { + onStart: function (request, data) { + if (data.request.isFinished()) { + Zotero.debug("Download request " + data.request.name + + " stopped before download started -- closing channel"); + request.cancel(0x804b0002); // NS_BINDING_ABORTED + return; + } + }, + onProgress: function (a, b, c) { + request.onProgress(a, b, c) + }, + onStop: function (request, status, response, data) { + if (status != 200) { + self.onError("Unexpected status code " + status + + " for request " + data.request.name + " in Zotero.Sync.Storage.Session.ZFS.downloadFile()"); + return; + } + + // Don't try to process if the request has been cancelled + if (data.request.isFinished()) { + Zotero.debug("Download request " + data.request.name + + " is no longer running after file download"); + return; + } + + Zotero.debug("Finished download of " + destFile.path); + + try { + Zotero.Sync.Storage.processDownload(data); + data.request.finish(); + } + catch (e) { + self.onError(e); + } + }, + request: request, + item: item, + compressed: info.compressed, + syncModTime: syncModTime, + syncHash: syncHash + } + ); + + var uri = self._getItemURI(item); + + // Don't display password in console + var disp = uri.clone(); + if (disp.password) { + disp.password = "********"; + } + + Zotero.debug('Saving ' + disp.spec + ' with saveURI()'); + const nsIWBP = Components.interfaces.nsIWebBrowserPersist; + var wbp = Components + .classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"] + .createInstance(nsIWBP); + wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE; + + wbp.progressListener = listener; + wbp.saveURI(uri, null, null, null, null, destFile); + } + catch (e) { + self.onError(e); + } + }); +} + + +Zotero.Sync.Storage.Session.ZFS.prototype.uploadFile = function (request) { + var self = this; + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + if (Zotero.Attachments.getNumFiles(item) > 1) { + Zotero.Sync.Storage.createUploadFile(request, function (data) { self._processUploadFile(data); }); + } + else { + this._processUploadFile({ request: request }); + } +} + + +/** + * Upload the file to the server + * + * @param {Object} Object with 'request' property + * @return {void} + */ +Zotero.Sync.Storage.Session.ZFS.prototype._processUploadFile = function (data) { + /* + _updateSizeMultiplier( + (100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100 + ); + */ + + var request = data.request; + var item = Zotero.Sync.Storage.getItemFromRequestName(request.name); + + var self = this; + + this._getStorageFileInfo(item, function (item, info) { + if (request.isFinished()) { + Zotero.debug("Upload request '" + request.name + + "' is no longer running after getting file info"); + return; + } + + try { + // Check for conflict + if (Zotero.Sync.Storage.getSyncState(item.id) + != Zotero.Sync.Storage.SYNC_STATE_FORCE_UPLOAD) { + if (info) { + // Remote mod time + var mtime = info.mtime; + // Local file time + var fmtime = item.attachmentModificationTime; + + if (fmtime == mtime) { + Zotero.debug("File mod time matches remote file -- skipping upload"); + + Zotero.debug(Zotero.Sync.Storage.getSyncedModificationTime(item.id)); + + Zotero.DB.beginTransaction(); + var syncState = Zotero.Sync.Storage.getSyncState(item.id); + //Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime, true); + Zotero.Sync.Storage.setSyncedModificationTime(item.id, fmtime); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + Zotero.DB.commitTransaction(); + self.onChangesMade(); + request.finish(); + return; + } + + var smtime = Zotero.Sync.Storage.getSyncedModificationTime(item.id); + if (smtime != mtime) { + var localData = { modTime: fmtime }; + var remoteData = { modTime: mtime }; + Zotero.Sync.Storage.QueueManager.addConflict( + request.name, localData, remoteData + ); + Zotero.debug("Conflict -- last synced file mod time " + + "does not match time on storage server" + + " (" + smtime + " != " + mtime + ")"); + request.finish(); + return; + } + } + else { + Zotero.debug("Remote file not found for item " + item.id); + } + } + + self._getFileUploadParameters( + item, + function (item, target, uploadKey, params) { + try { + self._postFile(request, item, target, uploadKey, params); + } + catch (e) { + self.onError(e); + } + }, + function () { + self._updateItemFileInfo(item); + request.finish(); + } + ); + } + catch (e) { + self.onError(e); + } + }); +} + + +/** + * Get mod time of file on storage server + * + * @param {Zotero.Item} item + * @param {Function} uploadCallback Callback f(request, item, target, params) + * @param {Function} existsCallback Callback f() to call when file already exists + * on server and uploading isn't necessary + */ +Zotero.Sync.Storage.Session.ZFS.prototype._getFileUploadParameters = function (item, uploadCallback, existsCallback) { + var uri = this._getItemURI(item); + + if (Zotero.Attachments.getNumFiles(item) > 1) { + var file = Zotero.getTempDirectory(); + var filename = item.key + '.zip'; + file.append(filename); + uri.spec = uri.spec; + var zip = true; + } + else { + var file = item.getFile(); + var filename = file.leafName; + var zip = false; + } + + var mtime = item.attachmentModificationTime; + var hash = Zotero.Utilities.prototype.md5(file); + + var body = "md5=" + hash + "&filename=" + encodeURIComponent(filename) + + "&filesize=" + file.fileSize + "&mtime=" + mtime; + if (zip) { + body += "&zip=1"; + } + + var self = this; + + Zotero.Utilities.HTTP.doPost(uri, body, function (req) { + var funcName = "Zotero.Sync.Storage.Session.ZFS._getFileUploadParameters()"; + + if (req.status == 413) { + var retry = req.getResponseHeader('Retry-After'); + if (retry) { + var minutes = Math.round(retry / 60); + var e = new Zotero.Error("You have too many queued uploads. Please try again in " + minutes + " minutes.", "ZFS_UPLOAD_QUEUE_LIMIT"); + self.onError(e); + } + else { + Zotero.debug(req.responseText); + var e = new Zotero.Error("File would exceed Zotero File Storage quota", "ZFS_OVER_QUOTA"); + self.onError(e); + } + return; + } + else if (req.status == 403) { + Zotero.debug(req.responseText); + + var groupID = Zotero.Groups.getGroupIDFromLibraryID(item.libraryID); + var e = new Zotero.Error("File editing denied for group", "ZFS_FILE_EDITING_DENIED", { groupID: groupID }); + self.onError(e); + return; + } + else if (req.status != 200) { + Zotero.debug(req.responseText); + self.onError("Unexpected status code " + req.status + " in " + funcName); + return; + } + + Zotero.debug(req.responseText); + + try { + // Strip XML declaration and convert to E4X + var xml = new XML(req.responseText.replace(/<\?xml.*\?>/, '')); + } + catch (e) { + self.onError("Invalid response retrieving file upload parameters"); + return; + } + + if (xml.name() != 'upload' && xml.name() != 'exists') { + self.onError("Invalid response retrieving file upload parameters"); + return; + } + // File was already available, so uploading isn't required + if (xml.name() == 'exists') { + existsCallback(); + return; + } + + var url = xml.url.toString(); + var uploadKey = xml.key.toString(); + var params = {}, p = ''; + for each(var param in xml.params.children()) { + params[param.name()] = param.toString(); + } + Zotero.debug(params); + uploadCallback(item, url, uploadKey, params); + }); +} + + +Zotero.Sync.Storage.Session.ZFS.prototype._postFile = function (request, item, url, uploadKey, params) { + if (request.isFinished()) { + Zotero.debug("Upload request " + request.name + " is no longer running after getting upload parameters"); + return; + } + + var file = this._getUploadFile(item); + + // TODO: make sure this doesn't appear in file + var boundary = "---------------------------" + Math.random().toString().substr(2); + + var mis = Components.classes["@mozilla.org/io/multiplex-input-stream;1"] + .createInstance(Components.interfaces.nsIMultiplexInputStream); + + // Add parameters + for (var key in params) { + var storage = Components.classes["@mozilla.org/storagestream;1"] + .createInstance(Components.interfaces.nsIStorageStream); + storage.init(4096, 4294967295, null); // PR_UINT32_MAX + var out = storage.getOutputStream(0); + + var conv = Components.classes["@mozilla.org/intl/converter-output-stream;1"] + .createInstance(Components.interfaces.nsIConverterOutputStream); + conv.init(out, null, 4096, "?"); + + var str = "--" + boundary + '\r\nContent-Disposition: form-data; name="' + key + '"' + + '\r\n\r\n' + params[key] + '\r\n'; + conv.writeString(str); + conv.close(); + + var instr = storage.newInputStream(0); + mis.appendStream(instr); + } + + // Add file + var sis = Components.classes["@mozilla.org/io/string-input-stream;1"] + .createInstance(Components.interfaces.nsIStringInputStream); + var str = "--" + boundary + '\r\nContent-Disposition: form-data; name="file"\r\n\r\n'; + sis.setData(str, -1); + mis.appendStream(sis); + + var fis = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(Components.interfaces.nsIFileInputStream); + fis.init(file, 0x01, 0, Components.interfaces.nsIFileInputStream.CLOSE_ON_EOF); + + var bis = Components.classes["@mozilla.org/network/buffered-input-stream;1"] + .createInstance(Components.interfaces.nsIBufferedInputStream) + bis.init(fis, 64 * 1024); + mis.appendStream(bis); + + // End request + var sis = Components.classes["@mozilla.org/io/string-input-stream;1"] + .createInstance(Components.interfaces.nsIStringInputStream); + var str = "\r\n--" + boundary + "--"; + sis.setData(str, -1); + mis.appendStream(sis); + + +/* var cstream = Components.classes["@mozilla.org/intl/converter-input-stream;1"]. + createInstance(Components.interfaces.nsIConverterInputStream); + cstream.init(mis, "UTF-8", 0, 0); // you can use another encoding here if you wish + + let (str = {}) { + cstream.readString(-1, str); // read the whole file and put it in str.value + data = str.value; + } + cstream.close(); // this closes fstream + alert(data); +*/ + + var ios = Components.classes["@mozilla.org/network/io-service;1"]. + getService(Components.interfaces.nsIIOService); + var uri = ios.newURI(url, null, null); + var channel = ios.newChannelFromURI(uri); + + channel.QueryInterface(Components.interfaces.nsIUploadChannel); + channel.setUploadStream(mis, "multipart/form-data", -1); + channel.QueryInterface(Components.interfaces.nsIHttpChannel); + channel.requestMethod = 'POST'; + channel.allowPipelining = false; + channel.setRequestHeader('Keep-Alive', '', false); + channel.setRequestHeader('Connection', '', false); + channel.setRequestHeader("Content-Type", "multipart/form-data; boundary=" + boundary, false); + //channel.setRequestHeader('Date', date, false); + + var self = this; + + request.setChannel(channel); + + var listener = new Zotero.Sync.Storage.StreamListener( + { + onProgress: function (a, b, c) { + request.onProgress(a, b, c); + }, + onStop: function (httpRequest, status, response, data) { self._onUploadComplete(httpRequest, status, response, data); }, + onCancel: function (httpRequest, status, data) { self._onUploadCancel(httpRequest, status, data); }, + request: request, + item: item, + uploadKey: uploadKey, + streams: [mis] + } + ); + channel.notificationCallbacks = listener; + + var dispURI = uri.clone(); + if (dispURI.password) { + dispURI.password = '********'; + } + Zotero.debug("HTTP POST of " + file.leafName + " to " + dispURI.spec); + + channel.asyncOpen(listener, null); +} + + +Zotero.Sync.Storage.Session.ZFS.prototype._onUploadComplete = function (httpRequest, status, response, data) { + var request = data.request; + var item = data.item; + var uploadKey = data.uploadKey; + + Zotero.debug("Upload of attachment " + item.key + + " finished with status code " + status); + + Zotero.debug(response); + + switch (status) { + case 201: + break; + + default: + this.onError("Unexpected file upload status " + status + + " in Zotero.Sync.Storage._onUploadComplete()"); + return; + } + + var uri = this._getItemURI(item); + var body = "update=" + uploadKey + "&mtime=" + item.attachmentModificationTime; + + var self = this; + + // Register upload on server + Zotero.Utilities.HTTP.doPost(uri, body, function (req) { + Zotero.debug(req.responseText); + + if (req.status != 204) { + self.onError("Unexpected file registration status " + status + + " in Zotero.Sync.Storage._onUploadComplete()"); + return; + } + + self._updateItemFileInfo(item); + request.finish(); + }); +} + + +Zotero.Sync.Storage.Session.ZFS.prototype._updateItemFileInfo = function (item) { + // Mark as changed locally + Zotero.DB.beginTransaction(); + Zotero.Sync.Storage.setSyncState(item.id, Zotero.Sync.Storage.SYNC_STATE_IN_SYNC); + + // Store file mod time + var mtime = item.attachmentModificationTime; + Zotero.Sync.Storage.setSyncedModificationTime(item.id, mtime, false); + + // Store file hash of individual files + if (Zotero.Attachments.getNumFiles(item) == 1) { + var hash = item.attachmentHash; + Zotero.Sync.Storage.setSyncedHash(item.id, hash, true); + } + + Zotero.DB.commitTransaction(); + + try { + if (Zotero.Attachments.getNumFiles(item) > 1) { + var file = Zotero.getTempDirectory(); + file.append(item.key + '.zip'); + file.remove(false); + } + } + catch (e) { + Components.utils.reportError(e); + } + + this.onChangesMade(); +} + + +Zotero.Sync.Storage.Session.ZFS.prototype._onUploadCancel = function (httpRequest, status, data) { + var request = data.request; + var item = data.item; + + Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status); + + try { + if (Zotero.Attachments.getNumFiles(item) > 1) { + var file = Zotero.getTempDirectory(); + file.append(item.key + '.zip'); + file.remove(false); + } + } + catch (e) { + Components.utils.reportError(e); + } + + request.finish(); +} + + +Zotero.Sync.Storage.Session.ZFS.prototype.getLastSyncTime = function (callback) { + var uri = this.userURI; + var successFileURI = uri.clone(); + successFileURI.spec += "laststoragesync?auth=1"; + + var self = this; + + // Cache the credentials + if (!this._cachedCredentials) { + var uri = this.rootURI; + // TODO: move to root uri + uri.spec += "?auth=1"; + Zotero.Utilities.HTTP.doHead(uri, function (req) { + if (req.status != 200) { + self.onError("Unexpected status code " + req.status + " caching " + + "authentication credentials in Zotero.Sync.Storage.Session.ZFS.getLastSyncTime()"); + return; + } + self._cachedCredentials = true; + self.getLastSyncTime(callback); + }); + return; + } + + Zotero.Utilities.HTTP.doGet(successFileURI, function (req) { + if (req.responseText) { + Zotero.debug(req.responseText); + } + Zotero.debug(req.status); + + if (req.status != 200 && req.status != 404) { + self.onError("Unexpected status code " + req.status + " getting " + + "last file sync time"); + return; + } + + var ts = req.responseText; + var date = new Date(req.responseText * 1000); + Zotero.debug("Last successful storage sync was " + date); + self._lastSyncTime = ts; + callback(ts); + }); +} + + +Zotero.Sync.Storage.Session.ZFS.prototype.setLastSyncTime = function (callback, useLastSyncTime) { + if (useLastSyncTime) { + var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)"; + Zotero.DB.query(sql, { int: this._lastSyncTime }); + + this._lastSyncTime = null; + this._cachedCredentials = false; + + if (callback) { + callback(); + } + return; + } + this._lastSyncTime = null; + + var uri = this.userURI; + var successFileURI = uri.clone(); + successFileURI.spec += "laststoragesync?auth=1"; + + var self = this; + + Zotero.Utilities.HTTP.doPost(successFileURI, "", function (req) { + Zotero.debug(req.responseText); + Zotero.debug(req.status); + + if (req.status != 200) { + self.onError("Unexpected status code " + req.status + " setting " + + "last file sync time"); + return; + } + + var ts = req.responseText; + + var sql = "REPLACE INTO version VALUES ('storage_zfs', ?)"; + Zotero.DB.query(sql, { int: ts }); + + self._cachedCredentials = false; + + if (callback) { + callback(); + } + }); +} + + +Zotero.Sync.Storage.Session.ZFS.prototype.purgeDeletedStorageFiles = function (callback) { + // If we don't have a user id we've never synced and don't need to bother + if (!Zotero.userID) { + return; + } + + var sql = "SELECT value FROM settings WHERE setting=? AND key=?"; + var values = Zotero.DB.columnQuery(sql, ['storage', 'zfsPurge']); + if (!values) { + return; + } + + Zotero.debug("Unlinking synced files on ZFS"); + + var uri = this.userURI; + uri.spec += "removestoragefiles?"; + for each(var value in values) { + switch (value) { + case 'user': + uri.spec += "user=1&"; + break; + + case 'group': + uri.spec += "group=1&"; + break; + + default: + throw ("Invalid zfsPurge value '" + value + "' in ZFS purgeDeletedStorageFiles()"); + } + } + uri.spec = uri.spec.substr(0, uri.spec.length - 1); + + var self = this; + + Zotero.Utilities.HTTP.doPost(uri, "", function (xmlhttp) { + if (xmlhttp.status != 204) { + if (callback) { + callback(false); + } + self.onError("Unexpected status code " + xmlhttp.status + " purging ZFS files"); + } + + var sql = "DELETE FROM settings WHERE setting=? AND key=?"; + Zotero.DB.query(sql, ['storage', 'zfsPurge']); + + if (callback) { + callback(true); + } + }); +} + + +// +// Private methods +// + +/** + * Get the storage URI for an item + * + * @inner + * @param {Zotero.Item} + * @return {nsIURI} URI of file on storage server + */ +Zotero.Sync.Storage.Session.ZFS.prototype._getItemURI = function (item) { + var uri = this.rootURI; + uri.spec += Zotero.URI.getItemPath(item) + '/file?auth=1'; + return uri; +} + + +Zotero.Sync.Storage.Session.ZFS.prototype._getUploadFile = function (item) { + if (Zotero.Attachments.getNumFiles(item) > 1) { + var file = Zotero.getTempDirectory(); + var filename = item.key + '.zip'; + file.append(filename); + } + else { + var file = item.getFile(); + } + return file; +} diff --git a/chrome/content/zotero/xpcom/sync.js b/chrome/content/zotero/xpcom/sync.js @@ -118,7 +118,7 @@ Zotero.Sync = new function() { } - /** + /** * @param {Date} lastSyncDate JS Date object * @param {Zotero.Sync.Server.ObjectKeySet} * @return TRUE if found, FALSE if none, or -1 if last sync time is before start of log @@ -443,30 +443,28 @@ Zotero.Sync.EventListener = new function () { Zotero.Sync.Runner = new function () { - this.__defineGetter__("lastSyncError", function () { - return _lastSyncError; - }); - this.__defineGetter__("background", function () { return _background; }); - var _lastSyncError; var _autoSyncTimer; var _queue; var _running; var _background; + var _warning = null; + this.init = function () { this.EventListener.init(); this.IdleListener.init(); } this.sync = function (background) { + _warning = null; + if (Zotero.Utilities.HTTP.browserIsOffline()){ - _lastSyncError = "Browser is offline"; // TODO: localize this.clearSyncTimeout(); // DEBUG: necessary? - this.setSyncIcon('error'); + this.setSyncIcon('error', "Browser is offline"); return false; } @@ -478,40 +476,135 @@ Zotero.Sync.Runner = new function () { Zotero.purgeDataObjects(true); _background = !!background; - - _queue = [ - Zotero.Sync.Server.sync, - Zotero.Sync.Storage.sync, - Zotero.Sync.Server.sync, - Zotero.Sync.Storage.sync - ]; _running = true; - _lastSyncError = ''; this.setSyncIcon('animate'); - this.next(); + + var storageSync = function () { + var syncNeeded = false; + + Zotero.Sync.Storage.sync( + 'webdav', + + { + // WebDAV success + onSuccess: function () { + syncNeeded = true; + + Zotero.Sync.Storage.sync( + 'zfs', + + { + // ZFS success + onSuccess: function () { + Zotero.Sync.Server.sync( + Zotero.Sync.Runner.stop, + Zotero.Sync.Runner.stop, + Zotero.Sync.Runner.error + ) + }, + + // ZFS skip + onSkip: function () { + if (syncNeeded) { + Zotero.Sync.Server.sync( + Zotero.Sync.Runner.stop, + Zotero.Sync.Runner.stop, + Zotero.Sync.Runner.error + ) + } + }, + + // ZFS cancel + onStop: Zotero.Sync.Runner.stop, + + // ZFS failure + onError: Zotero.Sync.Runner.error, + + onWarning: Zotero.Sync.Runner.warning + } + ) + }, + + // WebDAV skip + onSkip: function () { + Zotero.Sync.Storage.sync( + 'zfs', + + { + // ZFS success + onSuccess: function () { + Zotero.Sync.Server.sync({ + onSuccess: Zotero.Sync.Runner.stop, + onSkip: Zotero.Sync.Runner.stop, + onStop: Zotero.Sync.Runner.stop, + onError: Zotero.Sync.Runner.error + }) + }, + + // ZFS skip + onSkip: Zotero.Sync.Runner.stop, + + // ZFS cancel + onStop: Zotero.Sync.Runner.stop, + + // ZFS failure + onError: Zotero.Sync.Runner.error, + + onWarning: Zotero.Sync.Runner.warning + } + ) + }, + + // WebDAV cancel + onStop: Zotero.Sync.Runner.stop, + + // WebDAV failure + onError: Zotero.Sync.Runner.error + } + ) + } + + Zotero.Sync.Server.sync({ + // Sync 1 success + onSuccess: storageSync, + + // Sync 1 skip + onSkip: storageSync, + + // Sync 1 stop + onStop: Zotero.Sync.Runner.stop, + + // Sync 1 error + onError: Zotero.Sync.Runner.error + }); } - this.next = function () { - if (!_queue.length) { - this.setSyncIcon(); - _running = false; - return; + this.stop = function () { + if (_warning) { + Zotero.Sync.Runner.setSyncIcon('warning', _warning); + _warning = null; + } + else { + Zotero.Sync.Runner.setSyncIcon(); } - var func = _queue.shift(); - func(); + _running = false; } - this.setError = function (msg) { - this.setSyncIcon('error'); - _lastSyncError = msg; + /** + * Log a warning, but don't throw an error + */ + this.warning = function (e) { + Components.utils.reportError(e); + _warning = e; } - this.reset = function () { - _queue = []; + this.error = function (e) { + Zotero.Sync.Runner.setSyncIcon('error', e); _running = false; + throw (e); } @@ -593,12 +686,13 @@ Zotero.Sync.Runner = new function () { } - this.setSyncIcon = function (status) { + this.setSyncIcon = function (status, e) { status = status ? status : ''; switch (status) { case '': case 'animate': + case 'warning': case 'error': break; @@ -610,17 +704,113 @@ Zotero.Sync.Runner = new function () { var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] .getService(Components.interfaces.nsIWindowMediator); var win = wm.getMostRecentWindow('navigator:browser'); + + var warning = win.document.getElementById('zotero-tb-sync-warning'); var icon = win.document.getElementById('zotero-tb-sync'); - icon.setAttribute('status', status); - switch (status) { - case 'animate': - icon.setAttribute('disabled', true); - break; + + var message; + var buttonText; + var buttonCallback; + + if (e) { + if (e.error == Zotero.Error.ERROR_ZFS_OVER_QUOTA) { + // TODO: localize + message = "You have reached your Zotero File Storage quota. Some files were not synced.\n\n" + + "See your zotero.org account settings for additional storage options."; + + buttonText = "Open Account Settings"; + buttonCallback = function () { + var url = "https://www.zotero.org/settings/storage"; + + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var win = wm.getMostRecentWindow("navigator:browser"); + var browser = win.getBrowser(); + browser.selectedTab = browser.addTab(url); + } + } - default: - icon.setAttribute('disabled', false); + if (!message) { + message = e.message ? e.message : e; + } + } + + if (status == 'warning' || status == 'error') { + icon.setAttribute('status', ''); + warning.hidden = false; + warning.setAttribute('mode', status); + warning.tooltipText = "A sync error occurred. Click to view details."; + warning.onclick = function () { + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var win = wm.getMostRecentWindow("navigator:browser"); + + var pr = Components.classes["@mozilla.org/network/default-prompt;1"] + .createInstance(Components.interfaces.nsIPrompt); + // Warning + if (status == 'warning') { + var title = Zotero.getString('general.warning'); + + // If secondary button not specified, just use an alert + if (!buttonText) { + prompt.alert(title, message); + return; + } + + var buttonFlags = pr.BUTTON_POS_0 * pr.BUTTON_TITLE_OK + + pr.BUTTON_POS_1 * pr.BUTTON_TITLE_IS_STRING; + Zotero.debug(buttonFlags); + var index = pr.confirmEx( + title, + message, + buttonFlags, + "", + buttonText, + "", null, {} + ); + + if (index == 1) { + setTimeout(buttonCallback, 1); + } + } + + // Error + else if (status == 'error') { + var errorsLogged = Zotero.getErrors().length > 0; + // Probably not necessary, but let's be sure + if (!errorsLogged) { + Components.utils.reportError(message); + } + + var buttonFlags = pr.BUTTON_POS_0 * pr.BUTTON_TITLE_OK + + pr.BUTTON_POS_1 * pr.BUTTON_TITLE_IS_STRING; + var index = pr.confirmEx( + Zotero.getString('general.error'), + message, + buttonFlags, + "", + // TODO: localize + "Report Error...", + "", null, {} + ); + + if (index == 1) { + win.setTimeout(function () { + win.ZoteroPane.reportErrors(); + }, 1); + } + } + } + } + else { + icon.setAttribute('status', status); + warning.hidden = true; + warning.onclick = null; } + + // Disable button while spinning + icon.disabled = status == 'animate'; } } @@ -852,7 +1042,22 @@ Zotero.Sync.Server = new function () { var _throttleTimeout; var _canAutoResetClient = true; - function login(callback, callbackCallback) { + var _callbacks = { + onSuccess: function () { + Zotero.Sync.Runner.setSyncIcon(); + }, + onSkip: function () { + Zotero.Sync.Runner.setSyncIcon(); + }, + onStop: function () { + Zotero.Sync.Runner.setSyncIcon(); + }, + onError: function (msg) { + Zotero.Sync.Runner.error(msg); + } + }; + + function login(callback) { var url = _serverURL + "login"; var username = Zotero.Sync.Server.username; @@ -906,18 +1111,26 @@ Zotero.Sync.Server = new function () { //Zotero.debug('Got session ID ' + _sessionID + ' from server'); if (callback) { - callback(callbackCallback); + callback(); } }); } - function sync(callback) { + function sync(callbacks) { + for (var func in callbacks) { + _callbacks[func] = callbacks[func]; + } + + var self = this; + Zotero.Sync.Runner.setSyncIcon('animate'); if (!_sessionID) { Zotero.debug("Session ID not available -- logging in"); - Zotero.Sync.Server.login(Zotero.Sync.Server.sync, callback); + Zotero.Sync.Server.login(function () { + Zotero.Sync.Server.sync(_callbacks); + }); return; } @@ -955,7 +1168,9 @@ Zotero.Sync.Server = new function () { Zotero.debug("Invalid session ID -- logging in"); _sessionID = false; _syncInProgress = false; - Zotero.Sync.Server.login(Zotero.Sync.Server.sync, callback); + Zotero.Sync.Server.login(function () { + Zotero.Sync.Server.sync(_callbacks); + }); return; } @@ -976,32 +1191,36 @@ Zotero.Sync.Server = new function () { // Strip XML declaration and convert to E4X var xml = new XML(xmlhttp.responseText.replace(/<\?xml.*\?>/, '')); - Zotero.DB.beginTransaction(); - try { + // If no earliest date is provided by the server, the server + // account is empty + var earliestRemoteDate = parseInt(xml.@earliest) ? + new Date((xml.@earliest + 43200) * 1000) : false; + var noServerData = !!earliestRemoteDate; + + // Check to see if we're syncing with a different user var userID = parseInt(xml.@userID); var libraryID = parseInt(xml.@defaultLibraryID); - if (!_checkSyncUser(userID, libraryID)) { + var c = _checkSyncUser(userID, libraryID, noServerData); + if (c == 0) { + // Groups were deleted, so restart sync + Zotero.debug("Restarting sync"); + _syncInProgress = false; + Zotero.Sync.Server.sync(_callbacks); + return; + } + else if (c == -1) { Zotero.debug("Sync cancelled"); - Zotero.DB.rollbackTransaction(); Zotero.Sync.Server.unlock(function () { - if (callback) { - Zotero.Sync.Runner.setSyncIcon(); - callback(); - } - else { - Zotero.Sync.Runner.reset(); - Zotero.Sync.Runner.next(); - } + _callbacks.onStop(); }); _syncInProgress = false; return; } - Zotero.UnresponsiveScriptIndicator.disable(); + Zotero.DB.beginTransaction(); - var earliestRemoteDate = parseInt(xml.@earliest) ? - new Date((xml.@earliest + 43200) * 1000) : false; + Zotero.UnresponsiveScriptIndicator.disable(); var lastLocalSyncTime = Zotero.Sync.Server.lastLocalSyncTime; var lastLocalSyncDate = lastLocalSyncTime ? @@ -1039,14 +1258,7 @@ Zotero.Sync.Server = new function () { Zotero.debug("Sync cancelled"); Zotero.DB.rollbackTransaction(); Zotero.Sync.Server.unlock(function () { - if (callback) { - Zotero.Sync.Runner.setSyncIcon(); - callback(); - } - else { - Zotero.Sync.Runner.reset(); - Zotero.Sync.Runner.next(); - } + _callbacks.onSkip(); }); Zotero.reloadDataObjects(); Zotero.Sync.EventListener.resetIgnored(); @@ -1067,13 +1279,7 @@ Zotero.Sync.Server = new function () { Zotero.DB.commitTransaction(); Zotero.Sync.Server.unlock(function () { _syncInProgress = false; - if (callback) { - Zotero.Sync.Runner.setSyncIcon(); - callback(); - } - else { - Zotero.Sync.Runner.next(); - } + _callbacks.onSuccess(); }); return; } @@ -1114,13 +1320,7 @@ Zotero.Sync.Server = new function () { Zotero.DB.commitTransaction(); Zotero.Sync.Server.unlock(function () { _syncInProgress = false; - if (callback) { - Zotero.Sync.Runner.setSyncIcon(); - callback(); - } - else { - Zotero.Sync.Runner.next(); - } + _callbacks.onSuccess(); }); } @@ -1203,7 +1403,7 @@ Zotero.Sync.Server = new function () { } - function lock(callback, callbackCallback) { + function lock(callback) { Zotero.debug("Getting session lock"); if (!_sessionID) { @@ -1234,11 +1434,7 @@ Zotero.Sync.Server = new function () { if (response.firstChild.tagName == 'error') { if (_checkServerSessionLock(response.firstChild)) { - Zotero.Sync.Server.lock(function () { - if (callback) { - callback(callbackCallback); - } - }); + Zotero.Sync.Server.lock(callback ? function () { callback(); } : null); return; } @@ -1252,7 +1448,7 @@ Zotero.Sync.Server = new function () { _sessionLock = true; if (callback) { - callback(callbackCallback); + callback(); } }); } @@ -1300,7 +1496,9 @@ Zotero.Sync.Server = new function () { function clear(callback) { if (!_sessionID) { Zotero.debug("Session ID not available -- logging in"); - Zotero.Sync.Server.login(Zotero.Sync.Server.clear, callback); + Zotero.Sync.Server.login(function () { + Zotero.Sync.Server.clear(); + }); return; } @@ -1312,7 +1510,9 @@ Zotero.Sync.Server = new function () { if (_invalidSession(xmlhttp)) { Zotero.debug("Invalid session ID -- logging in"); _sessionID = false; - Zotero.Sync.Server.login(Zotero.Sync.Server.clear, callback); + Zotero.Sync.Server.login(function () { + Zotero.Sync.Server.clear(callback); + }); return; } @@ -1343,7 +1543,9 @@ Zotero.Sync.Server = new function () { function resetServer(callback) { if (!_sessionID) { Zotero.debug("Session ID not available -- logging in"); - Zotero.Sync.Server.login(Zotero.Sync.Server.resetServer, callback); + Zotero.Sync.Server.login(function () { + Zotero.Sync.Server.resetServer(callback); + }); return; } @@ -1355,7 +1557,9 @@ Zotero.Sync.Server = new function () { if (_invalidSession(xmlhttp)) { Zotero.debug("Invalid session ID -- logging in"); _sessionID = false; - Zotero.Sync.Server.login(Zotero.Sync.Server.reset, callback); + Zotero.Sync.Server.login(function () { + Zotero.Sync.Server.reset(); + }); return; } @@ -1391,11 +1595,12 @@ Zotero.Sync.Server = new function () { + "('lastlocalsync', 'lastremotesync', 'syncdeletelog')"; Zotero.DB.query(sql); + var sql = "DELETE FROM version WHERE schema IN " + + "('lastlocalsync', 'lastremotesync', 'syncdeletelog')"; + Zotero.DB.query(sql); + Zotero.DB.query("DELETE FROM syncDeleteLog"); Zotero.DB.query("DELETE FROM storageDeleteLog"); - sql = "DELETE FROM settings WHERE setting='account' AND " - + "key IN ('userID', 'username')"; - Zotero.DB.query(sql); sql = "INSERT INTO version VALUES ('syncdeletelog', ?)"; Zotero.DB.query(sql, Zotero.Date.getUnixTimestamp()); @@ -1545,6 +1750,41 @@ Zotero.Sync.Server = new function () { }, 1); break; + case 'LIBRARY_ACCESS_DENIED': + var background = Zotero.Sync.Runner.background; + setTimeout(function () { + var libraryID = parseInt(firstChild.getAttribute('libraryID')); + var group = Zotero.Groups.getByLibraryID(libraryID); + + var pr = Components.classes["@mozilla.org/network/default-prompt;1"] + .createInstance(Components.interfaces.nsIPrompt); + var buttonFlags = (pr.BUTTON_POS_0) * (pr.BUTTON_TITLE_IS_STRING) + + (pr.BUTTON_POS_1) * (pr.BUTTON_TITLE_CANCEL) + + pr.BUTTON_DELAY_ENABLE; + var index = pr.confirmEx( + Zotero.getString('general.warning'), + // TODO: localize + "You no longer have write access to the Zotero group '" + group.name + "', " + + "and changes you've made cannot be synced to the server.\n\n" + + "If you continue, your copy of the group will be reset to its state " + + "on the server, and your local modifications will be lost.\n\n" + + "If you would like a chance to copy your changes elsewhere or to request " + + "write access from a group administrator, cancel the sync now.", + buttonFlags, + "Reset Group and Sync", + null, null, null, {} + ); + + if (index == 0) { + group.erase(); + Zotero.Sync.Server.resetClient(); + Zotero.Sync.Storage.resetAllSyncStates(); + Zotero.Sync.Runner.sync(); + return; + } + }, 1); + break; + case 'TAG_TOO_LONG': if (!Zotero.Sync.Runner.background) { var tag = xmlhttp.responseXML.firstChild.getElementsByTagName('tag'); @@ -1626,9 +1866,19 @@ Zotero.Sync.Server = new function () { /** * Make sure we're syncing with the same account we used last time * - * @return TRUE if sync should continue, FALSE if cancelled + * @param {Integer} userID New userID + * @param {Integer} libraryID New libraryID + * @param {Integer} noServerData The server account is empty — this is + * the account after a server clear + * @return 1 if sync should continue, 0 if sync should restart, -1 if sync should cancel */ - function _checkSyncUser(userID, libraryID) { + function _checkSyncUser(userID, libraryID, noServerData) { + if (Zotero.DB.transactionInProgress()) { + throw ("Transaction in progress in Zotero.Sync.Server._checkSyncUser"); + } + + Zotero.DB.beginTransaction(); + var sql = "SELECT value FROM settings WHERE " + "setting='account' AND key='username'"; var lastUsername = Zotero.DB.valueQuery(sql); @@ -1636,7 +1886,11 @@ Zotero.Sync.Server = new function () { var lastUserID = Zotero.userID; var lastLibraryID = Zotero.libraryID; + var restartSync = false; + if (lastUserID && lastUserID != userID) { + var groups = Zotero.Groups.getAll(); + var pr = Components.classes["@mozilla.org/network/default-prompt;1"] .createInstance(Components.interfaces.nsIPrompt); var buttonFlags = (pr.BUTTON_POS_0) * (pr.BUTTON_TITLE_IS_STRING) @@ -1644,54 +1898,106 @@ Zotero.Sync.Server = new function () { + (pr.BUTTON_POS_2) * (pr.BUTTON_TITLE_IS_STRING) + pr.BUTTON_POS_1_DEFAULT + pr.BUTTON_DELAY_ENABLE; - var index = pr.confirmEx( - Zotero.getString('general.warning'), - // TODO: localize - "This Zotero database was last synced with a different " + + var msg = "This Zotero database was last synced with a different " + "zotero.org account ('" + lastUsername + "') from the " - + "current one ('" + username + "'). " - + "If you continue, local Zotero data will be " - + "combined with data from the '" + username + "' account " - + "stored on the server.\n\n" - + "To avoid combining data, revert to the '" - + lastUsername + "' account or use the Reset options " - + "in the Sync pane of the Zotero preferences.", - buttonFlags, - "Sync", - null, - "Open Sync Preferences", - null, {} - ); + + "current one ('" + username + "'). "; - if (index > 0) { - if (index == 1) { - // Cancel + if (!noServerData) { + // TODO: localize + msg += "If you continue, local Zotero data will be " + + "combined with data from the '" + username + "' account " + + "stored on the server."; + // If there are local groups belonging to the previous user, + // we need to remove them + if (groups.length) { + msg += "Local groups, including any with changed items, will also " + + "be removed."; } - else if (index == 2) { - var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] - .getService(Components.interfaces.nsIWindowMediator); - var lastWin = wm.getMostRecentWindow("navigator:browser"); - lastWin.ZoteroPane.openPreferences('zotero-prefpane-sync'); + msg += "\n\n" + + "To avoid combining or losing data, revert to the '" + + lastUsername + "' account or use the Reset options " + + "in the Sync pane of the Zotero preferences."; + + var syncButtonText = "Sync"; + } + else if (groups.length) { + msg += "If you continue, local groups, including any with changed items, " + + "will be removed and replaced with groups linked to the '" + + username + "' account." + + "\n\n" + + "To avoid losing local changes to groups, be sure you " + + "have synced with the '" + lastUsername + "' account before " + + "syncing with the '" + username + "' account."; + + var syncButtonText = "Remove Groups and Sync"; + } + // If there are no local groups and the server is empty, + // don't bother prompting + else { + var noPrompt = true; + } + + if (!noPrompt) { + var index = pr.confirmEx( + Zotero.getString('general.warning'), + msg, + buttonFlags, + syncButtonText, + null, + "Open Sync Preferences", + null, {} + ); + + if (index > 0) { + if (index == 1) { + // Cancel + } + else if (index == 2) { + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + var lastWin = wm.getMostRecentWindow("navigator:browser"); + lastWin.ZoteroPane.openPreferences('zotero-prefpane-sync'); + } + + return -1; + } + + // Delete all local groups + for each(var group in groups) { + group.erase(); } - return false; + restartSync = true; } } if (lastUserID != userID || lastLibraryID != libraryID) { Zotero.userID = userID; Zotero.libraryID = libraryID; + + // Update userID in relations + if (lastUserID && lastLibraryID) { + Zotero.Relations.updateUser(lastUserID, lastLibraryID, userID, libraryID); + } + + Zotero.Sync.Server.resetClient(); + Zotero.Sync.Storage.resetAllSyncStates(); } if (lastUsername != username) { - var sql = "REPLACE INTO settings VALUES ('account', 'username', ?)"; - Zotero.DB.query(sql, username); + Zotero.username = username; } - return true; + Zotero.DB.commitTransaction(); + + return restartSync ? 0 : 1; } + + + function _invalidSession(xmlhttp) { if (xmlhttp.responseXML.childNodes[0].firstChild.tagName != 'error') { return false; @@ -1783,8 +2089,7 @@ Zotero.Sync.Server = new function () { Zotero.Sync.Server.unlock() } - Zotero.Sync.Runner.setError(e.message ? e.message : e); - Zotero.Sync.Runner.reset(); + _callbacks.onError(e); throw (e); } @@ -1970,6 +2275,7 @@ Zotero.Sync.Server.Data = new function() { var itemStorageModTimes = {}; var childItemStore = []; + // Remotely changed groups if (xml.groups.length()) { Zotero.debug("Processing remotely changed groups"); for each(var xmlNode in xml.groups.group) { @@ -1978,6 +2284,7 @@ Zotero.Sync.Server.Data = new function() { } } + // Remotely deleted groups if (xml.deleted.groups.toString()) { Zotero.debug("Processing remotely deleted groups"); var groupIDs = xml.deleted.groups.toString().split(' '); @@ -1988,21 +2295,14 @@ Zotero.Sync.Server.Data = new function() { if (!group) { continue; } - // TODO: prompt to save - Zotero.Notifier.disable(); + // TODO: prompt to save data to local library? - // TODO: figure out a better way to do this - var notifierData = {}; - notifierData[groupID] = group.serialize(); group.erase(); - - Zotero.Notifier.enable(); - - Zotero.Notifier.trigger('delete', 'group', groupID, notifierData); } } + // Other objects for each(var syncObject in Zotero.Sync.syncObjects) { var Type = syncObject.singular; // 'Item' var Types = syncObject.plural; // 'Items' @@ -2271,11 +2571,7 @@ Zotero.Sync.Server.Data = new function() { syncSession.removeFromDeleted(creator.ref); } } - else if (obj.isAttachment() && - (obj.attachmentLinkMode == - Zotero.Attachments.LINK_MODE_IMPORTED_FILE || - obj.attachmentLinkMode == - Zotero.Attachments.LINK_MODE_IMPORTED_URL)) { + else if (obj.isImportedAttachment()) { // Mark new attachments for download if (isNewObject) { obj.attachmentSyncState = @@ -2285,7 +2581,8 @@ Zotero.Sync.Server.Data = new function() { else { var mtime = xmlNode.@storageModTime.toString(); if (mtime) { - itemStorageModTimes[obj.key] = parseInt(mtime); + var lk = Zotero.Items.getLibraryKeyHash(obj) + itemStorageModTimes[lk] = parseInt(mtime); } } } @@ -2504,15 +2801,16 @@ Zotero.Sync.Server.Data = new function() { } } - // Check mod times of updated items against stored time to see + // Check mod times and hashes of updated items against stored values to see // if they've been updated elsewhere and mark for download if so if (type == 'item') { var ids = []; var modTimes = {}; - for (var key in itemStorageModTimes) { - var item = Zotero.Items.getByLibraryAndKey(null, key); + for (var libraryKeyHash in itemStorageModTimes) { + var lk = Zotero.Items.parseLibraryKeyHash(libraryKeyHash); + var item = Zotero.Items.getByLibraryAndKey(lk.libraryID, lk.key); ids.push(item.id); - modTimes[item.id] = itemStorageModTimes[key]; + modTimes[item.id] = itemStorageModTimes[libraryKeyHash]; } if (ids.length > 0) { Zotero.Sync.Storage.checkForUpdatedFiles(ids, modTimes); @@ -3003,15 +3301,23 @@ Zotero.Sync.Server.Data = new function() { xml.@charset = charset; } - // 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; - } - + // Include paths for non-links var path = <path>{item.attachment.path}</path>; xml.path += path; + + // Include storage sync time and hash for imported files + if (item.attachment.linkMode != Zotero.Attachments.LINK_MODE_LINKED_FILE) { + var mtime = Zotero.Sync.Storage.getSyncedModificationTime(item.primary.itemID); + if (mtime) { + xml.@storageModTime = mtime; + } + + var hash = Zotero.Sync.Storage.getSyncedHash(item.primary.itemID); + if (hash) { + xml.@storageHash = hash; + } + } } if (item.note) { diff --git a/chrome/content/zotero/xpcom/uri.js b/chrome/content/zotero/xpcom/uri.js @@ -1,5 +1,6 @@ Zotero.URI = new function () { var _baseURI = ZOTERO_CONFIG.BASE_URI; + var _apiURI = ZOTERO_CONFIG.API_URI; /** @@ -22,8 +23,11 @@ Zotero.URI = new function () { * * @return {String} */ - this.getCurrentUserURI = function () { + this.getCurrentUserURI = function (noLocal) { var userID = Zotero.userID; + if (!userID && noLocal) { + throw new Exception("Local userID not available and noLocal set in Zotero.URI.getCurrentUserURI()"); + } if (userID) { return _baseURI + "users/" + userID; } @@ -42,22 +46,44 @@ Zotero.URI = new function () { this.getLibraryURI = function (libraryID) { - var libraryType = Zotero.Libraries.getType(libraryID); + var path = this.getLibraryPath(libraryID); + return _baseURI + path; + } + + + /** + * Get path portion of library URI (e.g., users/6 or groups/1) + */ + this.getLibraryPath = function (libraryID) { + if (libraryID) { + var libraryType = Zotero.Libraries.getType(libraryID); + } + else { + libraryType = 'user'; + } switch (libraryType) { + case 'user': + var id = Zotero.userID; + if (!id) { + throw new Exception("User id not available in Zotero.URI.getLibraryPath()"); + } + break; + case 'group': var id = Zotero.Groups.getGroupIDFromLibraryID(libraryID); break; - case 'user': - throw ("User library ids are not supported in Zotero.URI.getLibraryURI"); - default: - throw ("Unsupported library type '" + libraryType + "' in Zotero.URI.getLibraryURI()"); + throw ("Unsupported library type '" + libraryType + "' in Zotero.URI.getLibraryPath()"); } - return _baseURI + libraryType + "s/" + id; + + return libraryType + "s/" + id; } + /** + * Return URI of item, which might be a local URI if user hasn't synced + */ this.getItemURI = function (item) { if (item.libraryID) { var baseURI = this.getLibraryURI(item.libraryID); @@ -69,6 +95,14 @@ Zotero.URI = new function () { } + /** + * Get path portion of item URI (e.g., users/6/items/ABCD1234 or groups/1/items/ABCD1234) + */ + this.getItemPath = function (item) { + return this.getLibraryPath(item.libraryID) + "/items/" + item.key; + } + + this.getGroupsURL = function () { return ZOTERO_CONFIG.WWW_BASE_URL + "groups"; } diff --git a/chrome/content/zotero/xpcom/utilities.js b/chrome/content/zotero/xpcom/utilities.js @@ -396,19 +396,51 @@ Zotero.Utilities.prototype.getSQLDataType = function(value) { /* - * From http://developer.mozilla.org/en/docs/nsICryptoHash#Computing_the_Hash_of_a_String + * Adapted from http://developer.mozilla.org/en/docs/nsICryptoHash + * + * @param {String|nsIFile} strOrFile + * @param {Boolean} [base64=false] Return as base-64-encoded string rather than hex string + * @return {String} */ -Zotero.Utilities.prototype.md5 = function(str) { - var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]. - createInstance(Components.interfaces.nsIScriptableUnicodeConverter); - converter.charset = "UTF-8"; - var result = {}; - var data = converter.convertToByteArray(str, result); - var ch = Components.classes["@mozilla.org/security/hash;1"] - .createInstance(Components.interfaces.nsICryptoHash); - ch.init(ch.MD5); - ch.update(data, data.length); - var hash = ch.finish(false); +Zotero.Utilities.prototype.md5 = function(strOrFile, base64) { + if (typeof strOrFile == 'string') { + var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]. + createInstance(Components.interfaces.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + var result = {}; + var data = converter.convertToByteArray(strOrFile, result); + var ch = Components.classes["@mozilla.org/security/hash;1"] + .createInstance(Components.interfaces.nsICryptoHash); + ch.init(ch.MD5); + ch.update(data, data.length); + } + else if (strOrFile instanceof Components.interfaces.nsIFile) { + var istream = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(Components.interfaces.nsIFileInputStream); + // open for reading + istream.init(strOrFile, 0x01, 0444, 0); + var ch = Components.classes["@mozilla.org/security/hash;1"] + .createInstance(Components.interfaces.nsICryptoHash); + // we want to use the MD5 algorithm + ch.init(ch.MD5); + // this tells updateFromStream to read the entire file + const PR_UINT32_MAX = 0xffffffff; + ch.updateFromStream(istream, PR_UINT32_MAX); + } + + // pass false here to get binary data back + var hash = ch.finish(base64); + + if (istream) { + istream.close(); + } + + if (base64) { + return hash; + } + + /* + // This created 36-character hashes // return the two-digit hexadecimal code for a byte function toHexString(charCode) { @@ -417,6 +449,17 @@ Zotero.Utilities.prototype.md5 = function(str) { // convert the binary hash data to a hex string. return [toHexString(hash.charCodeAt(i)) for (i in hash)].join(""); + */ + + // From http://rcrowley.org/2007/11/15/md5-in-xulrunner-or-firefox-extensions/ + var ascii = []; ii = hash.length; + for (var i = 0; i < ii; ++i) { + var c = hash.charCodeAt(i); + var ones = c % 16; + var tens = c >> 4; + ascii.push(String.fromCharCode(tens + (tens > 9 ? 87 : 48)) + String.fromCharCode(ones + (ones > 9 ? 87 : 48))); + } + return ascii.join(''); } @@ -795,17 +838,18 @@ Zotero.Utilities.Translate.prototype.retrieveDocument = function(url) { * @param {String} url URL to load * @param {String} [body=null] Request body to POST to the URL; a GET request is * executed if no body is present - * @param {String} [requestContentType=application/x-www-form-urlencoded] - * Request content type for POST; ignored if no body + * @param {Object} [headers] HTTP headers to include in request; + * Content-Type defaults to application/x-www-form-urlencoded + * for POST; ignored if no body * @param {String} [responseCharset] Character set to force on the response * @return {String} Request body */ -Zotero.Utilities.Translate.prototype.retrieveSource = function(url, body, requestContentType, responseCharset) { +Zotero.Utilities.Translate.prototype.retrieveSource = function(url, body, headers, responseCharset) { /* Apparently, a synchronous XMLHttpRequest would have the behavior of this routine in FF3, but * in FF3.5, synchronous XHR blocks all JavaScript on the thread. See * http://hacks.mozilla.org/2009/07/synchronous-xhr/. */ if(this.translate.locationIsProxied) url = this._convertURL(url); - if(!requestContentType) requestContentType = null; + if(!headers) headers = null; if(!responseCharset) responseCharset = null; var mainThread = Zotero.mainThread; @@ -813,7 +857,7 @@ Zotero.Utilities.Translate.prototype.retrieveSource = function(url, body, reques var listener = function(aXmlhttp) { xmlhttp = aXmlhttp }; if(body) { - Zotero.Utilities.HTTP.doPost(url, body, listener, requestContentType, responseCharset); + Zotero.Utilities.HTTP.doPost(url, body, listener, headers, responseCharset); } else { Zotero.Utilities.HTTP.doGet(url, listener, responseCharset); } @@ -870,7 +914,7 @@ Zotero.Utilities.Translate.prototype.doGet = function(urls, processor, done, res * Already documented in Zotero.Utilities.HTTP * @ignore */ -Zotero.Utilities.Translate.prototype.doPost = function(url, body, onDone, requestContentType, responseCharset) { +Zotero.Utilities.Translate.prototype.doPost = function(url, body, onDone, headers, responseCharset) { url = this._convertURL(url); var translate = this.translate; @@ -880,7 +924,7 @@ Zotero.Utilities.Translate.prototype.doPost = function(url, body, onDone, reques } catch(e) { translate.error(false, e); } - }, requestContentType, responseCharset); + }, headers, responseCharset); } /** @@ -936,7 +980,6 @@ Zotero.Utilities.HTTP = new function() { } else { Zotero.debug("HTTP GET " + url); - } if (this.browserIsOffline()){ return false; @@ -981,12 +1024,20 @@ Zotero.Utilities.HTTP = new function() { * @param {String} url URL to request * @param {String} body Request body * @param {Function} onDone Callback to be executed upon request completion - * @param {String} requestContentType Request content type (usually - * application/x-www-form-urlencoded) + * @param {String} headers Request HTTP headers * @param {String} responseCharset Character set to force on the response * @return {Boolean} True if the request was sent, or false if the browser is offline */ - this.doPost = function(url, body, onDone, requestContentType, responseCharset) { + this.doPost = function(url, body, onDone, headers, responseCharset) { + if (url instanceof Components.interfaces.nsIURI) { + // Don't display password in console + var disp = url.clone(); + if (disp.password) { + disp.password = "********"; + } + url = url.spec; + } + var bodyStart = body.substr(0, 1024); // Don't display sync password or session id in console bodyStart = bodyStart.replace(/password=[^&]+/, 'password=********'); @@ -995,7 +1046,7 @@ Zotero.Utilities.HTTP = new function() { Zotero.debug("HTTP POST " + (body.length > 1024 ? bodyStart + '... (' + body.length + ' chars)' : bodyStart) - + " to " + url); + + " to " + (disp ? disp.spec : url)); if (this.browserIsOffline()){ @@ -1025,7 +1076,27 @@ Zotero.Utilities.HTTP = new function() { xmlhttp.channel.loadGroup = ds.getInterface(Ci.nsILoadGroup); xmlhttp.channel.loadFlags |= Ci.nsIChannel.LOAD_DOCUMENT_URI; - xmlhttp.setRequestHeader("Content-Type", (requestContentType ? requestContentType : "application/x-www-form-urlencoded" )); + if (headers) { + if (typeof headers == 'string') { + var msg = "doPost() now takes a headers object rather than a requestContentType -- update your code"; + Zotero.debug(msg, 2); + Components.utils.reportError(msg); + headers = { + "Content-Type": headers + }; + } + } + else { + headers = {}; + } + + if (!headers["Content-Type"]) { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + } + + for (var header in headers) { + xmlhttp.setRequestHeader(header, headers[header]); + } /** @ignore */ xmlhttp.onreadystatechange = function(){ @@ -1042,10 +1113,24 @@ Zotero.Utilities.HTTP = new function() { * * @param {String} url URL to request * @param {Function} onDone Callback to be executed upon request completion + * @param {Object} requestHeaders HTTP headers to include with request * @return {Boolean} True if the request was sent, or false if the browser is offline */ - this.doHead = function(url, onDone) { - Zotero.debug("HTTP HEAD "+url); + this.doHead = function(url, onDone, requestHeaders) { + if (url instanceof Components.interfaces.nsIURI) { + // Don't display password in console + var disp = url.clone(); + if (disp.password) { + disp.password = "********"; + } + Zotero.debug("HTTP HEAD " + disp.spec); + url = url.spec; + } + else { + Zotero.debug("HTTP HEAD " + url); + + } + if (this.browserIsOffline()){ return false; } @@ -1068,10 +1153,20 @@ Zotero.Utilities.HTTP = new function() { ds.itemType = Ci.nsIDocShellTreeItem.typeContent; var xmlhttp = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. createInstance(Ci.nsIXMLHttpRequest); + // Prevent certificate/authentication dialogs from popping up + xmlhttp.mozBackgroundRequest = true; xmlhttp.open("HEAD", url, true); + if (requestHeaders) { + for (var header in requestHeaders) { + xmlhttp.setRequestHeader(header, requestHeaders[header]); + } + } xmlhttp.channel.loadGroup = ds.getInterface(Ci.nsILoadGroup); xmlhttp.channel.loadFlags |= Ci.nsIChannel.LOAD_DOCUMENT_URI; + // Don't cache HEAD requests + xmlhttp.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + /** @ignore */ xmlhttp.onreadystatechange = function(){ _stateChange(xmlhttp, onDone); diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js @@ -28,7 +28,8 @@ const ZOTERO_CONFIG = { REPOSITORY_RETRY_INTERVAL: 3600, // 1 hour BASE_URI: 'http://zotero.org/', WWW_BASE_URL: 'http://www.zotero.org/', - SYNC_URL: 'https://sync.zotero.org/' + SYNC_URL: 'https://sync.zotero.org/', + API_URL: 'https://api.zotero.org/' }; /* @@ -100,6 +101,17 @@ var Zotero = new function(){ Zotero.DB.query(sql, parseInt(val)); }); + this.__defineGetter__('username', function () { + var sql = "SELECT value FROM settings WHERE " + + "setting='account' AND key='username'"; + return Zotero.DB.valueQuery(sql); + }); + + this.__defineSetter__('username', function (val) { + var sql = "REPLACE INTO settings VALUES ('account', 'username', ?)"; + Zotero.DB.query(sql, val); + }); + this.getLocalUserKey = function (generate) { if (_localUserKey) { return _localUserKey; @@ -1160,8 +1172,9 @@ var Zotero = new function(){ Zotero.Fulltext.purgeUnusedWords(); Zotero.Items.purge(); - if (!skipStoragePurge && Zotero.Sync.Storage.active && Zotero.Utilities.prototype.probability(10)) { - Zotero.Sync.Storage.purgeDeletedStorageFiles(); + if (!skipStoragePurge && Zotero.Utilities.prototype.probability(10)) { + Zotero.Sync.Storage.purgeDeletedStorageFiles('zfs'); + Zotero.Sync.Storage.purgeDeletedStorageFiles('webdav'); } } diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties @@ -1,5 +1,6 @@ extensions.zotero@chnm.gmu.edu.description = The Next-Generation Research Tool +general.success = Success general.error = Error general.warning = Warning general.dontShowWarningAgain = Don't show this warning again. diff --git a/chrome/skin/default/zotero/error.png b/chrome/skin/default/zotero/error.png Binary files differ. diff --git a/chrome/skin/default/zotero/exclamation.png b/chrome/skin/default/zotero/exclamation.png Binary files differ. diff --git a/chrome/skin/default/zotero/overlay.css b/chrome/skin/default/zotero/overlay.css @@ -205,9 +205,15 @@ font-weight: bold; } -#zotero-tb-storage-sync +#zotero-tb-sync-warning { - list-style-image: url(chrome://zotero/skin/drive_network.png); + list-style-image: url(chrome://zotero/skin/error.png); + margin-right: -5px; +} + +#zotero-tb-sync-warning[error=true] +{ + list-style-image: url(chrome://zotero/skin/exclamation.png); } #zotero-tb-sync { @@ -220,10 +226,6 @@ list-style-image: url(chrome://zotero/skin/arrow_rotate_animated.png); } -#zotero-tb-sync[status=error] { - list-style-image: url(chrome://zotero/skin/arrow_rotate_error.png); -} - #zotero-tb-sync #zotero-last-sync-time { color: gray; diff --git a/components/zotero-service.js b/components/zotero-service.js @@ -64,6 +64,9 @@ var xpcomFiles = [ 'style', 'sync', 'storage', + 'storage/session', + 'storage/zfs', + 'storage/webdav', 'timeline', 'translate', 'uri', diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js @@ -37,6 +37,10 @@ pref("extensions.zotero.sortAttachmentsChronologically", false); pref("extensions.zotero.showTrashWhenEmpty", true); pref("extensions.zotero.viewOnDoubleClick", true); +pref("extensions.zotero.groups.copyChildLinks", true); +pref("extensions.zotero.groups.copyChildFileAttachments", true); +pref("extensions.zotero.groups.copyChildNotes", true); + pref("extensions.zotero.backup.numBackups", 2); pref("extensions.zotero.backup.interval", 1440); @@ -113,14 +117,16 @@ pref("extensions.zotero.annotations.warnOnClose", true); pref("extensions.zotero.sync.autoSync", true); pref("extensions.zotero.sync.server.username", ''); pref("extensions.zotero.sync.server.compressData", true); -pref("extensions.zotero.sync.storage.protocol", "webdavs"); -pref("extensions.zotero.sync.storage.enabled", false); +pref("extensions.zotero.sync.storage.enabled", true); +pref("extensions.zotero.sync.storage.protocol", "zotero"); pref("extensions.zotero.sync.storage.verified", false); +pref("extensions.zotero.sync.storage.scheme", 'https'); 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); +pref("extensions.zotero.sync.storage.groups.enabled", true); // Proxy pref("extensions.zotero.proxies.transparent", true); diff --git a/userdata.sql b/userdata.sql @@ -1,4 +1,4 @@ --- 62 +-- 63 -- This file creates tables containing user-specific data for new users -- -- any changes made here must be mirrored in transition steps in schema.js::_migrateSchema() @@ -70,6 +70,7 @@ CREATE TABLE itemAttachments ( originalPath TEXT, syncState INT DEFAULT 0, storageModTime INT, + storageHash TEXT, FOREIGN KEY (itemID) REFERENCES items(itemID), FOREIGN KEY (sourceItemID) REFERENCES items(itemID) );