syncEngine.js (66054B)
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 // TODO: move? 31 Zotero.Sync.Data.conflictDelayIntervals = [10000, 20000, 40000, 60000, 120000, 240000, 300000]; 32 33 /** 34 * An Engine manages sync processes for a given library 35 * 36 * @param {Object} options 37 * @param {Zotero.Sync.APIClient} options.apiClient 38 * @param {Integer} options.libraryID 39 */ 40 Zotero.Sync.Data.Engine = function (options) { 41 if (options.apiClient == undefined) { 42 throw new Error("options.apiClient not set"); 43 } 44 if (options.libraryID == undefined) { 45 throw new Error("options.libraryID not set"); 46 } 47 48 this.apiClient = options.apiClient; 49 this.userID = options.userID; 50 this.libraryID = options.libraryID; 51 this.library = Zotero.Libraries.get(options.libraryID); 52 this.libraryTypeID = this.library.libraryTypeID; 53 this.uploadBatchSize = 25; 54 this.uploadDeletionBatchSize = 50; 55 this.maxUploadTries = 5; 56 57 this.failed = false; 58 this.failedItems = []; 59 60 // Options to pass through to processing functions 61 this.optionNames = [ 62 'setStatus', 63 'onError', 64 'stopOnError', 65 'background', 66 'firstInSession', 67 'resetMode' 68 ]; 69 this.options = {}; 70 this.optionNames.forEach(x => { 71 // Create dummy functions if not set 72 if (x == 'setStatus' || x == 'onError') { 73 this[x] = options[x] || function () {}; 74 } 75 else { 76 this[x] = options[x]; 77 } 78 }); 79 }; 80 81 Zotero.Sync.Data.Engine.prototype.DOWNLOAD_RESULT_CONTINUE = 1; 82 Zotero.Sync.Data.Engine.prototype.DOWNLOAD_RESULT_CHANGES_TO_UPLOAD = 2; 83 Zotero.Sync.Data.Engine.prototype.DOWNLOAD_RESULT_NO_CHANGES_TO_UPLOAD = 3; 84 Zotero.Sync.Data.Engine.prototype.DOWNLOAD_RESULT_LIBRARY_UNMODIFIED = 4; 85 Zotero.Sync.Data.Engine.prototype.DOWNLOAD_RESULT_RESTART = 5; 86 87 Zotero.Sync.Data.Engine.prototype.UPLOAD_RESULT_SUCCESS = 1; 88 Zotero.Sync.Data.Engine.prototype.UPLOAD_RESULT_NOTHING_TO_UPLOAD = 2; 89 Zotero.Sync.Data.Engine.prototype.UPLOAD_RESULT_LIBRARY_CONFLICT = 3; 90 Zotero.Sync.Data.Engine.prototype.UPLOAD_RESULT_OBJECT_CONFLICT = 4; 91 Zotero.Sync.Data.Engine.prototype.UPLOAD_RESULT_RESTART = 5; 92 Zotero.Sync.Data.Engine.prototype.UPLOAD_RESULT_CANCEL = 6; 93 94 Zotero.Sync.Data.Engine.prototype.start = Zotero.Promise.coroutine(function* () { 95 Zotero.debug("Starting data sync for " + this.library.name); 96 97 // TODO: Handle new/changed user when setting key 98 if (this.library.libraryType == 'user' && !this.libraryTypeID) { 99 let info = yield this.apiClient.getKeyInfo(); 100 Zotero.debug("Got userID " + info.userID + " for API key"); 101 this.libraryTypeID = info.userID; 102 } 103 104 this._statusCheck(); 105 this._restoringToServer = false; 106 107 // Check if we've synced this library with the current architecture yet 108 var libraryVersion = this.library.libraryVersion; 109 if (this.resetMode == Zotero.Sync.Runner.RESET_MODE_TO_SERVER) { 110 yield this._restoreToServer(); 111 } 112 else if (!libraryVersion || libraryVersion == -1) { 113 let versionResults = yield this._upgradeCheck(); 114 if (versionResults) { 115 libraryVersion = this.library.libraryVersion; 116 } 117 118 this._statusCheck(); 119 120 // Perform a full sync if necessary, passing the getVersions() results if available. 121 // 122 // The full-sync flag (libraryID == -1) is set at the end of a successful upgrade, so this 123 // won't run for installations that have just never synced before (which also lack library 124 // versions). We can't rely on last classic sync time because it's cleared after the last 125 // library is upgraded. 126 // 127 // Version results won't be available if an upgrade happened on a previous run but the 128 // full sync failed. 129 if (libraryVersion == -1) { 130 yield this._fullSync(versionResults); 131 } 132 } 133 134 this.downloadDelayGenerator = null; 135 var autoReset = false; 136 137 sync: 138 while (true) { 139 this._statusCheck(); 140 141 let downloadResult, uploadResult; 142 143 try { 144 uploadResult = yield this._startUpload(); 145 } 146 catch (e) { 147 if (e instanceof Zotero.Sync.UserCancelledException) { 148 throw e; 149 } 150 Zotero.debug("Upload failed -- performing download", 2); 151 downloadResult = yield this._startDownload(); 152 Zotero.debug("Download result is " + downloadResult, 4); 153 throw e; 154 } 155 156 Zotero.debug("Upload result is " + uploadResult, 4); 157 158 switch (uploadResult) { 159 // If upload succeeded, we're done 160 case this.UPLOAD_RESULT_SUCCESS: 161 break sync; 162 163 case this.UPLOAD_RESULT_OBJECT_CONFLICT: 164 if (Zotero.Prefs.get('sync.debugNoAutoResetClient')) { 165 throw new Error("Skipping automatic client reset due to debug pref"); 166 } 167 if (autoReset) { 168 throw new Error(this.library.name + " has already been auto-reset"); 169 } 170 Zotero.logError("Object in " + this.library.name + " is out of date -- resetting library"); 171 autoReset = true; 172 yield this._fullSync(); 173 break; 174 175 case this.UPLOAD_RESULT_NOTHING_TO_UPLOAD: 176 downloadResult = yield this._startDownload(); 177 Zotero.debug("Download result is " + downloadResult, 4); 178 if (downloadResult == this.DOWNLOAD_RESULT_CHANGES_TO_UPLOAD) { 179 break; 180 } 181 break sync; 182 183 // If conflict, start at beginning with downloads 184 case this.UPLOAD_RESULT_LIBRARY_CONFLICT: 185 if (!gen) { 186 var gen = Zotero.Utilities.Internal.delayGenerator( 187 Zotero.Sync.Data.conflictDelayIntervals, 60 * 1000 188 ); 189 } 190 // After the first upload version conflict (which is expected after remote changes), 191 // start delaying to give other sync sessions time to complete 192 else { 193 let keepGoing = yield gen.next().value; 194 if (!keepGoing) { 195 throw new Error("Could not sync " + this.library.name + " -- too many retries"); 196 } 197 } 198 199 downloadResult = yield this._startDownload(); 200 Zotero.debug("Download result is " + downloadResult, 4); 201 break; 202 203 case this.UPLOAD_RESULT_RESTART: 204 Zotero.debug("Restarting sync for " + this.library.name); 205 break; 206 207 case this.UPLOAD_RESULT_CANCEL: 208 Zotero.debug("Cancelling sync for " + this.library.name); 209 return; 210 } 211 } 212 213 this.library.updateLastSyncTime(); 214 yield this.library.saveTx({ 215 skipNotifier: true 216 }); 217 218 Zotero.debug("Done syncing " + this.library.name); 219 }); 220 221 222 /** 223 * Stop the sync process 224 */ 225 Zotero.Sync.Data.Engine.prototype.stop = function () { 226 Zotero.debug("Stopping data sync for " + this.library.name); 227 this._stopping = true; 228 } 229 230 231 /** 232 * Download updated objects from API and save to DB 233 * 234 * @return {Promise<Integer>} - A download result code (this.DOWNLOAD_RESULT_*) 235 */ 236 Zotero.Sync.Data.Engine.prototype._startDownload = Zotero.Promise.coroutine(function* () { 237 var localChanges = false; 238 var libraryVersion = this.library.libraryVersion; 239 var newLibraryVersion; 240 241 loop: 242 while (true) { 243 this._statusCheck(); 244 245 // Get synced settings first, since they affect how other data is displayed 246 let results = yield this._downloadSettings(libraryVersion); 247 if (results.result == this.DOWNLOAD_RESULT_LIBRARY_UNMODIFIED) { 248 let stop = true; 249 // If it's the first sync of the session or a manual sync and there are objects in the 250 // sync queue, or it's a subsequent auto-sync but there are objects that it's time to try 251 // again, go through all the steps even though the library version is unchanged. 252 // 253 // TODO: Skip the steps without queued objects. 254 if (this.firstInSession || !this.background) { 255 stop = !(yield Zotero.Sync.Data.Local.hasObjectsInSyncQueue(this.libraryID)); 256 } 257 else { 258 stop = !(yield Zotero.Sync.Data.Local.hasObjectsToTryInSyncQueue(this.libraryID)); 259 } 260 if (stop) { 261 break; 262 } 263 } 264 newLibraryVersion = results.libraryVersion; 265 266 // 267 // Get other object types 268 // 269 for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) { 270 this._statusCheck(); 271 272 // For items, fetch top-level items first 273 // 274 // The next run below will then see the same items in the non-top versions request, 275 // but they'll have been downloaded already and will be skipped. 276 if (objectType == 'item') { 277 let result = yield this._downloadUpdatedObjects( 278 objectType, 279 libraryVersion, 280 newLibraryVersion, 281 { 282 top: true 283 } 284 ); 285 if (result == this.DOWNLOAD_RESULT_RESTART) { 286 yield this._onLibraryVersionChange(); 287 continue loop; 288 } 289 } 290 291 let result = yield this._downloadUpdatedObjects( 292 objectType, 293 libraryVersion, 294 newLibraryVersion 295 ); 296 if (result == this.DOWNLOAD_RESULT_RESTART) { 297 yield this._onLibraryVersionChange(); 298 continue loop; 299 } 300 } 301 302 let deletionsResult = yield this._downloadDeletions(libraryVersion, newLibraryVersion); 303 if (deletionsResult.result == this.DOWNLOAD_RESULT_RESTART) { 304 yield this._onLibraryVersionChange(); 305 continue loop; 306 } 307 308 break; 309 } 310 311 if (newLibraryVersion) { 312 // After data is downloaded, the library version is updated to match the remote version. We 313 // track a library version for file syncing separately, so that even if Zotero is closed or 314 // interrupted between a data sync and a file sync, we know that file syncing has to be 315 // performed for any files marked for download during data sync (based on outdated mtime/md5). 316 // Files may be missing remotely, though, so it's only necessary to try to download them once 317 // every time there are remote storage changes, which we indicate with a 'storageDownloadNeeded' 318 // flag set in syncLocal. If the storage version was already behind, though, a storage download 319 // is needed, regardless of whether storage metadata was updated. 320 if (this.library.storageVersion < this.library.libraryVersion) { 321 this.library.storageDownloadNeeded = true; 322 } 323 // Update library version to match remote 324 this.library.libraryVersion = newLibraryVersion; 325 // Skip storage downloads if not needed 326 if (!this.library.storageDownloadNeeded) { 327 this.library.storageVersion = newLibraryVersion; 328 } 329 yield this.library.saveTx(); 330 } 331 332 return localChanges 333 ? this.DOWNLOAD_RESULT_CHANGES_TO_UPLOAD 334 : this.DOWNLOAD_RESULT_NO_CHANGES_TO_UPLOAD; 335 }); 336 337 338 /** 339 * Download settings modified since the given version 340 * 341 * Unlike the other download methods, this method, which runs first in the main download process, 342 * returns an object rather than just a download result code. It does this so it can return the 343 * current library version from the API to pass to later methods, allowing them to restart the download 344 * process if there was a remote change. 345 * 346 * @param {Integer} since - Last-known library version; get changes since this version 347 * @param {Integer} [newLibraryVersion] - Newest library version seen in this sync process; if newer 348 * version is seen, restart the sync 349 * @return {Object} - Object with 'result' (DOWNLOAD_RESULT_*) and 'libraryVersion' 350 */ 351 Zotero.Sync.Data.Engine.prototype._downloadSettings = Zotero.Promise.coroutine(function* (since, newLibraryVersion) { 352 let results = yield this.apiClient.getSettings( 353 this.library.libraryType, 354 this.libraryTypeID, 355 since 356 ); 357 // If library version hasn't changed remotely, the local library is up-to-date and we 358 // can skip all remaining downloads 359 if (results === false) { 360 Zotero.debug("Library " + this.libraryID + " hasn't been modified " 361 + "-- skipping further object downloads"); 362 return { 363 result: this.DOWNLOAD_RESULT_LIBRARY_UNMODIFIED, 364 libraryVersion: since 365 }; 366 } 367 if (newLibraryVersion !== undefined && newLibraryVersion != results.libraryVersion) { 368 return { 369 result: this.DOWNLOAD_RESULT_RESTART, 370 libraryVersion: results.libraryVersion 371 }; 372 } 373 var numObjects = Object.keys(results.settings).length; 374 if (numObjects) { 375 Zotero.debug(numObjects + " settings modified since last check"); 376 for (let setting in results.settings) { 377 yield Zotero.SyncedSettings.set( 378 this.libraryID, 379 setting, 380 results.settings[setting].value, 381 results.settings[setting].version, 382 true 383 ); 384 } 385 } 386 else { 387 Zotero.debug("No settings modified remotely since last check"); 388 } 389 return { 390 result: this.DOWNLOAD_RESULT_CONTINUE, 391 libraryVersion: results.libraryVersion 392 }; 393 }) 394 395 396 /** 397 * Get versions of objects updated remotely since the last sync time and kick off object downloading 398 * 399 * @param {String} objectType 400 * @param {Integer} since - Last-known library version; get changes sinces this version 401 * @param {Integer} newLibraryVersion - Last library version seen in this sync process; if newer version 402 * is seen, restart the sync 403 * @param {Object} [options] 404 * @return {Promise<Integer>} - A download result code (this.DOWNLOAD_RESULT_*) 405 */ 406 Zotero.Sync.Data.Engine.prototype._downloadUpdatedObjects = Zotero.Promise.coroutine(function* (objectType, since, newLibraryVersion, options = {}) { 407 var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 408 var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 409 410 // Get versions of all objects updated remotely since the current local library version 411 Zotero.debug(`Checking for updated ${options.top ? 'top-level ' : ''}` 412 + `${objectTypePlural} in ${this.library.name}`); 413 var queryParams = {}; 414 if (since) { 415 queryParams.since = since; 416 } 417 if (options.top) { 418 queryParams.top = true; 419 } 420 var results = yield this.apiClient.getVersions( 421 this.library.libraryType, 422 this.libraryTypeID, 423 objectType, 424 queryParams 425 ); 426 427 Zotero.debug("VERSIONS:"); 428 Zotero.debug(JSON.stringify(results)); 429 430 // If something else modified the remote library while we were getting updates, 431 // wait for increasing amounts of time before trying again, and then start from 432 // the beginning 433 if (newLibraryVersion != results.libraryVersion) { 434 return this.DOWNLOAD_RESULT_RESTART; 435 } 436 437 438 var numObjects = Object.keys(results.versions).length; 439 if (numObjects) { 440 Zotero.debug(numObjects + " " + (numObjects == 1 ? objectType : objectTypePlural) 441 + " modified since last check"); 442 } 443 else { 444 Zotero.debug("No " + objectTypePlural + " modified remotely since last check"); 445 } 446 447 // Get objects that should be retried based on the current time, unless it's top-level items mode. 448 // (We don't know if the queued items are top-level or not, so we do them with child items.) 449 let queuedKeys = []; 450 if (objectType != 'item' || !options.top) { 451 if (this.firstInSession || !this.background) { 452 queuedKeys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue( 453 objectType, this.libraryID 454 ); 455 } 456 else { 457 queuedKeys = yield Zotero.Sync.Data.Local.getObjectsToTryFromSyncQueue( 458 objectType, this.libraryID 459 ); 460 } 461 // Don't include items that just failed in the top-level run 462 if (this.failedItems.length) { 463 queuedKeys = Zotero.Utilities.arrayDiff(queuedKeys, this.failedItems); 464 } 465 if (queuedKeys.length) { 466 Zotero.debug(`Refetching ${queuedKeys.length} queued ` 467 + (queuedKeys.length == 1 ? objectType : objectTypePlural)) 468 } 469 } 470 471 if (!numObjects && !queuedKeys.length) { 472 return false; 473 } 474 475 let keys = []; 476 let versions = yield objectsClass.getObjectVersions( 477 this.libraryID, Object.keys(results.versions) 478 ); 479 let upToDate = []; 480 for (let key in results.versions) { 481 // Skip objects that are already up-to-date. Generally all returned objects should have 482 // newer version numbers, but there are some situations, such as full syncs or 483 // interrupted syncs, where we may get versions for objects that are already up-to-date 484 // locally. 485 if (versions[key] == results.versions[key]) { 486 upToDate.push(key); 487 continue; 488 } 489 keys.push(key); 490 } 491 if (upToDate.length) { 492 Zotero.debug(`Skipping up-to-date ${objectTypePlural} in library ${this.libraryID}: ` 493 + upToDate.sort().join(", ")); 494 } 495 496 // In child-items mode, remove top-level items that just failed 497 if (objectType == 'item' && !options.top && this.failedItems.length) { 498 keys = Zotero.Utilities.arrayDiff(keys, this.failedItems); 499 } 500 501 keys.push(...queuedKeys); 502 keys = Zotero.Utilities.arrayUnique(keys); 503 504 if (!keys.length) { 505 Zotero.debug(`No ${objectTypePlural} to download`); 506 return this.DOWNLOAD_RESULT_CONTINUE; 507 } 508 509 return this._downloadObjects(objectType, keys); 510 }); 511 512 513 /** 514 * Download data for specified objects from the API and run processing on them, and show the conflict 515 * resolution window if necessary 516 * 517 * @return {Promise<Integer>} - A download result code (this.DOWNLOAD_RESULT_*) 518 */ 519 Zotero.Sync.Data.Engine.prototype._downloadObjects = async function (objectType, keys) { 520 var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 521 522 var remainingKeys = [...keys]; 523 var lastLength = keys.length; 524 var objectData = {}; 525 keys.forEach(key => objectData[key] = null); 526 527 while (true) { 528 this._statusCheck(); 529 530 // Get data we've downloaded in a previous loop but failed to process 531 var json = []; 532 let keysToDownload = []; 533 for (let key in objectData) { 534 if (objectData[key] === null) { 535 keysToDownload.push(key); 536 } 537 else { 538 json.push(objectData[key]); 539 } 540 } 541 if (json.length) { 542 json = [json]; 543 } 544 // Add promises for batches of downloaded data for remaining keys 545 json.push(...this.apiClient.downloadObjects( 546 this.library.libraryType, 547 this.libraryTypeID, 548 objectType, 549 keysToDownload 550 )); 551 552 // TODO: localize 553 this.setStatus( 554 "Downloading " 555 + (keysToDownload.length == 1 556 ? "1 " + objectType 557 : Zotero.Utilities.numberFormat(remainingKeys.length, 0) + " " + objectTypePlural) 558 + " in " + this.library.name 559 ); 560 561 var conflicts = []; 562 var restored = []; 563 var num = 0; 564 565 // Process batches of object data as they're available, one at a time 566 await Zotero.Promise.map( 567 json, 568 async function (batch) { 569 this._statusCheck(); 570 571 Zotero.debug(`Processing batch of downloaded ${objectTypePlural} in ${this.library.name}`); 572 573 if (!Array.isArray(batch)) { 574 this.failed = batch; 575 return; 576 } 577 578 // Save downloaded JSON for later attempts 579 batch.forEach(obj => { 580 objectData[obj.key] = obj; 581 }); 582 583 // Process objects 584 let results = await Zotero.Sync.Data.Local.processObjectsFromJSON( 585 objectType, 586 this.libraryID, 587 batch, 588 this._getOptions({ 589 onObjectProcessed: () => { 590 num++; 591 // Check for stop every 5 items 592 if (num % 5 == 0) { 593 this._statusCheck(); 594 } 595 }, 596 // Increase the notifier batch size as we go, so that new items start coming in 597 // one by one but then switch to larger chunks 598 getNotifierBatchSize: () => { 599 var size; 600 if (num < 10) { 601 size = 1; 602 } 603 else if (num < 50) { 604 size = 5; 605 } 606 else if (num < 150) { 607 size = 25; 608 } 609 else { 610 size = 50; 611 } 612 return Math.min(size, batch.length); 613 } 614 }) 615 ); 616 617 num += results.length; 618 let processedKeys = []; 619 let conflictResults = []; 620 results.forEach(x => { 621 // If data was processed, remove JSON 622 if (x.processed) { 623 delete objectData[x.key]; 624 625 // We'll need to add items back to restored collections 626 if (x.restored) { 627 restored.push(x.key); 628 } 629 } 630 // If object shouldn't be retried, mark as processed 631 if (x.processed || !x.retry) { 632 processedKeys.push(x.key); 633 } 634 if (x.conflict) { 635 conflictResults.push(x); 636 } 637 }); 638 remainingKeys = Zotero.Utilities.arrayDiff(remainingKeys, processedKeys); 639 conflicts.push(...conflictResults); 640 }.bind(this), 641 { 642 concurrency: 1 643 } 644 ); 645 646 // If any locally deleted collections were restored, either add them back to the collection 647 // (if the items still exist) or remove them from the delete log and add them to the sync queue 648 if (restored.length && objectType == 'collection') { 649 await this._restoreRestoredCollectionItems(restored); 650 } 651 652 this._statusCheck(); 653 654 // If all requests were successful, such that we had a chance to see all keys, remove keys we 655 // didn't see from the sync queue so they don't keep being retried forever 656 if (!this.failed) { 657 let missingKeys = keys.filter(key => objectData[key] === null); 658 if (missingKeys.length) { 659 Zotero.debug(`Removing ${missingKeys.length} missing ` 660 + Zotero.Utilities.pluralize(missingKeys.length, [objectType, objectTypePlural]) 661 + " from sync queue"); 662 await Zotero.Sync.Data.Local.removeObjectsFromSyncQueue(objectType, this.libraryID, missingKeys); 663 remainingKeys = Zotero.Utilities.arrayDiff(remainingKeys, missingKeys); 664 } 665 } 666 667 if (!remainingKeys.length || remainingKeys.length == lastLength) { 668 // Add failed objects to sync queue 669 let failedKeys = keys.filter(key => objectData[key]); 670 if (failedKeys.length) { 671 Zotero.debug(`Queueing ${failedKeys.length} failed ` 672 + Zotero.Utilities.pluralize(failedKeys.length, [objectType, objectTypePlural]) 673 + " for later", 2); 674 await Zotero.Sync.Data.Local.addObjectsToSyncQueue( 675 objectType, this.libraryID, failedKeys 676 ); 677 678 // Note failed item keys so child items step (if this isn't it) can skip them 679 if (objectType == 'item') { 680 this.failedItems = failedKeys; 681 } 682 } 683 else { 684 Zotero.debug(`All ${objectTypePlural} for ${this.library.name} saved to database`); 685 686 if (objectType == 'item') { 687 this.failedItems = []; 688 } 689 } 690 break; 691 } 692 693 lastLength = remainingKeys.length; 694 695 Zotero.debug(`Retrying ${remainingKeys.length} remaining ` 696 + Zotero.Utilities.pluralize(remainingKeys, [objectType, objectTypePlural])); 697 } 698 699 // Show conflict resolution window 700 if (conflicts.length) { 701 this._statusCheck(); 702 703 let results = await Zotero.Sync.Data.Local.processConflicts( 704 objectType, this.libraryID, conflicts, this._getOptions() 705 ); 706 let keys = results.filter(x => x.processed).map(x => x.key); 707 // If all keys are unprocessed and didn't fail from an error, conflict resolution was cancelled 708 if (results.every(x => !x.processed && !x.error)) { 709 throw new Zotero.Sync.UserCancelledException(); 710 } 711 await Zotero.Sync.Data.Local.removeObjectsFromSyncQueue(objectType, this.libraryID, keys); 712 } 713 714 return this.DOWNLOAD_RESULT_CONTINUE; 715 }; 716 717 718 /** 719 * If a collection is deleted locally but modified remotely between syncs, the local collection is 720 * restored, but collection membership is a property of items, the local items that were previously 721 * in that collection won't be any longer (or they might have been deleted along with the collection), 722 * so we have to get the current collection items from the API and either add them back 723 * (if they exist) or clear them from the delete log and mark them for download. 724 * 725 * Remote items in the trash aren't currently restored and will be removed from the collection when the 726 * local collection-item removal syncs up. 727 */ 728 Zotero.Sync.Data.Engine.prototype._restoreRestoredCollectionItems = async function (collectionKeys) { 729 for (let collectionKey of collectionKeys) { 730 let { keys: itemKeys } = await this.apiClient.getKeys( 731 this.library.libraryType, 732 this.libraryTypeID, 733 { 734 target: `collections/${collectionKey}/items/top`, 735 format: 'keys' 736 } 737 ); 738 739 if (itemKeys.length) { 740 let collection = Zotero.Collections.getByLibraryAndKey(this.libraryID, collectionKey); 741 let addToCollection = []; 742 let addToQueue = []; 743 for (let itemKey of itemKeys) { 744 let o = Zotero.Items.getByLibraryAndKey(this.libraryID, itemKey); 745 if (o) { 746 addToCollection.push(o.id); 747 // Remove item from trash if it's there, since it's not in the trash remotely. 748 // (This would happen if items were moved to the trash along with the collection 749 // deletion.) 750 if (o.deleted) { 751 o.deleted = false 752 await o.saveTx(); 753 } 754 } 755 else { 756 addToQueue.push(itemKey); 757 } 758 } 759 if (addToCollection.length) { 760 Zotero.debug(`Restoring ${addToCollection.length} ` 761 + `${Zotero.Utilities.pluralize(addToCollection.length, ['item', 'items'])} ` 762 + `to restored collection ${collection.libraryKey}`); 763 await Zotero.DB.executeTransaction(function* () { 764 yield collection.addItems(addToCollection); 765 }.bind(this)); 766 } 767 if (addToQueue.length) { 768 Zotero.debug(`Restoring ${addToQueue.length} deleted ` 769 + `${Zotero.Utilities.pluralize(addToQueue.length, ['item', 'items'])} ` 770 + `in restored collection ${collection.libraryKey}`); 771 await Zotero.Sync.Data.Local.removeObjectsFromDeleteLog( 772 'item', this.libraryID, addToQueue 773 ); 774 await Zotero.Sync.Data.Local.addObjectsToSyncQueue( 775 'item', this.libraryID, addToQueue 776 ); 777 } 778 } 779 } 780 }; 781 782 783 784 /** 785 * Get deleted objects from the API and process them 786 * 787 * @param {Integer} since - Last-known library version; get changes sinces this version 788 * @param {Integer} [newLibraryVersion] - Newest library version seen in this sync process; if newer 789 * version is seen, restart the sync 790 * @return {Object} - Object with 'result' (DOWNLOAD_RESULT_*) and 'libraryVersion' 791 */ 792 Zotero.Sync.Data.Engine.prototype._downloadDeletions = Zotero.Promise.coroutine(function* (since, newLibraryVersion) { 793 const batchSize = 50; 794 795 let results = yield this.apiClient.getDeleted( 796 this.library.libraryType, 797 this.libraryTypeID, 798 since 799 ); 800 if (newLibraryVersion && newLibraryVersion != results.libraryVersion) { 801 return { 802 result: this.DOWNLOAD_RESULT_RESTART, 803 libraryVersion: results.libraryVersion 804 }; 805 } 806 807 var numObjects = Object.keys(results.deleted).reduce((n, k) => n + results.deleted[k].length, 0); 808 if (!numObjects) { 809 Zotero.debug("No objects deleted remotely since last check"); 810 return { 811 result: this.DOWNLOAD_RESULT_CONTINUE, 812 libraryVersion: results.libraryVersion 813 }; 814 } 815 816 Zotero.debug(numObjects + " objects deleted remotely since last check"); 817 818 // Process deletions 819 for (let objectTypePlural in results.deleted) { 820 let objectType = Zotero.DataObjectUtilities.getObjectTypeSingular(objectTypePlural); 821 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 822 let toDelete = []; 823 let conflicts = []; 824 for (let key of results.deleted[objectTypePlural]) { 825 // TODO: Remove from request? 826 if (objectType == 'tag') { 827 continue; 828 } 829 830 if (objectType == 'setting') { 831 let meta = Zotero.SyncedSettings.getMetadata(this.libraryID, key); 832 if (!meta) { 833 continue; 834 } 835 if (meta.synced) { 836 yield Zotero.SyncedSettings.clear(this.libraryID, key, { 837 skipDeleteLog: true 838 }); 839 } 840 841 // Ignore setting if changed locally 842 continue; 843 } 844 845 let obj = objectsClass.getByLibraryAndKey(this.libraryID, key); 846 if (!obj) { 847 continue; 848 } 849 if (obj.synced) { 850 toDelete.push(obj); 851 } 852 // Conflict resolution 853 else if (objectType == 'item') { 854 // If item is already in trash locally, just delete it 855 if (obj.deleted) { 856 Zotero.debug("Local item is in trash -- applying remote deletion"); 857 obj.eraseTx({ 858 skipDeleteLog: true 859 }); 860 continue; 861 } 862 conflicts.push({ 863 libraryID: this.libraryID, 864 left: obj.toJSON(), 865 right: { 866 deleted: true 867 } 868 }); 869 } 870 } 871 872 if (conflicts.length) { 873 this._statusCheck(); 874 875 // Sort conflicts by Date Modified 876 conflicts.sort(function (a, b) { 877 var d1 = a.left.dateModified; 878 var d2 = b.left.dateModified; 879 if (d1 > d2) { 880 return 1 881 } 882 if (d1 < d2) { 883 return -1; 884 } 885 return 0; 886 }); 887 var mergeData = Zotero.Sync.Data.Local.showConflictResolutionWindow(conflicts); 888 if (!mergeData) { 889 Zotero.debug("Cancelling sync"); 890 throw new Zotero.Sync.UserCancelledException(); 891 } 892 yield Zotero.Utilities.Internal.forEachChunkAsync( 893 mergeData, 894 batchSize, 895 function (chunk) { 896 return Zotero.DB.executeTransaction(function* () { 897 for (let json of chunk) { 898 let data = json.data; 899 if (!data.deleted) continue; 900 let obj = objectsClass.getByLibraryAndKey(this.libraryID, data.key); 901 if (!obj) { 902 Zotero.logError("Remotely deleted " + objectType 903 + " didn't exist after conflict resolution"); 904 continue; 905 } 906 yield obj.erase({ 907 skipEditCheck: true 908 }); 909 } 910 }.bind(this)); 911 }.bind(this) 912 ); 913 } 914 915 if (toDelete.length) { 916 yield Zotero.Utilities.Internal.forEachChunkAsync( 917 toDelete, 918 batchSize, 919 function (chunk) { 920 return Zotero.DB.executeTransaction(function* () { 921 for (let obj of chunk) { 922 yield obj.erase({ 923 skipEditCheck: true, 924 skipDeleteLog: true 925 }); 926 } 927 }); 928 } 929 ); 930 } 931 } 932 933 return { 934 result: this.DOWNLOAD_RESULT_CONTINUE, 935 libraryVersion: results.libraryVersion 936 }; 937 }); 938 939 940 /** 941 * If something else modified the remote library while we were getting updates, wait for increasing 942 * amounts of time before trying again, and then start from the beginning 943 */ 944 Zotero.Sync.Data.Engine.prototype._onLibraryVersionChange = Zotero.Promise.coroutine(function* (mode) { 945 Zotero.logError("Library version changed since last download -- restarting sync"); 946 947 if (!this.downloadDelayGenerator) { 948 this.downloadDelayGenerator = Zotero.Utilities.Internal.delayGenerator( 949 Zotero.Sync.Data.conflictDelayIntervals, 60 * 60 * 1000 950 ); 951 } 952 953 let keepGoing = yield this.downloadDelayGenerator.next().value; 954 if (!keepGoing) { 955 throw new Error("Could not update " + this.library.name + " -- library in use"); 956 } 957 }); 958 959 960 /** 961 * Get unsynced objects, build upload JSON, and start API requests 962 * 963 * @throws {Zotero.HTTP.UnexpectedStatusException} 964 * @return {Promise<Integer>} - An upload result code (this.UPLOAD_RESULT_*) 965 */ 966 Zotero.Sync.Data.Engine.prototype._startUpload = Zotero.Promise.coroutine(function* () { 967 var libraryVersion = this.library.libraryVersion; 968 969 var settingsUploaded = false; 970 var uploadNeeded = false; 971 var objectIDs = {}; 972 var objectDeletions = {}; 973 974 // Upload synced settings 975 try { 976 let settings = yield Zotero.SyncedSettings.getUnsynced(this.libraryID); 977 if (Object.keys(settings).length) { 978 libraryVersion = yield this._uploadSettings(settings, libraryVersion); 979 settingsUploaded = true; 980 } 981 else { 982 Zotero.debug("No settings to upload in " + this.library.name); 983 } 984 } 985 catch (e) { 986 return this._handleUploadError(e); 987 } 988 989 // Get unsynced local objects for each object type 990 for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) { 991 this._statusCheck(); 992 993 let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 994 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 995 996 // New/modified objects 997 let ids = yield Zotero.Sync.Data.Local.getUnsynced(objectType, this.libraryID); 998 let origIDs = ids; // TEMP 999 1000 // Skip objects in sync queue, because they might have unresolved conflicts. 1001 // The queue only has keys, so we have to convert to keys and back. 1002 let unsyncedKeys = ids.map(id => objectsClass.getLibraryAndKeyFromID(id).key); 1003 let origUnsynced = unsyncedKeys; // TEMP 1004 let queueKeys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, this.libraryID); 1005 let newUnsyncedKeys = Zotero.Utilities.arrayDiff(unsyncedKeys, queueKeys); 1006 if (newUnsyncedKeys.length < unsyncedKeys.length) { 1007 Zotero.debug(`Skipping ${unsyncedKeys.length - newUnsyncedKeys.length} key(s) in sync queue`); 1008 Zotero.debug(Zotero.Utilities.arrayDiff(unsyncedKeys, newUnsyncedKeys)); 1009 } 1010 unsyncedKeys = newUnsyncedKeys; 1011 1012 // TEMP 1013 //ids = unsyncedKeys.map(key => objectsClass.getIDFromLibraryAndKey(this.libraryID, key)); 1014 let missing = []; 1015 ids = unsyncedKeys.map(key => { 1016 let id = objectsClass.getIDFromLibraryAndKey(this.libraryID, key) 1017 if (!id) { 1018 Zotero.debug("Missing id for key " + key); 1019 missing.push(key); 1020 } 1021 return id; 1022 }); 1023 if (missing.length) { 1024 Zotero.debug("Missing " + objectTypePlural + ":"); 1025 Zotero.debug(origIDs); 1026 Zotero.debug(origUnsynced); 1027 Zotero.debug(ids); 1028 Zotero.debug(unsyncedKeys); 1029 Zotero.debug(missing); 1030 for (let key of missing) { 1031 Zotero.debug(yield Zotero.DB.valueQueryAsync( 1032 `SELECT ${objectsClass.idColumn} FROM ${objectsClass.table} WHERE libraryID=? AND key=?`, 1033 [this.libraryID, key] 1034 )); 1035 } 1036 } 1037 1038 if (ids.length) { 1039 Zotero.debug(ids.length + " " 1040 + (ids.length == 1 ? objectType : objectTypePlural) 1041 + " to upload in library " + this.libraryID); 1042 objectIDs[objectType] = ids; 1043 } 1044 else { 1045 Zotero.debug("No " + objectTypePlural + " to upload in " + this.library.name); 1046 } 1047 1048 // Deleted objects 1049 let keys = yield Zotero.Sync.Data.Local.getDeleted(objectType, this.libraryID); 1050 if (keys.length) { 1051 Zotero.debug(`${keys.length} ${objectType} deletion` 1052 + (keys.length == 1 ? '' : 's') 1053 + ` to upload in ${this.library.name}`); 1054 objectDeletions[objectType] = keys; 1055 } 1056 else { 1057 Zotero.debug(`No ${objectType} deletions to upload in ${this.library.name}`); 1058 } 1059 1060 if (ids.length || keys.length) { 1061 uploadNeeded = true; 1062 } 1063 } 1064 1065 if (!uploadNeeded) { 1066 return settingsUploaded ? this.UPLOAD_RESULT_SUCCESS : this.UPLOAD_RESULT_NOTHING_TO_UPLOAD; 1067 } 1068 1069 try { 1070 Zotero.debug(JSON.stringify(objectIDs)); 1071 for (let objectType in objectIDs) { 1072 this._statusCheck(); 1073 1074 libraryVersion = yield this._uploadObjects( 1075 objectType, objectIDs[objectType], libraryVersion 1076 ); 1077 } 1078 1079 Zotero.debug(JSON.stringify(objectDeletions)); 1080 for (let objectType in objectDeletions) { 1081 this._statusCheck(); 1082 1083 libraryVersion = yield this._uploadDeletions( 1084 objectType, objectDeletions[objectType], libraryVersion 1085 ); 1086 } 1087 } 1088 catch (e) { 1089 return this._handleUploadError(e); 1090 } 1091 1092 return this.UPLOAD_RESULT_SUCCESS; 1093 }); 1094 1095 1096 Zotero.Sync.Data.Engine.prototype._uploadSettings = Zotero.Promise.coroutine(function* (settings, libraryVersion) { 1097 let json = {}; 1098 for (let key in settings) { 1099 json[key] = { 1100 value: settings[key] 1101 }; 1102 } 1103 libraryVersion = yield this.apiClient.uploadSettings( 1104 this.library.libraryType, 1105 this.libraryTypeID, 1106 libraryVersion, 1107 json 1108 ); 1109 yield Zotero.SyncedSettings.markAsSynced( 1110 this.libraryID, 1111 Object.keys(settings), 1112 libraryVersion 1113 ); 1114 if (this.library.libraryVersion == this.library.storageVersion) { 1115 this.library.storageVersion = libraryVersion; 1116 } 1117 this.library.libraryVersion = libraryVersion; 1118 yield this.library.saveTx({ 1119 skipNotifier: true 1120 }); 1121 1122 Zotero.debug("Done uploading settings in " + this.library.name); 1123 return libraryVersion; 1124 }); 1125 1126 1127 Zotero.Sync.Data.Engine.prototype._uploadObjects = Zotero.Promise.coroutine(function* (objectType, ids, libraryVersion) { 1128 let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 1129 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1130 1131 let queue = []; 1132 for (let id of ids) { 1133 queue.push({ 1134 id: id, 1135 json: null, 1136 tries: 0, 1137 failed: false 1138 }); 1139 } 1140 1141 // Watch for objects that change locally during the sync, so that we don't overwrite them with the 1142 // older saved server version after uploading 1143 var changedObjects = new Set(); 1144 var observerID = Zotero.Notifier.registerObserver( 1145 { 1146 notify: function (event, type, ids, extraData) { 1147 let keys = []; 1148 if (event == 'modify') { 1149 keys = ids.map(id => { 1150 var { libraryID, key } = objectsClass.getLibraryAndKeyFromID(id); 1151 return (libraryID == this.libraryID) ? key : false; 1152 }); 1153 } 1154 else if (event == 'delete') { 1155 keys = ids.map(id => { 1156 if (!extraData[id]) return false; 1157 var { libraryID, key } = extraData[id]; 1158 return (libraryID == this.libraryID) ? key : false; 1159 }); 1160 } 1161 keys.filter(key => key).forEach(key => changedObjects.add(key)); 1162 }.bind(this) 1163 }, 1164 [objectType], 1165 objectTypePlural + "Upload" 1166 ); 1167 1168 try { 1169 while (queue.length) { 1170 this._statusCheck(); 1171 1172 // Get a slice of the queue and generate JSON for objects if necessary 1173 let batch = []; 1174 let numSkipped = 0; 1175 for (let i = 0; i < queue.length && i < this.uploadBatchSize; i++) { 1176 let o = queue[i]; 1177 // Skip requests that failed with 4xx or that have been retried too many times 1178 if (o.failed || o.tries >= this.maxUploadTries) { 1179 numSkipped++; 1180 continue; 1181 } 1182 if (!o.json) { 1183 o.json = yield this._getJSONForObject( 1184 objectType, 1185 o.id, 1186 { 1187 restoreToServer: this._restoringToServer, 1188 // Only include storage properties ('mtime', 'md5') when restoring to 1189 // server and for WebDAV files 1190 skipStorageProperties: 1191 objectType == 'item' 1192 ? !this._restoringToServer 1193 && Zotero.Sync.Storage.Local.getModeForLibrary(this.library.libraryID) != 'webdav' 1194 : undefined 1195 } 1196 ); 1197 } 1198 batch.push(o); 1199 } 1200 1201 // No more non-failed requests 1202 if (!batch.length) { 1203 Zotero.debug(`No more ${objectTypePlural} to upload`); 1204 break; 1205 } 1206 1207 // Remove selected and skipped objects from queue 1208 queue.splice(0, batch.length + numSkipped); 1209 1210 let jsonBatch = batch.map(o => o.json); 1211 1212 Zotero.debug("UPLOAD BATCH:"); 1213 Zotero.debug(jsonBatch); 1214 1215 let results; 1216 let numSuccessful = 0; 1217 ({ libraryVersion, results } = yield this.apiClient.uploadObjects( 1218 this.library.libraryType, 1219 this.libraryTypeID, 1220 "POST", 1221 libraryVersion, 1222 objectType, 1223 jsonBatch 1224 )); 1225 1226 // Mark successful and unchanged objects as synced with new version, 1227 // and save uploaded JSON to cache 1228 let updateVersionIDs = []; 1229 let updateSyncedIDs = []; 1230 let toSave = []; 1231 let toCache = []; 1232 for (let state of ['successful', 'unchanged']) { 1233 for (let index in results[state]) { 1234 let current = results[state][index]; 1235 // 'successful' includes objects, not keys 1236 let key = state == 'successful' ? current.key : current; 1237 let changed = changedObjects.has(key); 1238 1239 if (key != jsonBatch[index].key) { 1240 throw new Error("Key mismatch (" + key + " != " + jsonBatch[index].key + ")"); 1241 } 1242 1243 let obj = objectsClass.getByLibraryAndKey(this.libraryID, key); 1244 // This might not exist if the object was deleted during the upload 1245 if (obj) { 1246 updateVersionIDs.push(obj.id); 1247 if (!changed) { 1248 updateSyncedIDs.push(obj.id); 1249 } 1250 } 1251 1252 if (state == 'successful') { 1253 // Update local object with saved data if necessary, as long as it hasn't 1254 // changed locally since the upload 1255 if (!changed) { 1256 obj.fromJSON(current.data); 1257 toSave.push(obj); 1258 } 1259 else { 1260 Zotero.debug("Local version changed during upload " 1261 + "-- not updating from remotely saved version"); 1262 } 1263 toCache.push(current); 1264 } 1265 else { 1266 // This won't necessarily reflect the actual version of the object on the server, 1267 // since objects are uploaded in batches and we only get the final version, but it 1268 // will guarantee that the item won't be redownloaded unnecessarily in the case of 1269 // a full sync, because the version will be higher than whatever version is on the 1270 // server. 1271 jsonBatch[index].version = libraryVersion; 1272 toCache.push(jsonBatch[index]); 1273 } 1274 1275 numSuccessful++; 1276 // Remove from batch to mark as successful 1277 delete batch[index]; 1278 delete jsonBatch[index]; 1279 } 1280 } 1281 yield Zotero.Sync.Data.Local.saveCacheObjects( 1282 objectType, this.libraryID, toCache 1283 ); 1284 yield Zotero.DB.executeTransaction(function* () { 1285 for (let i = 0; i < toSave.length; i++) { 1286 yield toSave[i].save({ 1287 skipSelect: true, 1288 skipSyncedUpdate: true, 1289 // We want to minimize the times when server writes actually result in local 1290 // updates, but when they do, don't update the user-visible timestamp 1291 skipDateModifiedUpdate: true 1292 }); 1293 } 1294 if (this.library.libraryVersion == this.library.storageVersion) { 1295 this.library.storageVersion = libraryVersion; 1296 } 1297 this.library.libraryVersion = libraryVersion; 1298 yield this.library.save(); 1299 objectsClass.updateVersion(updateVersionIDs, libraryVersion); 1300 objectsClass.updateSynced(updateSyncedIDs, true); 1301 }.bind(this)); 1302 1303 // Purge older objects in sync cache 1304 if (toSave.length) { 1305 yield Zotero.Sync.Data.Local.purgeCache(objectType, this.libraryID); 1306 } 1307 1308 // Handle failed objects 1309 for (let index in results.failed) { 1310 let { code, message, data } = results.failed[index]; 1311 let key = jsonBatch[index].key; 1312 // API errors are HTML 1313 message = Zotero.Utilities.unescapeHTML(message); 1314 let e = new Error(message); 1315 e.name = "ZoteroObjectUploadError"; 1316 e.code = code; 1317 if (data) { 1318 e.data = data; 1319 } 1320 e.objectType = objectType; 1321 e.object = objectsClass.getByLibraryAndKey(this.libraryID, key); 1322 1323 Zotero.logError(`Error ${code} for ${objectType} ${key} in ` 1324 + this.library.name + ":\n\n" + e); 1325 1326 let keepGoing = yield this._checkObjectUploadError(objectType, key, e, queue, batch); 1327 if (keepGoing) { 1328 numSuccessful++; 1329 continue; 1330 } 1331 1332 if (this.onError) { 1333 this.onError(e); 1334 } 1335 if (this.stopOnError) { 1336 throw e; 1337 } 1338 batch[index].tries++; 1339 // Mark 400 errors as permanently failed 1340 if (e.code < 500) { 1341 batch[index].failed = true; 1342 } 1343 // 500 errors should stay in queue and be retried, unless a dependency also failed 1344 else if (objectType == 'item') { 1345 // Check parent item 1346 let parentItem = batch[index].json.parentItem; 1347 if (parentItem) { 1348 for (let i in batch) { 1349 if (i == index) break; 1350 let o = batch[i]; 1351 if (o.failed && o.json.key) { 1352 Zotero.debug(`Not retrying child of failed parent ${parentItem}`); 1353 batch[index].failed = true; 1354 } 1355 } 1356 } 1357 } 1358 } 1359 1360 // Add failed objects back to end of queue 1361 var numFailed = 0; 1362 for (let o of batch) { 1363 if (o !== undefined) { 1364 queue.push(o); 1365 // TODO: Clear JSON? 1366 numFailed++; 1367 } 1368 } 1369 Zotero.debug("Failed: " + numFailed, 2); 1370 1371 // If we didn't make any progress, bail 1372 if (!numSuccessful) { 1373 throw new Error("Made no progress during upload -- stopping"); 1374 } 1375 } 1376 } 1377 finally { 1378 Zotero.Notifier.unregisterObserver(observerID); 1379 } 1380 Zotero.debug("Done uploading " + objectTypePlural + " in library " + this.libraryID); 1381 1382 return libraryVersion; 1383 }) 1384 1385 1386 Zotero.Sync.Data.Engine.prototype._uploadDeletions = Zotero.Promise.coroutine(function* (objectType, keys, libraryVersion) { 1387 let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 1388 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1389 1390 while (keys.length) { 1391 let batch = keys.slice(0, this.uploadDeletionBatchSize); 1392 libraryVersion = yield this.apiClient.uploadDeletions( 1393 this.library.libraryType, 1394 this.libraryTypeID, 1395 libraryVersion, 1396 objectType, 1397 batch 1398 ); 1399 keys.splice(0, batch.length); 1400 1401 // Update library version 1402 if (this.library.libraryVersion == this.library.storageVersion) { 1403 this.library.storageVersion = libraryVersion; 1404 } 1405 this.library.libraryVersion = libraryVersion; 1406 yield this.library.saveTx({ 1407 skipNotifier: true 1408 }); 1409 1410 // Remove successful deletions from delete log 1411 yield Zotero.Sync.Data.Local.removeObjectsFromDeleteLog( 1412 objectType, this.libraryID, batch 1413 ); 1414 } 1415 Zotero.debug(`Done uploading ${objectType} deletions in ${this.library.name}`); 1416 1417 return libraryVersion; 1418 }); 1419 1420 1421 Zotero.Sync.Data.Engine.prototype._getJSONForObject = function (objectType, id, options = {}) { 1422 return Zotero.DB.executeTransaction(function* () { 1423 var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1424 var obj = objectsClass.get(id); 1425 var cacheObj = false; 1426 // If the object has been synced before, get the pristine version from the cache so we can 1427 // use PATCH mode and include only fields that have changed 1428 if (obj.version) { 1429 cacheObj = yield Zotero.Sync.Data.Local.getCacheObject( 1430 objectType, obj.libraryID, obj.key, obj.version 1431 ); 1432 } 1433 var patchBase = false; 1434 // If restoring to server, use full mode. (The version and cache are cleared, so we would 1435 // use "new" otherwise, which might be slightly different.) 1436 if (options.restoreToServer) { 1437 var mode = 'full'; 1438 } 1439 // If copy of object in cache, use patch mode with cache data as the base 1440 else if (cacheObj) { 1441 var mode = 'patch'; 1442 patchBase = cacheObj.data; 1443 } 1444 // Otherwise use full mode if there's a version 1445 else { 1446 var mode = obj.version ? "full" : "new"; 1447 } 1448 return obj.toJSON({ 1449 mode, 1450 includeKey: true, 1451 includeVersion: !options.restoreToServer, 1452 includeDate: true, 1453 // Whether to skip 'mtime' and 'md5' 1454 skipStorageProperties: options.skipStorageProperties, 1455 // Use last-synced mtime/md5 instead of current values from the file itself 1456 syncedStorageProperties: true, 1457 patchBase 1458 }); 1459 }); 1460 } 1461 1462 1463 /** 1464 * Upgrade library to current sync architecture 1465 * 1466 * This sets the 'synced' and 'version' properties based on classic last-sync times and object 1467 * modification times. Objects are marked as: 1468 * 1469 * - synced=1 if modified locally before the last classic sync time 1470 * - synced=0 (unchanged) if modified locally since the last classic sync time 1471 * - version=<remote version> if modified remotely before the last classic sync time 1472 * - version=0 if modified remotely since the last classic sync time 1473 * 1474 * If both are 0, that's a conflict. 1475 * 1476 * @return {Object[]} - Objects returned from getVersions(), keyed by objectType, for use 1477 * by _fullSync() 1478 */ 1479 Zotero.Sync.Data.Engine.prototype._upgradeCheck = Zotero.Promise.coroutine(function* () { 1480 var libraryVersion = this.library.libraryVersion; 1481 if (libraryVersion) return; 1482 1483 var lastLocalSyncTime = yield Zotero.DB.valueQueryAsync( 1484 "SELECT version FROM version WHERE schema='lastlocalsync'" 1485 ); 1486 // Never synced with classic architecture, or already upgraded and full sync (which updates 1487 // library version) didn't finish 1488 if (!lastLocalSyncTime) return; 1489 1490 Zotero.debug("Upgrading library to current sync architecture"); 1491 1492 var lastRemoteSyncTime = yield Zotero.DB.valueQueryAsync( 1493 "SELECT version FROM version WHERE schema='lastremotesync'" 1494 ); 1495 // Shouldn't happen 1496 if (!lastRemoteSyncTime) lastRemoteSyncTime = lastLocalSyncTime; 1497 1498 var objectTypes = Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID); 1499 1500 // Mark all items modified locally before the last classic sync time as synced 1501 if (lastLocalSyncTime) { 1502 lastLocalSyncTime = new Date(lastLocalSyncTime * 1000); 1503 for (let objectType of objectTypes) { 1504 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1505 let ids = yield objectsClass.getOlder(this.libraryID, lastLocalSyncTime); 1506 yield objectsClass.updateSynced(ids, true); 1507 } 1508 } 1509 1510 var versionResults = {}; 1511 var currentVersions = {}; 1512 var gen; 1513 loop: 1514 while (true) { 1515 let lastLibraryVersion = 0; 1516 for (let objectType of objectTypes) { 1517 currentVersions[objectType] = {}; 1518 1519 let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 1520 // TODO: localize 1521 this.setStatus("Updating " + objectTypePlural + " in " + this.library.name); 1522 1523 // Get versions from API for all objects 1524 let allResults = yield this.apiClient.getVersions( 1525 this.library.libraryType, 1526 this.libraryTypeID, 1527 objectType 1528 ); 1529 1530 // Get versions from API for objects modified remotely since the last classic sync time 1531 let sinceResults = yield this.apiClient.getVersions( 1532 this.library.libraryType, 1533 this.libraryTypeID, 1534 objectType, 1535 { 1536 sincetime: lastRemoteSyncTime 1537 } 1538 ); 1539 1540 // If something else modified the remote library while we were getting updates, 1541 // wait for increasing amounts of time before trying again, and then start from 1542 // the first object type 1543 if (allResults.libraryVersion != sinceResults.libraryVersion 1544 || (lastLibraryVersion && allResults.libraryVersion != lastLibraryVersion)) { 1545 if (!gen) { 1546 gen = Zotero.Utilities.Internal.delayGenerator( 1547 Zotero.Sync.Data.conflictDelayIntervals, 60 * 60 * 1000 1548 ); 1549 } 1550 Zotero.debug("Library version changed since last check (" 1551 + allResults.libraryVersion + " != " 1552 + sinceResults.libraryVersion + " != " 1553 + lastLibraryVersion + ") -- waiting"); 1554 let keepGoing = yield gen.next().value; 1555 if (!keepGoing) { 1556 throw new Error("Could not update " + this.library.name + " -- library in use"); 1557 } 1558 continue loop; 1559 } 1560 else { 1561 lastLibraryVersion = allResults.libraryVersion; 1562 } 1563 1564 versionResults[objectType] = allResults; 1565 1566 // Get versions for remote objects modified remotely before the last classic sync time, 1567 // which is all the objects not modified since that time 1568 for (let key in allResults.versions) { 1569 if (!sinceResults.versions[key]) { 1570 currentVersions[objectType][key] = allResults.versions[key]; 1571 } 1572 } 1573 } 1574 break; 1575 } 1576 1577 // Update versions on local objects modified remotely before last classic sync time, 1578 // to indicate that they don't need to receive remote updates 1579 yield Zotero.DB.executeTransaction(function* () { 1580 for (let objectType in currentVersions) { 1581 let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 1582 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1583 1584 // TODO: localize 1585 this.setStatus("Updating " + objectTypePlural + " in " + this.library.name); 1586 1587 // Group objects with the same version together and update in batches 1588 let versionObjects = {}; 1589 for (let key in currentVersions[objectType]) { 1590 let id = objectsClass.getIDFromLibraryAndKey(this.libraryID, key); 1591 // If local object doesn't exist, skip 1592 if (!id) continue; 1593 let version = currentVersions[objectType][key]; 1594 if (!versionObjects[version]) { 1595 versionObjects[version] = []; 1596 } 1597 versionObjects[version].push(id); 1598 } 1599 for (let version in versionObjects) { 1600 yield objectsClass.updateVersion(versionObjects[version], version); 1601 } 1602 } 1603 1604 // Mark library as requiring full sync 1605 this.library.libraryVersion = -1; 1606 yield this.library.save(); 1607 1608 // If this is the last classic sync library, delete old timestamps 1609 if (!(yield Zotero.DB.valueQueryAsync("SELECT COUNT(*) FROM libraries WHERE version=0"))) { 1610 yield Zotero.DB.queryAsync( 1611 "DELETE FROM version WHERE schema IN ('lastlocalsync', 'lastremotesync')" 1612 ); 1613 } 1614 }.bind(this)); 1615 1616 Zotero.debug("Done upgrading " + this.library.name); 1617 1618 return versionResults; 1619 }); 1620 1621 1622 /** 1623 * Perform a full sync 1624 * 1625 * Get all object versions from the API and compare to the local database. If any objects are 1626 * missing or outdated, download them. If any local objects are marked as synced but aren't available 1627 * remotely, mark them as unsynced for later uploading. 1628 * 1629 * (Technically this isn't a full sync on its own, because local objects are only flagged for later 1630 * upload.) 1631 * 1632 * @param {Object[]} [versionResults] - Objects returned from getVersions(), keyed by objectType 1633 * @return {Promise<Integer>} - Promise for the library version after syncing 1634 */ 1635 Zotero.Sync.Data.Engine.prototype._fullSync = Zotero.Promise.coroutine(function* (versionResults) { 1636 Zotero.debug("Performing a full sync of " + this.library.name); 1637 1638 var gen; 1639 var lastLibraryVersion; 1640 1641 loop: 1642 while (true) { 1643 this._statusCheck(); 1644 1645 // Reprocess all deletions available from API 1646 let results = yield this._downloadDeletions(0); 1647 lastLibraryVersion = results.libraryVersion; 1648 1649 // Get synced settings 1650 results = yield this._downloadSettings(0, lastLibraryVersion); 1651 if (results.result == this.DOWNLOAD_RESULT_RESTART) { 1652 yield this._onLibraryVersionChange(); 1653 continue loop; 1654 } 1655 else { 1656 lastLibraryVersion = results.libraryVersion; 1657 } 1658 1659 // Get object types 1660 for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) { 1661 this._statusCheck(); 1662 1663 let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 1664 let ObjectType = Zotero.Utilities.capitalize(objectType); 1665 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1666 1667 // TODO: localize 1668 this.setStatus("Updating " + objectTypePlural + " in " + this.library.name); 1669 1670 let results = {}; 1671 // Use provided versions 1672 if (versionResults) { 1673 results = versionResults[objectType]; 1674 } 1675 // If not available, get from API 1676 else { 1677 results = yield this.apiClient.getVersions( 1678 this.library.libraryType, 1679 this.libraryTypeID, 1680 objectType 1681 ); 1682 } 1683 if (lastLibraryVersion != results.libraryVersion) { 1684 yield this._onLibraryVersionChange(); 1685 continue loop; 1686 } 1687 1688 let toDownload = []; 1689 let localVersions = yield objectsClass.getObjectVersions(this.libraryID); 1690 // Queue objects that are out of date or don't exist locally 1691 for (let key in results.versions) { 1692 let version = results.versions[key]; 1693 let obj = objectsClass.getByLibraryAndKey(this.libraryID, key); 1694 // If object is already at or above latest version, skip. Local version can be 1695 // higher because, as explained in _uploadObjects(), we upload items in batches 1696 // and only get the last version to record in the database. 1697 let localVersion = localVersions[key]; 1698 if (localVersion && localVersion >= version) { 1699 continue; 1700 } 1701 1702 if (obj) { 1703 Zotero.debug(`${ObjectType} ${obj.libraryKey} is older than remote version`); 1704 } 1705 else { 1706 Zotero.debug(`${ObjectType} ${this.libraryID}/${key} does not exist locally`); 1707 } 1708 1709 toDownload.push(key); 1710 } 1711 1712 if (toDownload.length) { 1713 Zotero.debug("Downloading missing/outdated " + objectTypePlural); 1714 yield this._downloadObjects(objectType, toDownload); 1715 } 1716 else { 1717 Zotero.debug(`No missing/outdated ${objectTypePlural} to download`); 1718 } 1719 1720 // Mark local objects that don't exist remotely as unsynced and version 0 1721 let allKeys = yield objectsClass.getAllKeys(this.libraryID); 1722 let remoteMissing = Zotero.Utilities.arrayDiff(allKeys, Object.keys(results.versions)); 1723 if (remoteMissing.length) { 1724 Zotero.debug("Checking remotely missing " + objectTypePlural); 1725 Zotero.debug(remoteMissing); 1726 1727 let toUpload = remoteMissing.map( 1728 key => objectsClass.getIDFromLibraryAndKey(this.libraryID, key) 1729 // Remove any objects deleted since getAllKeys() call 1730 ).filter(id => id); 1731 1732 // For remotely missing objects that exist locally, reset version, since old 1733 // version will no longer match remote, and mark for upload 1734 if (toUpload.length) { 1735 Zotero.debug(`Marking remotely missing ${objectTypePlural} as unsynced`); 1736 yield objectsClass.updateVersion(toUpload, 0); 1737 yield objectsClass.updateSynced(toUpload, false); 1738 } 1739 } 1740 else { 1741 Zotero.debug(`No remotely missing synced ${objectTypePlural}`); 1742 } 1743 } 1744 break; 1745 } 1746 1747 this.library.libraryVersion = lastLibraryVersion; 1748 yield this.library.saveTx(); 1749 1750 Zotero.debug("Done with full sync for " + this.library.name); 1751 1752 return lastLibraryVersion; 1753 }); 1754 1755 1756 Zotero.Sync.Data.Engine.prototype._restoreToServer = async function () { 1757 Zotero.debug("Performing a restore-to-server for " + this.library.name); 1758 1759 var libraryVersion; 1760 1761 // Flag engine as restore-to-server mode so it uses library version only 1762 this._restoringToServer = true; 1763 1764 await Zotero.DB.executeTransaction(function* () { 1765 yield Zotero.Sync.Data.Local.clearCacheForLibrary(this.libraryID); 1766 yield Zotero.Sync.Data.Local.clearQueueForLibrary(this.libraryID); 1767 yield Zotero.Sync.Data.Local.clearDeleteLogForLibrary(this.libraryID); 1768 1769 // Mark all local settings as unsynced 1770 yield Zotero.SyncedSettings.markAllAsUnsynced(this.libraryID); 1771 1772 // Mark all objects as unsynced 1773 for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) { 1774 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1775 // Reset version on all objects and mark as unsynced 1776 let ids = yield objectsClass.getAllIDs(this.libraryID) 1777 yield objectsClass.updateVersion(ids, 0); 1778 yield objectsClass.updateSynced(ids, false); 1779 } 1780 }.bind(this)); 1781 1782 var remoteUpdatedError = "Online library updated since restore began"; 1783 1784 for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(this.libraryID)) { 1785 this._statusCheck(); 1786 1787 let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 1788 let ObjectType = Zotero.Utilities.capitalize(objectType); 1789 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 1790 1791 // Get all object versions from the API 1792 let results = await this.apiClient.getVersions( 1793 this.library.libraryType, 1794 this.libraryTypeID, 1795 objectType 1796 ); 1797 if (libraryVersion && libraryVersion != results.libraryVersion) { 1798 throw new Error(remoteUpdatedError 1799 + ` (${libraryVersion} != ${results.libraryVersion})`); 1800 } 1801 libraryVersion = results.libraryVersion; 1802 1803 // Filter to objects that don't exist locally and delete those objects remotely 1804 let remoteKeys = Object.keys(results.versions); 1805 let locallyMissingKeys = remoteKeys.filter((key) => { 1806 return !objectsClass.getIDFromLibraryAndKey(this.libraryID, key); 1807 }); 1808 if (locallyMissingKeys.length) { 1809 Zotero.debug(`Deleting remote ${objectTypePlural} that don't exist locally`); 1810 try { 1811 libraryVersion = await this._uploadDeletions( 1812 objectType, locallyMissingKeys, libraryVersion 1813 ); 1814 } 1815 catch (e) { 1816 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 1817 // Let's just hope this doesn't happen 1818 if (e.status == 412) { 1819 throw new Error(remoteUpdatedError); 1820 } 1821 } 1822 throw e; 1823 } 1824 } 1825 else { 1826 Zotero.debug(`No remote ${objectTypePlural} that don't exist locally`); 1827 } 1828 } 1829 1830 this.library.libraryVersion = libraryVersion; 1831 await this.library.saveTx(); 1832 1833 // Upload the local data, which has all been marked as unsynced. We could just fall through to 1834 // the normal _startUpload() in start(), but we don't want to accidentally restart and 1835 // start downloading data if there's an error condition, so it's safer to call it explicitly 1836 // here. 1837 var uploadResult; 1838 try { 1839 uploadResult = await this._startUpload(); 1840 } 1841 catch (e) { 1842 if (e instanceof Zotero.Sync.UserCancelledException) { 1843 throw e; 1844 } 1845 Zotero.logError("Restore-to-server failed for " + this.library.name); 1846 throw e; 1847 } 1848 1849 Zotero.debug("Upload result is " + uploadResult, 4); 1850 1851 switch (uploadResult) { 1852 case this.UPLOAD_RESULT_SUCCESS: 1853 case this.UPLOAD_RESULT_NOTHING_TO_UPLOAD: 1854 // Force all files to be checked for upload. If an attachment's hash was changed, it will 1855 // no longer have an associated file, and then upload check will cause a file to be 1856 // uploaded (or, more likely if this is a restoration from a backup, reassociated with 1857 // another existing file). If the attachment's hash wasn't changed, it should already 1858 // have the correct file. 1859 await Zotero.Sync.Storage.Local.resetAllSyncStates(this.libraryID); 1860 1861 Zotero.debug("Restore-to-server completed"); 1862 break; 1863 1864 case this.UPLOAD_RESULT_LIBRARY_CONFLICT: 1865 throw new Error(remoteUpdatedError); 1866 1867 case this.UPLOAD_RESULT_RESTART: 1868 return this._restoreToServer() 1869 1870 case this.UPLOAD_RESULT_CANCEL: 1871 throw new Zotero.Sync.UserCancelledException; 1872 1873 default: 1874 throw new Error("Restore-to-server failed for " + this.library.name); 1875 } 1876 1877 this._restoringToServer = false; 1878 }; 1879 1880 1881 Zotero.Sync.Data.Engine.prototype._getOptions = function (additionalOpts = {}) { 1882 var options = {}; 1883 this.optionNames.forEach(x => options[x] = this[x]); 1884 for (let opt in additionalOpts) { 1885 options[opt] = additionalOpts[opt]; 1886 } 1887 return options; 1888 } 1889 1890 1891 Zotero.Sync.Data.Engine.prototype._handleUploadError = Zotero.Promise.coroutine(function* (e) { 1892 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 1893 switch (e.status) { 1894 // This should only happen if library permissions were changed between the group check at 1895 // sync start and now, or to people who upgraded from <5.0-beta.r25+66ca2cf with unsynced local 1896 // changes. 1897 case 403: 1898 let index = Zotero.Sync.Data.Utilities.showWriteAccessLostPrompt(null, this.library); 1899 if (index === 0) { 1900 yield Zotero.Sync.Data.Local.resetUnsyncedLibraryData(this.libraryID); 1901 return this.UPLOAD_RESULT_RESTART; 1902 } 1903 if (index == 1) { 1904 return this.UPLOAD_RESULT_CANCEL; 1905 } 1906 throw new Error(`Unexpected index value ${index}`); 1907 1908 case 409: // TEMP: from classic sync 1909 case 412: 1910 return this.UPLOAD_RESULT_LIBRARY_CONFLICT; 1911 } 1912 } 1913 else if (e.name == "ZoteroObjectUploadError") { 1914 switch (e.code) { 1915 case 404: 1916 case 412: 1917 return this.UPLOAD_RESULT_OBJECT_CONFLICT; 1918 } 1919 } 1920 else if (e.name == "ZoteroUploadRestartError") { 1921 return this.UPLOAD_RESULT_RESTART; 1922 } 1923 else if (e.name == "ZoteroUploadCancelError") { 1924 return this.UPLOAD_RESULT_CANCEL; 1925 } 1926 throw e; 1927 }); 1928 1929 1930 Zotero.Sync.Data.Engine.prototype._checkObjectUploadError = Zotero.Promise.coroutine(function* (objectType, key, e, queue, batch) { 1931 var { code, data, message } = e; 1932 1933 // If an item's dependency is missing remotely and it isn't in the queue (which 1934 // shouldn't happen), mark it as unsynced 1935 if (code == 400 || code == 409) { 1936 if (data) { 1937 if (objectType == 'collection' && code == 409) { 1938 if (data.collection) { 1939 let collection = Zotero.Collections.getByLibraryAndKey(this.libraryID, data.collection); 1940 if (!collection) { 1941 throw new Error(`Collection ${this.libraryID}/${key} ` 1942 + `references parent collection ${data.collection}, which doesn't exist`); 1943 } 1944 Zotero.logError(`Marking collection ${data.collection} as unsynced`); 1945 yield Zotero.Sync.Data.Local.markObjectAsUnsynced(collection); 1946 } 1947 } 1948 else if (objectType == 'item') { 1949 if (data.collection) { 1950 let collection = Zotero.Collections.getByLibraryAndKey(this.libraryID, data.collection); 1951 if (!collection) { 1952 throw new Error(`Item ${this.libraryID}/${key} ` 1953 + `references collection ${data.collection}, which doesn't exist`); 1954 } 1955 Zotero.logError(`Marking collection ${data.collection} as unsynced`); 1956 yield Zotero.Sync.Data.Local.markObjectAsUnsynced(collection); 1957 } 1958 else if (data.parentItem) { 1959 let parentItem = Zotero.Items.getByLibraryAndKey(this.libraryID, data.parentItem); 1960 if (!parentItem) { 1961 throw new Error(`Item ${this.libraryID}/${key} references parent ` 1962 + `item ${data.parentItem}, which doesn't exist`); 1963 } 1964 1965 let id = parentItem.id; 1966 // If parent item isn't already in queue, mark it as unsynced and add it 1967 if (!queue.find(o => o.id == id) 1968 // TODO: Don't use 'delete' on batch, which results in undefineds 1969 && !batch.find(o => o && o.id == id)) { 1970 yield Zotero.Sync.Data.Local.markObjectAsUnsynced(parentItem); 1971 Zotero.logError(`Adding parent item ${data.parentItem} to upload queue`); 1972 queue.push({ 1973 id, 1974 json: null, 1975 tries: 0, 1976 failed: false 1977 }); 1978 // Pretend that we were successful so syncing continues 1979 return true; 1980 } 1981 } 1982 } 1983 } 1984 } 1985 else if (code == 403) { 1986 // If we get a 403 for a local group attachment, check the group permissions to confirm 1987 // that we no longer have file-editing access and prompt to reset local group files 1988 if (objectType == 'item') { 1989 let item = Zotero.Items.getByLibraryAndKey(this.libraryID, key); 1990 if (this.library.libraryType == 'group' && item.isFileAttachment()) { 1991 let reset = false; 1992 let groupID = Zotero.Groups.getGroupIDFromLibraryID(this.libraryID); 1993 let info = yield this.apiClient.getGroup(groupID); 1994 if (info) { 1995 Zotero.debug(info); 1996 let { editable, filesEditable } = Zotero.Groups.getPermissionsFromJSON( 1997 info.data, this.userID 1998 ); 1999 // If we do still have file-editing access, something else went wrong, 2000 // and we should just fail without resetting 2001 if (!filesEditable) { 2002 let index = Zotero.Sync.Storage.Utilities.showFileWriteAccessLostPrompt( 2003 null, this.library 2004 ); 2005 2006 let e = new Error(message); 2007 if (index === 0) { 2008 let group = Zotero.Groups.get(groupID); 2009 group.filesEditable = false; 2010 yield group.saveTx(); 2011 2012 yield Zotero.Sync.Data.Local.resetUnsyncedLibraryFiles(this.libraryID); 2013 e.name = "ZoteroUploadRestartError"; 2014 } 2015 else { 2016 e.name = "ZoteroUploadCancelError"; 2017 } 2018 throw e; 2019 } 2020 } 2021 else { 2022 Zotero.logError("Couldn't get metadata for group " + groupID); 2023 } 2024 } 2025 } 2026 } 2027 // This shouldn't happen, because the upload request includes a library version and should 2028 // prevent an outdated upload before the object version is checked. If it does, we need to 2029 // do a full sync. This error is checked in handleUploadError(). 2030 else if (code == 404 || code == 412) { 2031 throw e; 2032 } 2033 2034 return false; 2035 }); 2036 2037 2038 Zotero.Sync.Data.Engine.prototype._statusCheck = function () { 2039 this._stopCheck(); 2040 this._failedCheck(); 2041 } 2042 2043 2044 Zotero.Sync.Data.Engine.prototype._stopCheck = function () { 2045 if (!this._stopping) return; 2046 Zotero.debug("Sync stopped for " + this.library.name); 2047 throw new Zotero.Sync.UserCancelledException; 2048 } 2049 2050 2051 Zotero.Sync.Data.Engine.prototype._failedCheck = function () { 2052 if (this.stopOnError && this.failed) { 2053 Zotero.logError("Stopping on error"); 2054 throw this.failed; 2055 } 2056 };