syncLocal.js (58534B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2014 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 if (!Zotero.Sync.Data) { 27 Zotero.Sync.Data = {}; 28 } 29 30 Zotero.Sync.Data.Local = { 31 _syncQueueIntervals: [0.5, 1, 4, 16, 16, 16, 16, 16, 16, 16, 64], // hours 32 _loginManagerHost: 'chrome://zotero', 33 _loginManagerRealm: 'Zotero Web API', 34 _lastSyncTime: null, 35 _lastClassicSyncTime: null, 36 37 init: Zotero.Promise.coroutine(function* () { 38 yield this._loadLastSyncTime(); 39 if (!_lastSyncTime) { 40 yield this._loadLastClassicSyncTime(); 41 } 42 }), 43 44 45 /** 46 * @return {Promise} 47 */ 48 getAPIKey: Zotero.Promise.method(function () { 49 var login = this._getAPIKeyLoginInfo(); 50 return login 51 ? login.password 52 // Fallback to old username/password 53 : this._getAPIKeyFromLogin(); 54 }), 55 56 57 /** 58 * Check for an API key or a legacy username/password (which may or may not be valid) 59 */ 60 hasCredentials: function () { 61 var login = this._getAPIKeyLoginInfo(); 62 if (login) { 63 return true; 64 } 65 // If no API key, check for legacy login 66 var username = Zotero.Prefs.get('sync.server.username'); 67 return username && !!this.getLegacyPassword(username) 68 }, 69 70 71 setAPIKey: function (apiKey) { 72 var loginManager = Components.classes["@mozilla.org/login-manager;1"] 73 .getService(Components.interfaces.nsILoginManager); 74 75 var oldLoginInfo = this._getAPIKeyLoginInfo(); 76 77 // Clear old login 78 if ((!apiKey || apiKey === "")) { 79 if (oldLoginInfo) { 80 Zotero.debug("Clearing old API key"); 81 loginManager.removeLogin(oldLoginInfo); 82 } 83 Zotero.Notifier.trigger('delete', 'api-key', []); 84 return; 85 } 86 87 var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", 88 Components.interfaces.nsILoginInfo, "init"); 89 var loginInfo = new nsLoginInfo( 90 this._loginManagerHost, 91 null, 92 this._loginManagerRealm, 93 'API Key', 94 apiKey, 95 '', 96 '' 97 ); 98 if (!oldLoginInfo) { 99 Zotero.debug("Setting API key"); 100 loginManager.addLogin(loginInfo); 101 } 102 else { 103 Zotero.debug("Replacing API key"); 104 loginManager.modifyLogin(oldLoginInfo, loginInfo); 105 } 106 Zotero.Notifier.trigger('modify', 'api-key', []); 107 }, 108 109 110 /** 111 * Make sure we're syncing with the same account we used last time, and prompt if not. 112 * If user accepts, change the current user and initiate deletion of all user data after a 113 * restart. 114 * 115 * @param {Window|null} 116 * @param {Integer} userID - New userID 117 * @param {Integer} username - New username 118 * @return {Boolean} - True to continue, false to cancel 119 */ 120 checkUser: Zotero.Promise.coroutine(function* (win, userID, username) { 121 var lastUserID = Zotero.Users.getCurrentUserID(); 122 var lastUsername = Zotero.Users.getCurrentUsername(); 123 124 if (lastUserID && lastUserID != userID) { 125 Zotero.debug(`Last user id ${lastUserID}, current user id ${userID}, ` 126 + `last username '${lastUsername}', current username '${username}'`, 2); 127 var io = { 128 title: Zotero.getString('general.warning'), 129 text: [Zotero.getString('account.lastSyncWithDifferentAccount', [ZOTERO_CONFIG.CLIENT_NAME, lastUsername, username])], 130 checkboxLabel: Zotero.getString('account.confirmDelete'), 131 acceptLabel: Zotero.getString('account.confirmDelete.button') 132 }; 133 win.openDialog("chrome://zotero/content/hardConfirmationDialog.xul", "", 134 "chrome, dialog, modal, centerscreen", io); 135 136 if (io.accept) { 137 var resetDataDirFile = OS.Path.join(Zotero.DataDirectory.dir, 'reset-data-directory'); 138 yield Zotero.File.putContentsAsync(resetDataDirFile, ''); 139 140 Zotero.Prefs.clear('sync.storage.downloadMode.groups'); 141 Zotero.Prefs.clear('sync.storage.groups.enabled'); 142 Zotero.Prefs.clear('sync.storage.downloadMode.personal'); 143 Zotero.Prefs.clear('sync.storage.username'); 144 Zotero.Prefs.clear('sync.storage.url'); 145 Zotero.Prefs.clear('sync.storage.scheme'); 146 Zotero.Prefs.clear('sync.storage.protocol'); 147 Zotero.Prefs.clear('sync.storage.enabled'); 148 149 Zotero.Utilities.Internal.quitZotero(true); 150 151 return true; 152 } 153 154 return false; 155 } 156 157 yield Zotero.DB.executeTransaction(function* () { 158 if (lastUsername != username) { 159 yield Zotero.Users.setCurrentUsername(username); 160 } 161 if (!lastUserID) { 162 yield Zotero.Users.setCurrentUserID(userID); 163 164 // Replace local user key with libraryID, in case duplicates were merged before the 165 // first sync 166 yield Zotero.Relations.updateUser(null, userID); 167 } 168 }); 169 170 return true; 171 }), 172 173 174 /** 175 * @return {Promise<Boolean>} - True if library updated, false to cancel 176 */ 177 checkLibraryForAccess: Zotero.Promise.coroutine(function* (win, libraryID, editable, filesEditable) { 178 var library = Zotero.Libraries.get(libraryID); 179 180 // If library is going from editable to non-editable and there's unsynced local data, prompt 181 if (library.editable && !editable && (yield this._libraryHasUnsyncedData(libraryID))) { 182 let index = Zotero.Sync.Data.Utilities.showWriteAccessLostPrompt(win, library); 183 // Reset library 184 if (index == 0) { 185 // This check happens before item data is loaded for syncing, so do it now, 186 // since the reset requires it 187 if (!library.getDataLoaded('item')) { 188 yield library.waitForDataLoad('item'); 189 } 190 yield this.resetUnsyncedLibraryData(libraryID); 191 return true; 192 } 193 194 // Skip library 195 return false; 196 } 197 198 if (library.filesEditable && !filesEditable && (yield this._libraryHasUnsyncedFiles(libraryID))) { 199 let index = Zotero.Sync.Storage.Utilities.showFileWriteAccessLostPrompt(win, library); 200 // Reset library files 201 if (index == 0) { 202 // This check happens before item data is loaded for syncing, so do it now, 203 // since the reset requires it 204 if (!library.getDataLoaded('item')) { 205 yield library.waitForDataLoad('item'); 206 } 207 yield this.resetUnsyncedLibraryFiles(libraryID); 208 return true; 209 } 210 211 // Skip library 212 return false; 213 } 214 215 return true; 216 }), 217 218 219 _libraryHasUnsyncedData: Zotero.Promise.coroutine(function* (libraryID) { 220 let settings = yield Zotero.SyncedSettings.getUnsynced(libraryID); 221 if (Object.keys(settings).length) { 222 return true; 223 } 224 225 for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) { 226 let ids = yield Zotero.Sync.Data.Local.getUnsynced(objectType, libraryID); 227 if (ids.length) { 228 return true; 229 } 230 231 let keys = yield Zotero.Sync.Data.Local.getDeleted(objectType, libraryID); 232 if (keys.length) { 233 return true; 234 } 235 } 236 237 return false; 238 }), 239 240 241 _libraryHasUnsyncedFiles: Zotero.Promise.coroutine(function* (libraryID) { 242 // TODO: Check for modified file attachment items, which also can't be uploaded 243 // (and which are corrected by resetUnsyncedLibraryFiles()) 244 yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID); 245 return !!(yield Zotero.Sync.Storage.Local.getFilesToUpload(libraryID)).length; 246 }), 247 248 249 resetUnsyncedLibraryData: Zotero.Promise.coroutine(function* (libraryID) { 250 let settings = yield Zotero.SyncedSettings.getUnsynced(libraryID); 251 if (Object.keys(settings).length) { 252 yield Zotero.Promise.each(Object.keys(settings), function (key) { 253 return Zotero.SyncedSettings.clear(libraryID, key, { skipDeleteLog: true }); 254 }); 255 } 256 257 for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) { 258 // New/modified objects 259 let ids = yield this.getUnsynced(objectType, libraryID); 260 yield this._resetObjects(libraryID, objectType, ids); 261 } 262 263 // Mark library for full sync 264 var library = Zotero.Libraries.get(libraryID); 265 library.libraryVersion = -1; 266 yield library.saveTx(); 267 268 yield this.resetUnsyncedLibraryFiles(libraryID); 269 }), 270 271 272 /** 273 * Delete unsynced files from library 274 * 275 * _libraryHasUnsyncedFiles(), which checks for updated files, must be called first. 276 */ 277 resetUnsyncedLibraryFiles: async function (libraryID) { 278 // Reset unsynced file attachments 279 var itemIDs = await Zotero.Sync.Data.Local.getUnsynced('item', libraryID); 280 var toReset = []; 281 for (let itemID of itemIDs) { 282 let item = Zotero.Items.get(itemID); 283 if (item.isFileAttachment()) { 284 toReset.push(item.id); 285 } 286 } 287 await this._resetObjects(libraryID, 'item', toReset); 288 289 // Delete unsynced files 290 var itemIDs = await Zotero.Sync.Storage.Local.getFilesToUpload(libraryID); 291 for (let itemID of itemIDs) { 292 let item = Zotero.Items.get(itemID); 293 await item.deleteAttachmentFile(); 294 } 295 }, 296 297 298 _resetObjects: async function (libraryID, objectType, ids) { 299 var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 300 301 var keys = ids.map(id => objectsClass.getLibraryAndKeyFromID(id).key); 302 var cacheVersions = await this.getLatestCacheObjectVersions(objectType, libraryID, keys); 303 var toDelete = []; 304 for (let key of keys) { 305 let obj = objectsClass.getByLibraryAndKey(libraryID, key); 306 307 // If object is in cache, overwrite with pristine data 308 if (cacheVersions[key]) { 309 let json = await this.getCacheObject(objectType, libraryID, key, cacheVersions[key]); 310 await Zotero.DB.executeTransaction(async function () { 311 await this._saveObjectFromJSON(obj, json, {}); 312 }.bind(this)); 313 } 314 // Otherwise, erase 315 else { 316 toDelete.push(objectsClass.getIDFromLibraryAndKey(libraryID, key)); 317 } 318 } 319 if (toDelete.length) { 320 await objectsClass.erase( 321 toDelete, 322 { 323 skipEditCheck: true, 324 skipDeleteLog: true 325 } 326 ); 327 } 328 329 // Deleted objects 330 keys = await Zotero.Sync.Data.Local.getDeleted(objectType, libraryID); 331 await this.removeObjectsFromDeleteLog(objectType, libraryID, keys); 332 }, 333 334 335 getSkippedLibraries: function () { 336 return this._getSkippedLibrariesByPrefix("L"); 337 }, 338 339 340 getSkippedGroups: function () { 341 return this._getSkippedLibrariesByPrefix("G"); 342 }, 343 344 345 _getSkippedLibrariesByPrefix: function (prefix) { 346 var pref = 'sync.librariesToSkip'; 347 try { 348 var librariesToSkip = JSON.parse(Zotero.Prefs.get(pref) || '[]'); 349 return librariesToSkip 350 .filter(id => id.startsWith(prefix)) 351 .map(id => parseInt(id.substr(1))); 352 } 353 catch (e) { 354 Zotero.logError(e); 355 Zotero.Prefs.clear(pref); 356 return []; 357 } 358 }, 359 360 361 /** 362 * @param {Zotero.Library[]} libraries 363 * @return {Zotero.Library[]} 364 */ 365 filterSkippedLibraries: function (libraries) { 366 var skippedLibraries = this.getSkippedLibraries(); 367 var skippedGroups = this.getSkippedGroups(); 368 369 return libraries.filter((library) => { 370 var libraryType = library.libraryType; 371 if (libraryType == 'group') { 372 return !skippedGroups.includes(library.groupID); 373 } 374 return !skippedLibraries.includes(library.libraryID); 375 }); 376 }, 377 378 379 /** 380 * @return {nsILoginInfo|false} 381 */ 382 _getAPIKeyLoginInfo: function () { 383 try { 384 var loginManager = Components.classes["@mozilla.org/login-manager;1"] 385 .getService(Components.interfaces.nsILoginManager); 386 var logins = loginManager.findLogins( 387 {}, 388 this._loginManagerHost, 389 null, 390 this._loginManagerRealm 391 ); 392 } 393 catch (e) { 394 Zotero.logError(e); 395 if (Zotero.isStandalone) { 396 var msg = Zotero.getString('sync.error.loginManagerCorrupted1', Zotero.appName) + "\n\n" 397 + Zotero.getString('sync.error.loginManagerCorrupted2', [Zotero.appName, Zotero.appName]); 398 } 399 else { 400 var msg = Zotero.getString('sync.error.loginManagerInaccessible') + "\n\n" 401 + Zotero.getString('sync.error.checkMasterPassword', Zotero.appName) + "\n\n" 402 + Zotero.getString('sync.error.corruptedLoginManager', Zotero.appName); 403 } 404 var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] 405 .getService(Components.interfaces.nsIPromptService); 406 ps.alert(null, Zotero.getString('general.error'), msg); 407 return false; 408 } 409 410 // Get API from returned array of nsILoginInfo objects 411 return logins.length ? logins[0] : false; 412 }, 413 414 415 _getAPIKeyFromLogin: Zotero.Promise.coroutine(function* () { 416 let username = Zotero.Prefs.get('sync.server.username'); 417 if (username) { 418 // Check for legacy password if no password set in current session 419 // and no API keys stored yet 420 let password = this.getLegacyPassword(username); 421 if (!password) { 422 return ""; 423 } 424 425 let json = yield Zotero.Sync.Runner.createAPIKeyFromCredentials(username, password); 426 this.removeLegacyLogins(); 427 return json.key; 428 } 429 return ""; 430 }), 431 432 433 getLegacyPassword: function (username) { 434 var loginManagerHost = 'chrome://zotero'; 435 var loginManagerRealm = 'Zotero Sync Server'; 436 437 Zotero.debug('Getting Zotero sync password'); 438 439 var loginManager = Components.classes["@mozilla.org/login-manager;1"] 440 .getService(Components.interfaces.nsILoginManager); 441 try { 442 var logins = loginManager.findLogins({}, loginManagerHost, null, loginManagerRealm); 443 } 444 catch (e) { 445 Zotero.logError(e); 446 return ''; 447 } 448 449 // Find user from returned array of nsILoginInfo objects 450 for (let i = 0; i < logins.length; i++) { 451 if (logins[i].username == username) { 452 return logins[i].password; 453 } 454 } 455 456 // Pre-4.0.28.5 format, broken for findLogins and removeLogin in Fx41, 457 var logins = loginManager.findLogins({}, loginManagerHost, "", null); 458 for (let i = 0; i < logins.length; i++) { 459 if (logins[i].username == username 460 && logins[i].formSubmitURL == "Zotero Sync Server") { 461 return logins[i].password; 462 } 463 } 464 return ''; 465 }, 466 467 468 removeLegacyLogins: function () { 469 var loginManagerHost = 'chrome://zotero'; 470 var loginManagerRealm = 'Zotero Sync Server'; 471 472 Zotero.debug('Removing legacy Zotero sync credentials (api key acquired)'); 473 474 var loginManager = Components.classes["@mozilla.org/login-manager;1"] 475 .getService(Components.interfaces.nsILoginManager); 476 try { 477 var logins = loginManager.findLogins({}, loginManagerHost, null, loginManagerRealm); 478 } 479 catch (e) { 480 Zotero.logError(e); 481 return ''; 482 } 483 484 // Remove all legacy users 485 for (let login of logins) { 486 loginManager.removeLogin(login); 487 } 488 // Remove the legacy pref 489 Zotero.Prefs.clear('sync.server.username'); 490 }, 491 492 493 getLastSyncTime: function () { 494 if (_lastSyncTime === null) { 495 throw new Error("Last sync time not yet loaded"); 496 } 497 return _lastSyncTime; 498 }, 499 500 501 /** 502 * @return {Promise} 503 */ 504 updateLastSyncTime: function () { 505 _lastSyncTime = new Date(); 506 return Zotero.DB.queryAsync( 507 "REPLACE INTO version (schema, version) VALUES ('lastsync', ?)", 508 Math.round(_lastSyncTime.getTime() / 1000) 509 ); 510 }, 511 512 513 _loadLastSyncTime: Zotero.Promise.coroutine(function* () { 514 var sql = "SELECT version FROM version WHERE schema='lastsync'"; 515 var lastsync = yield Zotero.DB.valueQueryAsync(sql); 516 _lastSyncTime = (lastsync ? new Date(lastsync * 1000) : false); 517 }), 518 519 520 /** 521 * @param {String} objectType 522 * @param {Integer} libraryID 523 * @return {Promise<String[]>} - A promise for an array of object keys 524 */ 525 getSynced: function (objectType, libraryID) { 526 var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 527 var sql = "SELECT key FROM " + objectsClass.table + " WHERE libraryID=? AND synced=1"; 528 return Zotero.DB.columnQueryAsync(sql, [libraryID]); 529 }, 530 531 532 /** 533 * @param {String} objectType 534 * @param {Integer} libraryID 535 * @return {Promise<Integer[]>} - A promise for an array of object ids 536 */ 537 getUnsynced: Zotero.Promise.coroutine(function* (objectType, libraryID) { 538 var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 539 var sql = "SELECT O." + objectsClass.idColumn + " FROM " + objectsClass.table + " O"; 540 if (objectType == 'item') { 541 sql += " LEFT JOIN itemAttachments IA USING (itemID) " 542 + "LEFT JOIN itemNotes INo ON (O.itemID=INo.itemID) "; 543 } 544 sql += " WHERE libraryID=? AND synced=0"; 545 // Sort child items last 546 if (objectType == 'item') { 547 sql += " ORDER BY COALESCE(IA.parentItemID, INo.parentItemID)"; 548 } 549 550 var ids = yield Zotero.DB.columnQueryAsync(sql, [libraryID]); 551 552 // Sort descendent collections last 553 if (objectType == 'collection') { 554 ids = Zotero.Collections.sortByLevel(ids); 555 } 556 557 return ids; 558 }), 559 560 561 // 562 // Cache management 563 // 564 /** 565 * Gets the latest version for each object of a given type in the given library 566 * 567 * @return {Promise<Object>} - A promise for an object with object keys as keys and versions 568 * as properties 569 */ 570 getLatestCacheObjectVersions: Zotero.Promise.coroutine(function* (objectType, libraryID, keys=[]) { 571 var versions = {}; 572 573 yield Zotero.Utilities.Internal.forEachChunkAsync( 574 keys, 575 Zotero.DB.MAX_BOUND_PARAMETERS - 2, 576 Zotero.Promise.coroutine(function* (chunk) { 577 // The MAX(version) ensures we get the data from the most recent version of the object, 578 // thanks to SQLite 3.7.11 (http://www.sqlite.org/releaselog/3_7_11.html) 579 var sql = "SELECT key, MAX(version) AS version FROM syncCache " 580 + "WHERE libraryID=? AND " 581 + "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?) "; 582 var params = [libraryID, objectType] 583 if (chunk.length) { 584 sql += "AND key IN (" + chunk.map(key => '?').join(', ') + ") "; 585 params = params.concat(chunk); 586 } 587 sql += "GROUP BY libraryID, key"; 588 var rows = yield Zotero.DB.queryAsync(sql, params); 589 590 for (let i = 0; i < rows.length; i++) { 591 let row = rows[i]; 592 versions[row.key] = row.version; 593 } 594 }) 595 ); 596 597 return versions; 598 }), 599 600 601 /** 602 * @return {Promise<Integer[]>} - A promise for an array of object versions 603 */ 604 getCacheObjectVersions: function (objectType, libraryID, key) { 605 var sql = "SELECT version FROM syncCache WHERE libraryID=? AND key=? " 606 + "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM " 607 + "syncObjectTypes WHERE name=?) ORDER BY version"; 608 return Zotero.DB.columnQueryAsync(sql, [libraryID, key, objectType]); 609 }, 610 611 612 /** 613 * @return {Promise<Number>} - A promise for an object version 614 */ 615 getLatestCacheObjectVersion: function (objectType, libraryID, key) { 616 var sql = "SELECT version FROM syncCache WHERE libraryID=? AND key=? " 617 + "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM " 618 + "syncObjectTypes WHERE name=?) ORDER BY VERSION DESC LIMIT 1"; 619 return Zotero.DB.valueQueryAsync(sql, [libraryID, key, objectType]); 620 }, 621 622 623 /** 624 * @return {Promise} 625 */ 626 getCacheObject: Zotero.Promise.coroutine(function* (objectType, libraryID, key, version) { 627 var sql = "SELECT data FROM syncCache WHERE libraryID=? AND key=? AND version=? " 628 + "AND syncObjectTypeID IN (SELECT syncObjectTypeID FROM " 629 + "syncObjectTypes WHERE name=?)"; 630 var data = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, version, objectType]); 631 if (data) { 632 return JSON.parse(data); 633 } 634 return false; 635 }), 636 637 638 getCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, keyVersionPairs) { 639 if (!keyVersionPairs.length) return []; 640 var rows = []; 641 yield Zotero.Utilities.Internal.forEachChunkAsync( 642 keyVersionPairs, 643 240, // SQLITE_MAX_COMPOUND_SELECT defaults to 500 644 async function (chunk) { 645 var sql = "SELECT data FROM syncCache SC JOIN (SELECT " 646 + chunk.map((pair) => { 647 Zotero.DataObjectUtilities.checkKey(pair[0]); 648 return "'" + pair[0] + "' AS key, " + parseInt(pair[1]) + " AS version"; 649 }).join(" UNION SELECT ") 650 + ") AS pairs ON (pairs.key=SC.key AND pairs.version=SC.version) " 651 + "WHERE libraryID=? AND " 652 + "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)"; 653 rows.push(...await Zotero.DB.columnQueryAsync(sql, [libraryID, objectType])); 654 } 655 ) 656 return rows.map(row => JSON.parse(row)); 657 }), 658 659 660 saveCacheObject: Zotero.Promise.coroutine(function* (objectType, libraryID, json) { 661 json = this._checkCacheJSON(json); 662 663 Zotero.debug("Saving to sync cache:"); 664 Zotero.debug(json); 665 666 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 667 var sql = "INSERT OR REPLACE INTO syncCache " 668 + "(libraryID, key, syncObjectTypeID, version, data) VALUES (?, ?, ?, ?, ?)"; 669 var params = [libraryID, json.key, syncObjectTypeID, json.version, JSON.stringify(json)]; 670 return Zotero.DB.queryAsync(sql, params); 671 }), 672 673 674 saveCacheObjects: Zotero.Promise.coroutine(function* (objectType, libraryID, jsonArray) { 675 if (!Array.isArray(jsonArray)) { 676 throw new Error("'json' must be an array"); 677 } 678 679 if (!jsonArray.length) { 680 Zotero.debug("No " + Zotero.DataObjectUtilities.getObjectTypePlural(objectType) 681 + " to save to sync cache"); 682 return; 683 } 684 685 jsonArray = jsonArray.map(json => this._checkCacheJSON(json)); 686 687 Zotero.debug("Saving to sync cache:"); 688 Zotero.debug(jsonArray); 689 690 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 691 var sql = "INSERT OR REPLACE INTO syncCache " 692 + "(libraryID, key, syncObjectTypeID, version, data) VALUES "; 693 var chunkSize = Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 5); 694 return Zotero.Utilities.Internal.forEachChunkAsync( 695 jsonArray, 696 chunkSize, 697 Zotero.Promise.coroutine(function* (chunk) { 698 var params = []; 699 for (let i = 0; i < chunk.length; i++) { 700 let o = chunk[i]; 701 params.push(libraryID, o.key, syncObjectTypeID, o.version, JSON.stringify(o)); 702 } 703 return Zotero.DB.queryAsync( 704 sql + chunk.map(() => "(?, ?, ?, ?, ?)").join(", "), params 705 ); 706 }) 707 ); 708 }), 709 710 711 /** 712 * Process downloaded JSON and update local objects 713 * 714 * @return {Promise<Object[]>} - Promise for an array of objects with the following properties: 715 * {String} key 716 * {Boolean} processed 717 * {Object} [error] 718 * {Boolean} [retry] 719 * {Boolean} [restored=false] - Locally deleted object was added back 720 * {Boolean} [conflict=false] 721 * {Object} [left] - Local JSON data for conflict (or .deleted and .dateDeleted) 722 * {Object} [right] - Remote JSON data for conflict 723 * {Object[]} [changes] - An array of operations to apply locally to resolve conflicts, 724 * as returned by _reconcileChanges() 725 * {Object[]} [conflicts] - An array of conflicting fields that can't be resolved automatically 726 */ 727 processObjectsFromJSON: Zotero.Promise.coroutine(function* (objectType, libraryID, json, options = {}) { 728 var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 729 var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 730 var ObjectType = Zotero.Utilities.capitalize(objectType); 731 var libraryName = Zotero.Libraries.get(libraryID).name; 732 733 var knownErrors = [ 734 'ZoteroUnknownTypeError', 735 'ZoteroUnknownFieldError', 736 'ZoteroMissingObjectError' 737 ]; 738 739 Zotero.debug("Processing " + json.length + " downloaded " 740 + (json.length == 1 ? objectType : objectTypePlural) 741 + " for " + libraryName); 742 743 var results = []; 744 745 if (!json.length) { 746 return results; 747 } 748 749 json = json.map(o => this._checkCacheJSON(o)); 750 751 if (options.setStatus) { 752 options.setStatus("Downloading " + objectTypePlural + " in " + libraryName); // TODO: localize 753 } 754 755 // Sort parent objects first, to avoid retries due to unmet dependencies 756 if (objectType == 'item' || objectType == 'collection') { 757 let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1); 758 json.sort(function (a, b) { 759 if (a[parentProp] && !b[parentProp]) return 1; 760 if (b[parentProp] && !a[parentProp]) return -1; 761 return 0; 762 }); 763 } 764 765 var batchSize = options.getNotifierBatchSize ? options.getNotifierBatchSize() : json.length; 766 var notifierQueues = []; 767 768 try { 769 for (let i = 0; i < json.length; i++) { 770 // Batch notifier updates 771 if (notifierQueues.length == batchSize) { 772 yield Zotero.Notifier.commit(notifierQueues); 773 notifierQueues = []; 774 // Get the current batch size, which might have increased 775 if (options.getNotifierBatchSize) { 776 batchSize = options.getNotifierBatchSize() 777 } 778 } 779 let notifierQueue = new Zotero.Notifier.Queue; 780 781 let jsonObject = json[i]; 782 let jsonData = jsonObject.data; 783 let objectKey = jsonObject.key; 784 785 let saveOptions = {}; 786 Object.assign(saveOptions, options); 787 saveOptions.isNewObject = false; 788 saveOptions.skipCache = false; 789 saveOptions.storageDetailsChanged = false; 790 saveOptions.notifierQueue = notifierQueue; 791 792 Zotero.debug(`Processing ${objectType} ${libraryID}/${objectKey}`); 793 Zotero.debug(jsonObject); 794 795 // Skip objects with unmet dependencies 796 if (objectType == 'item' || objectType == 'collection') { 797 // Missing parent collection or item 798 let parentProp = 'parent' + objectType[0].toUpperCase() + objectType.substr(1); 799 let parentKey = jsonData[parentProp]; 800 if (parentKey) { 801 let parentObj = yield objectsClass.getByLibraryAndKeyAsync( 802 libraryID, parentKey, { noCache: true } 803 ); 804 if (!parentObj) { 805 let error = new Error("Parent of " + objectType + " " 806 + libraryID + "/" + jsonData.key + " not found -- skipping"); 807 error.name = "ZoteroMissingObjectError"; 808 Zotero.debug(error.message); 809 results.push({ 810 key: objectKey, 811 processed: false, 812 error, 813 retry: true 814 }); 815 continue; 816 } 817 } 818 819 // Missing collection -- this could happen if the collection was deleted 820 // locally and an item in it was modified remotely 821 if (objectType == 'item' && jsonData.collections) { 822 let error; 823 for (let key of jsonData.collections) { 824 let collection = Zotero.Collections.getByLibraryAndKey(libraryID, key); 825 if (!collection) { 826 error = new Error(`Collection ${libraryID}/${key} not found ` 827 + `-- skipping item`); 828 error.name = "ZoteroMissingObjectError"; 829 Zotero.debug(error.message); 830 results.push({ 831 key: objectKey, 832 processed: false, 833 error, 834 retry: false 835 }); 836 837 // If the collection is in the delete log, the deletion will upload 838 // after downloads are done. Otherwise, we somehow missed 839 // downloading it and should add it to the queue to try again. 840 if (!(yield this.getDateDeleted('collection', libraryID, key))) { 841 yield this.addObjectsToSyncQueue('collection', libraryID, [key]); 842 } 843 break; 844 } 845 } 846 if (error) { 847 continue; 848 } 849 } 850 } 851 852 // Errors have to be thrown in order to roll back the transaction, so catch those here 853 // and continue 854 try { 855 yield Zotero.DB.executeTransaction(function* () { 856 let obj = yield objectsClass.getByLibraryAndKeyAsync( 857 libraryID, objectKey, { noCache: true } 858 ); 859 let restored = false; 860 if (obj) { 861 Zotero.debug("Matching local " + objectType + " exists", 4); 862 863 let jsonDataLocal = obj.toJSON(); 864 865 // For items, check if mtime or file hash changed in metadata, 866 // which would indicate that a remote storage sync took place and 867 // a download is needed 868 if (objectType == 'item' && obj.isImportedAttachment()) { 869 if (jsonDataLocal.mtime != jsonData.mtime 870 || jsonDataLocal.md5 != jsonData.md5) { 871 saveOptions.storageDetailsChanged = true; 872 } 873 } 874 875 // Local object has been modified since last sync 876 if (!obj.synced) { 877 Zotero.debug("Local " + objectType + " " + obj.libraryKey 878 + " has been modified since last sync", 4); 879 880 let cachedJSON = yield this.getCacheObject( 881 objectType, obj.libraryID, obj.key, obj.version 882 ); 883 let result = this._reconcileChanges( 884 objectType, 885 cachedJSON.data, 886 jsonDataLocal, 887 jsonData, 888 ['mtime', 'md5', 'dateAdded', 'dateModified'] 889 ); 890 891 // If no changes, just update local version number and mark as synced 892 if (!result.changes.length && !result.conflicts.length) { 893 Zotero.debug("No remote changes to apply to local " 894 + objectType + " " + obj.libraryKey); 895 saveOptions.skipData = true; 896 // If local object is different but we ignored the changes 897 // (e.g., ISBN hyphenation), save as unsynced. Since we're skipping 898 // data, the local version won't be overwritten. 899 if (result.localChanged) { 900 saveOptions.saveAsUnsynced = true; 901 } 902 let saveResults = yield this._saveObjectFromJSON( 903 obj, 904 jsonObject, 905 saveOptions 906 ); 907 results.push(saveResults); 908 if (!saveResults.processed) { 909 throw saveResults.error; 910 } 911 return; 912 } 913 914 if (result.conflicts.length) { 915 if (objectType != 'item') { 916 throw new Error(`Unexpected conflict on ${objectType} object`); 917 } 918 Zotero.debug("Conflict!", 2); 919 Zotero.debug(jsonDataLocal); 920 Zotero.debug(jsonData); 921 Zotero.debug(result); 922 results.push({ 923 libraryID, 924 key: objectKey, 925 processed: false, 926 conflict: true, 927 left: jsonDataLocal, 928 right: jsonData, 929 changes: result.changes, 930 conflicts: result.conflicts 931 }); 932 return; 933 } 934 935 // If no conflicts, apply remote changes automatically 936 Zotero.debug(`Applying remote changes to ${objectType} ` 937 + obj.libraryKey); 938 Zotero.debug(result.changes); 939 Zotero.DataObjectUtilities.applyChanges( 940 jsonDataLocal, result.changes 941 ); 942 // Transfer properties that aren't in the changeset 943 ['version', 'dateAdded', 'dateModified'].forEach(x => { 944 if (jsonDataLocal[x] !== jsonData[x]) { 945 Zotero.debug(`Applying remote '${x}' value`); 946 } 947 jsonDataLocal[x] = jsonData[x]; 948 }) 949 jsonObject.data = jsonDataLocal; 950 } 951 } 952 // Object doesn't exist locally 953 else { 954 Zotero.debug(ObjectType + " doesn't exist locally"); 955 956 saveOptions.isNewObject = true; 957 958 // Check if object has been deleted locally 959 let dateDeleted = yield this.getDateDeleted( 960 objectType, libraryID, objectKey 961 ); 962 if (dateDeleted) { 963 Zotero.debug(ObjectType + " was deleted locally"); 964 965 switch (objectType) { 966 case 'item': 967 if (jsonData.deleted) { 968 Zotero.debug("Remote item is in trash -- allowing local deletion to propagate"); 969 results.push({ 970 libraryID, 971 key: objectKey, 972 processed: true 973 }); 974 return; 975 } 976 977 results.push({ 978 libraryID, 979 key: objectKey, 980 processed: false, 981 conflict: true, 982 left: { 983 deleted: true, 984 dateDeleted: Zotero.Date.dateToSQL(dateDeleted, true) 985 }, 986 right: jsonData 987 }); 988 return; 989 990 // Auto-restore some locally deleted objects that have changed remotely 991 case 'collection': 992 case 'search': 993 Zotero.debug(`${ObjectType} ${objectKey} was modified remotely ` 994 + '-- restoring'); 995 yield this.removeObjectsFromDeleteLog( 996 objectType, 997 libraryID, 998 [objectKey] 999 ); 1000 restored = true; 1001 break; 1002 1003 default: 1004 throw new Error("Unknown object type '" + objectType + "'"); 1005 } 1006 } 1007 1008 // Create new object 1009 obj = new Zotero[ObjectType]; 1010 obj.libraryID = libraryID; 1011 obj.key = objectKey; 1012 yield obj.loadPrimaryData(); 1013 1014 // Don't cache new items immediately, which skips reloading after save 1015 saveOptions.skipCache = true; 1016 } 1017 1018 let saveResults = yield this._saveObjectFromJSON(obj, jsonObject, saveOptions); 1019 if (restored) { 1020 saveResults.restored = true; 1021 } 1022 results.push(saveResults); 1023 if (!saveResults.processed) { 1024 throw saveResults.error; 1025 } 1026 }.bind(this)); 1027 1028 if (notifierQueue.size) { 1029 notifierQueues.push(notifierQueue); 1030 } 1031 } 1032 catch (e) { 1033 // Display nicer debug line for known errors 1034 if (knownErrors.indexOf(e.name) != -1) { 1035 let desc = e.name 1036 .replace(/^Zotero/, "") 1037 // Convert "MissingObjectError" to "missing object error" 1038 .split(/([a-z]+)/).join(' ').trim() 1039 .replace(/([A-Z]) ([a-z]+)/g, "$1$2").toLowerCase(); 1040 let msg = Zotero.Utilities.capitalize(desc) + " for " 1041 + `${objectType} ${jsonObject.key} in ${Zotero.Libraries.get(libraryID).name}`; 1042 Zotero.debug(msg, 2); 1043 Zotero.debug(e, 2); 1044 Components.utils.reportError(msg + ": " + e.message); 1045 } 1046 else { 1047 Zotero.logError(e); 1048 } 1049 1050 if (options.onError) { 1051 options.onError(e); 1052 } 1053 1054 if (Zotero.DB.closed) { 1055 e.fatal = true; 1056 } 1057 if (options.stopOnError || e.fatal) { 1058 throw e; 1059 } 1060 } 1061 finally { 1062 if (options.onObjectProcessed) { 1063 options.onObjectProcessed(); 1064 } 1065 } 1066 1067 yield Zotero.Promise.delay(10); 1068 } 1069 } 1070 finally { 1071 if (notifierQueues.length) { 1072 yield Zotero.Notifier.commit(notifierQueues); 1073 } 1074 } 1075 1076 let processed = 0; 1077 let skipped = 0; 1078 results.forEach(x => x.processed ? processed++ : skipped++); 1079 1080 Zotero.debug(`Processed ${processed} ` 1081 + (processed == 1 ? objectType : objectTypePlural) 1082 + (skipped ? ` and skipped ${skipped}` : "") 1083 + " in " + libraryName); 1084 1085 return results; 1086 }), 1087 1088 1089 _checkCacheJSON: function (json) { 1090 if (json.key === undefined) { 1091 Zotero.debug(json, 1); 1092 throw new Error("Missing 'key' property in JSON"); 1093 } 1094 if (json.version === undefined) { 1095 Zotero.debug(json, 1); 1096 throw new Error("Missing 'version' property in JSON"); 1097 } 1098 if (json.version === 0) { 1099 Zotero.debug(json, 1); 1100 // TODO: Fix tests so this doesn't happen 1101 Zotero.warn("'version' cannot be 0 in cache JSON"); 1102 //throw new Error("'version' cannot be 0 in cache JSON"); 1103 } 1104 // If direct data object passed, wrap in fake response object 1105 return json.data === undefined ? { 1106 key: json.key, 1107 version: json.version, 1108 data: json 1109 } : json; 1110 }, 1111 1112 1113 /** 1114 * Check whether an attachment's file mod time matches the given mod time, and mark the file 1115 * for download if not (or if this is a new attachment) 1116 */ 1117 _checkAttachmentForDownload: Zotero.Promise.coroutine(function* (item, mtime, isNewObject) { 1118 var markToDownload = false; 1119 if (!isNewObject) { 1120 // Convert previously used Unix timestamps to ms-based timestamps 1121 if (mtime < 10000000000) { 1122 Zotero.debug("Converting Unix timestamp '" + mtime + "' to ms"); 1123 mtime = mtime * 1000; 1124 } 1125 var fmtime = null; 1126 try { 1127 fmtime = yield item.attachmentModificationTime; 1128 } 1129 catch (e) { 1130 // This will probably fail later too, but ignore it for now 1131 Zotero.logError(e); 1132 } 1133 if (fmtime) { 1134 let state = Zotero.Sync.Storage.Local.checkFileModTime(item, fmtime, mtime); 1135 if (state !== false) { 1136 markToDownload = true; 1137 } 1138 } 1139 else { 1140 markToDownload = true; 1141 } 1142 } 1143 else { 1144 markToDownload = true; 1145 } 1146 if (markToDownload) { 1147 item.attachmentSyncState = "to_download"; 1148 } 1149 }), 1150 1151 1152 /** 1153 * Delete one or more versions of an object from the sync cache 1154 * 1155 * @param {String} objectType 1156 * @param {Integer} libraryID 1157 * @param {String} key 1158 * @param {Integer} [minVersion] 1159 * @param {Integer} [maxVersion] 1160 */ 1161 deleteCacheObjectVersions: function (objectType, libraryID, key, minVersion, maxVersion) { 1162 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 1163 var sql = "DELETE FROM syncCache WHERE libraryID=? AND key=? AND syncObjectTypeID=?"; 1164 var params = [libraryID, key, syncObjectTypeID]; 1165 if (minVersion && minVersion == maxVersion) { 1166 sql += " AND version=?"; 1167 params.push(minVersion); 1168 } 1169 else { 1170 if (minVersion) { 1171 sql += " AND version>=?"; 1172 params.push(minVersion); 1173 } 1174 if (maxVersion || maxVersion === 0) { 1175 sql += " AND version<=?"; 1176 params.push(maxVersion); 1177 } 1178 } 1179 return Zotero.DB.queryAsync(sql, params); 1180 }, 1181 1182 1183 /** 1184 * Delete entries from sync cache that don't exist or are less than the current object version 1185 */ 1186 purgeCache: Zotero.Promise.coroutine(function* (objectType, libraryID) { 1187 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 1188 var table = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType).table; 1189 var sql = "DELETE FROM syncCache WHERE ROWID IN (" 1190 + "SELECT SC.ROWID FROM syncCache SC " 1191 + `LEFT JOIN ${table} O USING (libraryID, key, version) ` 1192 + "WHERE syncObjectTypeID=? AND SC.libraryID=? AND " 1193 + "(O.libraryID IS NULL OR SC.version < O.version))"; 1194 yield Zotero.DB.queryAsync(sql, [syncObjectTypeID, libraryID]); 1195 }), 1196 1197 1198 clearCacheForLibrary: async function (libraryID) { 1199 await Zotero.DB.queryAsync("DELETE FROM syncCache WHERE libraryID=?", libraryID); 1200 }, 1201 1202 1203 processConflicts: Zotero.Promise.coroutine(function* (objectType, libraryID, conflicts, options = {}) { 1204 if (!conflicts.length) return []; 1205 1206 var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1207 var ObjectType = Zotero.Utilities.capitalize(objectType); 1208 1209 // Sort conflicts by local Date Modified/Deleted 1210 conflicts.sort(function (a, b) { 1211 var d1 = a.left.dateDeleted || a.left.dateModified; 1212 var d2 = b.left.dateDeleted || b.left.dateModified; 1213 if (d1 > d2) { 1214 return 1 1215 } 1216 if (d1 < d2) { 1217 return -1; 1218 } 1219 return 0; 1220 }) 1221 1222 var results = []; 1223 1224 var mergeData = this.showConflictResolutionWindow(conflicts); 1225 if (!mergeData) { 1226 Zotero.debug("Conflict resolution was cancelled", 2); 1227 for (let conflict of conflicts) { 1228 results.push({ 1229 // Use key from either, in case one side is deleted 1230 key: conflict.left.key || conflict.right.key, 1231 processed: false, 1232 retry: false 1233 }); 1234 } 1235 return results; 1236 } 1237 1238 Zotero.debug("Processing resolved conflicts"); 1239 1240 let batchSize = mergeData.length; 1241 let notifierQueues = []; 1242 try { 1243 for (let i = 0; i < mergeData.length; i++) { 1244 // Batch notifier updates, despite multiple transactions 1245 if (notifierQueues.length == batchSize) { 1246 yield Zotero.Notifier.commit(notifierQueues); 1247 notifierQueues = []; 1248 } 1249 let notifierQueue = new Zotero.Notifier.Queue; 1250 1251 let json = mergeData[i].data; 1252 1253 let saveOptions = {}; 1254 Object.assign(saveOptions, options); 1255 // If choosing local object, save as unsynced with remote version (or 0 if remote is 1256 // deleted) and remote object in cache, to simulate a save and edit 1257 if (mergeData[i].selected == 'left') { 1258 json.version = conflicts[i].right.version || 0; 1259 saveOptions.saveAsUnsynced = true; 1260 if (conflicts[i].right.version) { 1261 saveOptions.cacheObject = conflicts[i].right; 1262 } 1263 } 1264 saveOptions.notifierQueue = notifierQueue; 1265 1266 // Errors have to be thrown in order to roll back the transaction, so catch 1267 // those here and continue 1268 try { 1269 yield Zotero.DB.executeTransaction(function* () { 1270 let obj = yield objectsClass.getByLibraryAndKeyAsync( 1271 libraryID, json.key, { noCache: true } 1272 ); 1273 // Update object with merge data 1274 if (obj) { 1275 // Delete local object 1276 if (json.deleted) { 1277 try { 1278 yield obj.erase({ 1279 notifierQueue 1280 }); 1281 } 1282 catch (e) { 1283 results.push({ 1284 key: json.key, 1285 processed: false, 1286 error: e, 1287 retry: false 1288 }); 1289 throw e; 1290 } 1291 results.push({ 1292 key: json.key, 1293 processed: true 1294 }); 1295 return; 1296 } 1297 1298 // Save merged changes below 1299 } 1300 // If no local object and merge wanted a delete, we're good 1301 else if (json.deleted) { 1302 results.push({ 1303 key: json.key, 1304 processed: true 1305 }); 1306 return; 1307 } 1308 // Recreate locally deleted object 1309 else { 1310 obj = new Zotero[ObjectType]; 1311 obj.libraryID = libraryID; 1312 obj.key = json.key; 1313 yield obj.loadPrimaryData(); 1314 1315 // Don't cache new items immediately, 1316 // which skips reloading after save 1317 saveOptions.skipCache = true; 1318 } 1319 1320 let saveResults = yield this._saveObjectFromJSON(obj, json, saveOptions); 1321 results.push(saveResults); 1322 if (!saveResults.processed) { 1323 throw saveResults.error; 1324 } 1325 }.bind(this)); 1326 1327 if (notifierQueue.size) { 1328 notifierQueues.push(notifierQueue); 1329 } 1330 } 1331 catch (e) { 1332 Zotero.logError(e); 1333 1334 if (options.onError) { 1335 options.onError(e); 1336 } 1337 1338 if (options.stopOnError) { 1339 throw e; 1340 } 1341 } 1342 } 1343 } 1344 finally { 1345 if (notifierQueues.length) { 1346 yield Zotero.Notifier.commit(notifierQueues); 1347 } 1348 } 1349 1350 return results; 1351 }), 1352 1353 1354 showConflictResolutionWindow: function (conflicts) { 1355 Zotero.debug("Showing conflict resolution window"); 1356 Zotero.debug(conflicts); 1357 1358 var io = { 1359 dataIn: { 1360 captions: [ 1361 Zotero.getString('sync.conflict.localItem'), 1362 Zotero.getString('sync.conflict.remoteItem'), 1363 Zotero.getString('sync.conflict.mergedItem') 1364 ], 1365 conflicts 1366 } 1367 }; 1368 var url = 'chrome://zotero/content/merge.xul'; 1369 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1370 .getService(Components.interfaces.nsIWindowMediator); 1371 var lastWin = wm.getMostRecentWindow("navigator:browser"); 1372 if (lastWin) { 1373 lastWin.openDialog(url, '', 'chrome,modal,centerscreen', io); 1374 } 1375 else { 1376 // When using nsIWindowWatcher, the object has to be wrapped here 1377 // https://developer.mozilla.org/en-US/docs/Working_with_windows_in_chrome_code#Example_5_Using_nsIWindowWatcher_for_passing_an_arbritrary_JavaScript_object 1378 io.wrappedJSObject = io; 1379 let ww = Components.classes["@mozilla.org/embedcomp/window-watcher;1"] 1380 .getService(Components.interfaces.nsIWindowWatcher); 1381 ww.openWindow(null, url, '', 'chrome,modal,centerscreen,dialog', io); 1382 } 1383 if (io.error) { 1384 throw io.error; 1385 } 1386 return io.dataOut; 1387 }, 1388 1389 1390 // 1391 // Classic sync 1392 // 1393 getLastClassicSyncTime: function () { 1394 if (_lastClassicSyncTime === null) { 1395 throw new Error("Last classic sync time not yet loaded"); 1396 } 1397 return _lastClassicSyncTime; 1398 }, 1399 1400 _loadLastClassicSyncTime: Zotero.Promise.coroutine(function* () { 1401 var sql = "SELECT version FROM version WHERE schema='lastlocalsync'"; 1402 var lastsync = yield Zotero.DB.valueQueryAsync(sql); 1403 _lastClassicSyncTime = (lastsync ? new Date(lastsync * 1000) : false); 1404 }), 1405 1406 _saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) { 1407 var results = {}; 1408 try { 1409 results.key = json.key; 1410 json = this._checkCacheJSON(json); 1411 1412 if (!options.skipData) { 1413 obj.fromJSON(json.data); 1414 } 1415 if (obj.objectType == 'item' && obj.isImportedAttachment()) { 1416 yield this._checkAttachmentForDownload(obj, json.data.mtime, options.isNewObject); 1417 } 1418 obj.version = json.data.version; 1419 if (!options.saveAsUnsynced) { 1420 obj.synced = true; 1421 } 1422 yield obj.save({ 1423 skipEditCheck: true, 1424 skipDateModifiedUpdate: true, 1425 skipSelect: true, 1426 skipCache: options.skipCache || false, 1427 notifierQueue: options.notifierQueue, 1428 // Errors are logged elsewhere, so skip in DataObject.save() 1429 errorHandler: function (e) { 1430 return; 1431 } 1432 }); 1433 let cacheJSON = options.cacheObject ? options.cacheObject : json.data; 1434 yield this.saveCacheObject(obj.objectType, obj.libraryID, cacheJSON); 1435 // Delete older versions of the object in the cache 1436 yield this.deleteCacheObjectVersions( 1437 obj.objectType, 1438 obj.libraryID, 1439 json.key, 1440 null, 1441 cacheJSON.version - 1 1442 ); 1443 results.processed = true; 1444 1445 // Delete from sync queue 1446 yield this._removeObjectFromSyncQueue(obj.objectType, obj.libraryID, json.key); 1447 1448 // Mark updated attachments for download 1449 if (obj.objectType == 'item' && obj.isImportedAttachment()) { 1450 // If storage changes were made (attachment mtime or hash), mark 1451 // library as requiring download 1452 if (options.isNewObject || options.storageDetailsChanged) { 1453 Zotero.Libraries.get(obj.libraryID).storageDownloadNeeded = true; 1454 } 1455 } 1456 } 1457 catch (e) { 1458 // For now, allow sync to proceed after all errors 1459 results.processed = false; 1460 results.error = e; 1461 results.retry = false; 1462 } 1463 return results; 1464 }), 1465 1466 1467 /** 1468 * Calculate a changeset to apply locally to resolve an object conflict, plus a list of 1469 * conflicts where not possible 1470 */ 1471 _reconcileChanges: function (objectType, originalJSON, currentJSON, newJSON, ignoreFields) { 1472 if (!originalJSON) { 1473 return this._reconcileChangesWithoutCache(objectType, currentJSON, newJSON, ignoreFields); 1474 } 1475 1476 var changeset1 = Zotero.DataObjectUtilities.diff(originalJSON, currentJSON, ignoreFields); 1477 var changeset2 = Zotero.DataObjectUtilities.diff(originalJSON, newJSON, ignoreFields); 1478 1479 Zotero.debug("CHANGESET1"); 1480 Zotero.debug(changeset1); 1481 Zotero.debug("CHANGESET2"); 1482 Zotero.debug(changeset2); 1483 1484 var conflicts = []; 1485 1486 for (let i = 0; i < changeset1.length; i++) { 1487 for (let j = 0; j < changeset2.length; j++) { 1488 let c1 = changeset1[i]; 1489 let c2 = changeset2[j]; 1490 if (c1.field != c2.field) { 1491 continue; 1492 } 1493 1494 // Disregard member additions/deletions for different values 1495 if (c1.op.startsWith('member-') && c2.op.startsWith('member-')) { 1496 switch (c1.field) { 1497 case 'collections': 1498 if (c1.value !== c2.value) { 1499 continue; 1500 } 1501 break; 1502 1503 case 'creators': 1504 if (!Zotero.Creators.equals(c1.value, c2.value)) { 1505 continue; 1506 } 1507 break; 1508 1509 case 'tags': 1510 if (!Zotero.Tags.equals(c1.value, c2.value)) { 1511 // If just a type difference, treat as modify with type 0 if 1512 // not type 0 in changeset1 1513 if (c1.op == 'member-add' && c2.op == 'member-add' 1514 && c1.value.tag === c2.value.tag) { 1515 changeset1.splice(i--, 1); 1516 changeset2.splice(j--, 1); 1517 if (c1.value.type > 0) { 1518 changeset2.push({ 1519 field: "tags", 1520 op: "member-remove", 1521 value: c1.value 1522 }); 1523 changeset2.push({ 1524 field: "tags", 1525 op: "member-add", 1526 value: c2.value 1527 }); 1528 } 1529 } 1530 continue; 1531 } 1532 break; 1533 } 1534 } 1535 1536 // Disregard member additions/deletions for different properties and values 1537 if (c1.op.startsWith('property-member-') && c2.op.startsWith('property-member-')) { 1538 if (c1.value.key !== c2.value.key || c1.value.value !== c2.value.value) { 1539 continue; 1540 } 1541 } 1542 1543 // Changes are equal or in conflict 1544 1545 // Removed on both sides 1546 if (c1.op == 'delete' && c2.op == 'delete') { 1547 changeset2.splice(j--, 1); 1548 continue; 1549 } 1550 1551 // Added or removed members on both sides 1552 if ((c1.op == 'member-add' && c2.op == 'member-add') 1553 || (c1.op == 'member-remove' && c2.op == 'member-remove') 1554 || (c1.op == 'property-member-add' && c2.op == 'property-member-add') 1555 || (c1.op == 'property-member-remove' && c2.op == 'property-member-remove')) { 1556 changeset2.splice(j--, 1); 1557 continue; 1558 } 1559 1560 // If both sides have values, see if they're the same, and if so remove the 1561 // second one 1562 if (c1.op != 'delete' && c2.op != 'delete' && c1.value === c2.value) { 1563 changeset2.splice(j--, 1); 1564 continue; 1565 } 1566 1567 // Automatically apply remote changes if both items are in trash and for non-items, 1568 // even if in conflict 1569 if ((objectType == 'item' && currentJSON.deleted && newJSON.deleted) 1570 || objectType != 'item') { 1571 continue; 1572 } 1573 1574 // Conflict 1575 changeset2.splice(j--, 1); 1576 conflicts.push([c1, c2]); 1577 } 1578 } 1579 1580 return { 1581 changes: changeset2, 1582 conflicts 1583 }; 1584 }, 1585 1586 1587 /** 1588 * Calculate a changeset to apply locally to resolve an object conflict in absence of a 1589 * cached version. Members and property members (e.g., collections, tags, relations) 1590 * are combined, so any removals will be automatically undone. Field changes result in 1591 * conflicts. 1592 */ 1593 _reconcileChangesWithoutCache: function (objectType, currentJSON, newJSON, ignoreFields) { 1594 var changeset = Zotero.DataObjectUtilities.diff(currentJSON, newJSON, ignoreFields); 1595 1596 var changes = []; 1597 var conflicts = []; 1598 1599 for (let i = 0; i < changeset.length; i++) { 1600 let c2 = changeset[i]; 1601 1602 // Member changes are additive only, so ignore removals 1603 if (c2.op.endsWith('-remove')) { 1604 continue; 1605 } 1606 1607 // Record member changes 1608 if (c2.op.startsWith('member-') || c2.op.startsWith('property-member-')) { 1609 changes.push(c2); 1610 continue; 1611 } 1612 1613 // Automatically apply remote changes for non-items, even if in conflict 1614 if ((objectType == 'item' && currentJSON.deleted && newJSON.deleted) 1615 || objectType != 'item') { 1616 changes.push(c2); 1617 continue; 1618 } 1619 1620 // Field changes are conflicts 1621 // 1622 // Since we don't know what changed, use only 'add' and 'delete' 1623 if (c2.op == 'modify') { 1624 c2.op = 'add'; 1625 } 1626 let val = currentJSON[c2.field]; 1627 let c1 = { 1628 field: c2.field, 1629 op: val !== undefined ? 'add' : 'delete' 1630 }; 1631 if (val !== undefined) { 1632 c1.value = val; 1633 } 1634 if (c2.op == 'modify') { 1635 c2.op = 'add'; 1636 } 1637 conflicts.push([c1, c2]); 1638 } 1639 1640 var localChanged = false; 1641 var normalizeHTML = (str) => { 1642 let parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] 1643 .createInstance(Components.interfaces.nsIDOMParser); 1644 str = parser.parseFromString(str, 'text/html'); 1645 str = str.body.textContent; 1646 // Normalize internal spaces 1647 str = str.replace(/\s+/g, ' '); 1648 return str; 1649 }; 1650 1651 // Massage some old data 1652 conflicts = conflicts.filter((x) => { 1653 // If one side has auto-hyphenated ISBN, use that 1654 if (x[0].field == 'ISBN' && x[0].op == 'add' && x[1].op == 'add') { 1655 let hyphenatedA = Zotero.Utilities.Internal.hyphenateISBN(x[0].value); 1656 let hyphenatedB = Zotero.Utilities.Internal.hyphenateISBN(x[1].value); 1657 if (hyphenatedA && hyphenatedB) { 1658 // Use remote 1659 if (hyphenatedA == x[1].value) { 1660 changes.push(x[1]); 1661 return false; 1662 } 1663 // Use local 1664 else if (x[0].value == hyphenatedB) { 1665 localChanged = true; 1666 return false; 1667 } 1668 } 1669 } 1670 // Ignore notes with the same text content 1671 // 1672 // These can happen to people upgrading to 5.0 with notes that were added without going 1673 // through TinyMCE (e.g., from translators) 1674 else if (x[0].field == 'note' && x[0].op == 'add' && x[1].op == 'add') { 1675 let a = x[0].value; 1676 let b = x[1].value; 1677 try { 1678 a = normalizeHTML(a); 1679 b = normalizeHTML(b); 1680 if (a == b) { 1681 Zotero.debug("Notes differ only by markup -- using remote version"); 1682 changes.push(x[1]); 1683 return false; 1684 } 1685 } 1686 catch (e) { 1687 Zotero.logError(e); 1688 return true 1689 } 1690 } 1691 return true; 1692 }); 1693 1694 return { changes, conflicts, localChanged }; 1695 }, 1696 1697 1698 markObjectAsSynced: Zotero.Promise.method(function (obj) { 1699 obj.synced = true; 1700 return obj.saveTx({ skipAll: true }); 1701 }), 1702 1703 1704 markObjectAsUnsynced: Zotero.Promise.method(function (obj) { 1705 obj.synced = false; 1706 return obj.saveTx({ skipAll: true }); 1707 }), 1708 1709 1710 /** 1711 * @return {Promise<Date|false>} 1712 */ 1713 getDateDeleted: Zotero.Promise.coroutine(function* (objectType, libraryID, key) { 1714 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 1715 var sql = "SELECT dateDeleted FROM syncDeleteLog WHERE libraryID=? AND key=? " 1716 + "AND syncObjectTypeID=?"; 1717 var date = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, syncObjectTypeID]); 1718 return date ? Zotero.Date.sqlToDate(date, true) : false; 1719 }), 1720 1721 1722 /** 1723 * @return {Promise<String[]>} - Promise for array of keys 1724 */ 1725 getDeleted: Zotero.Promise.coroutine(function* (objectType, libraryID) { 1726 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 1727 var sql = "SELECT key FROM syncDeleteLog WHERE libraryID=? AND syncObjectTypeID=?"; 1728 return Zotero.DB.columnQueryAsync(sql, [libraryID, syncObjectTypeID]); 1729 }), 1730 1731 1732 /** 1733 * @return {Promise} 1734 */ 1735 removeObjectsFromDeleteLog: function (objectType, libraryID, keys) { 1736 if (!keys.length) Zotero.Promise.resolve(); 1737 1738 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 1739 var sql = "DELETE FROM syncDeleteLog WHERE libraryID=? AND syncObjectTypeID=? AND key IN ("; 1740 return Zotero.Utilities.Internal.forEachChunkAsync( 1741 keys, 1742 Zotero.DB.MAX_BOUND_PARAMETERS - 2, 1743 Zotero.Promise.coroutine(function* (chunk) { 1744 var params = [libraryID, syncObjectTypeID].concat(chunk); 1745 return Zotero.DB.queryAsync( 1746 sql + Array(chunk.length).fill('?').join(',') + ")", params 1747 ); 1748 }) 1749 ); 1750 }, 1751 1752 1753 clearDeleteLogForLibrary: async function (libraryID) { 1754 await Zotero.DB.queryAsync("DELETE FROM syncDeleteLog WHERE libraryID=?", libraryID); 1755 }, 1756 1757 1758 addObjectsToSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID, keys) { 1759 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 1760 var now = Zotero.Date.getUnixTimestamp(); 1761 1762 // Default to first try 1763 var keyTries = {}; 1764 keys.forEach(key => keyTries[key] = 0); 1765 1766 // Check current try counts 1767 var sql = "SELECT key, tries FROM syncQueue WHERE "; 1768 yield Zotero.Utilities.Internal.forEachChunkAsync( 1769 keys, 1770 Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 3), 1771 Zotero.Promise.coroutine(function* (chunk) { 1772 var params = chunk.reduce( 1773 (arr, key) => arr.concat([libraryID, key, syncObjectTypeID]), [] 1774 ); 1775 var rows = yield Zotero.DB.queryAsync( 1776 sql + Array(chunk.length) 1777 .fill('(libraryID=? AND key=? AND syncObjectTypeID=?)') 1778 .join(' OR '), 1779 params 1780 ); 1781 for (let row of rows) { 1782 keyTries[row.key] = row.tries + 1; // increment current count 1783 } 1784 }) 1785 ); 1786 1787 // Insert or update 1788 var sql = "INSERT OR REPLACE INTO syncQueue " 1789 + "(libraryID, key, syncObjectTypeID, lastCheck, tries) VALUES "; 1790 return Zotero.Utilities.Internal.forEachChunkAsync( 1791 keys, 1792 Math.floor(Zotero.DB.MAX_BOUND_PARAMETERS / 5), 1793 function (chunk) { 1794 var params = chunk.reduce( 1795 (arr, key) => arr.concat( 1796 [libraryID, key, syncObjectTypeID, now, keyTries[key]] 1797 ), [] 1798 ); 1799 return Zotero.DB.queryAsync( 1800 sql + Array(chunk.length).fill('(?, ?, ?, ?, ?)').join(', '), params 1801 ); 1802 } 1803 ); 1804 }), 1805 1806 1807 hasObjectsInSyncQueue: function (libraryID) { 1808 return Zotero.DB.valueQueryAsync( 1809 "SELECT ROWID FROM syncQueue WHERE libraryID=? LIMIT 1", libraryID 1810 ).then(x => !!x); 1811 }, 1812 1813 1814 getObjectsFromSyncQueue: function (objectType, libraryID) { 1815 return Zotero.DB.columnQueryAsync( 1816 "SELECT key FROM syncQueue WHERE libraryID=? AND " 1817 + "syncObjectTypeID IN (SELECT syncObjectTypeID FROM syncObjectTypes WHERE name=?)", 1818 [libraryID, objectType] 1819 ); 1820 }, 1821 1822 1823 hasObjectsToTryInSyncQueue: Zotero.Promise.coroutine(function* (libraryID) { 1824 var rows = yield Zotero.DB.queryAsync( 1825 "SELECT key, lastCheck, tries FROM syncQueue WHERE libraryID=?", libraryID 1826 ); 1827 for (let row of rows) { 1828 let interval = this._syncQueueIntervals[row.tries]; 1829 // Keep using last interval if beyond 1830 if (!interval) { 1831 interval = this._syncQueueIntervals[this._syncQueueIntervals.length - 1]; 1832 } 1833 let nextCheck = row.lastCheck + interval * 60 * 60; 1834 if (nextCheck <= Zotero.Date.getUnixTimestamp()) { 1835 return true; 1836 } 1837 } 1838 return false; 1839 }), 1840 1841 1842 getObjectsToTryFromSyncQueue: Zotero.Promise.coroutine(function* (objectType, libraryID) { 1843 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 1844 var rows = yield Zotero.DB.queryAsync( 1845 "SELECT key, lastCheck, tries FROM syncQueue WHERE libraryID=? AND syncObjectTypeID=?", 1846 [libraryID, syncObjectTypeID] 1847 ); 1848 var keysToTry = []; 1849 for (let row of rows) { 1850 let interval = this._syncQueueIntervals[row.tries]; 1851 // Keep using last interval if beyond 1852 if (!interval) { 1853 interval = this._syncQueueIntervals[this._syncQueueIntervals.length - 1]; 1854 } 1855 let nextCheck = row.lastCheck + interval * 60 * 60; 1856 if (nextCheck <= Zotero.Date.getUnixTimestamp()) { 1857 keysToTry.push(row.key); 1858 } 1859 } 1860 return keysToTry; 1861 }), 1862 1863 1864 removeObjectsFromSyncQueue: function (objectType, libraryID, keys) { 1865 var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); 1866 var sql = "DELETE FROM syncQueue WHERE libraryID=? AND syncObjectTypeID=? AND key IN ("; 1867 return Zotero.Utilities.Internal.forEachChunkAsync( 1868 keys, 1869 Zotero.DB.MAX_BOUND_PARAMETERS - 2, 1870 Zotero.Promise.coroutine(function* (chunk) { 1871 var params = [libraryID, syncObjectTypeID].concat(chunk); 1872 return Zotero.DB.queryAsync( 1873 sql + Array(chunk.length).fill('?').join(',') + ")", params 1874 ); 1875 }) 1876 ); 1877 }, 1878 1879 1880 clearQueueForLibrary: async function (libraryID) { 1881 await Zotero.DB.queryAsync("DELETE FROM syncQueue WHERE libraryID=?", libraryID); 1882 }, 1883 1884 1885 _removeObjectFromSyncQueue: function (objectType, libraryID, key) { 1886 return Zotero.DB.queryAsync( 1887 "DELETE FROM syncQueue WHERE libraryID=? AND key=? AND syncObjectTypeID=?", 1888 [ 1889 libraryID, 1890 key, 1891 Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType) 1892 ] 1893 ); 1894 }, 1895 1896 1897 resetSyncQueue: function () { 1898 return Zotero.DB.queryAsync("DELETE FROM syncQueue"); 1899 }, 1900 1901 1902 resetSyncQueueTries: function () { 1903 return Zotero.DB.queryAsync("UPDATE syncQueue SET tries=0"); 1904 } 1905 }