syncRunner.js (45716B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2009 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 "use strict"; 27 28 if (!Zotero.Sync) { 29 Zotero.Sync = {}; 30 } 31 32 // Initialized as Zotero.Sync.Runner in zotero.js 33 Zotero.Sync.Runner_Module = function (options = {}) { 34 const stopOnError = false; 35 36 Zotero.defineProperty(this, 'enabled', { 37 get: () => { 38 return _apiKey || Zotero.Sync.Data.Local.hasCredentials(); 39 } 40 }); 41 Zotero.defineProperty(this, 'syncInProgress', { get: () => _syncInProgress }); 42 Zotero.defineProperty(this, 'lastSyncStatus', { get: () => _lastSyncStatus }); 43 44 Zotero.defineProperty(this, 'RESET_MODE_FROM_SERVER', { value: 1 }); 45 Zotero.defineProperty(this, 'RESET_MODE_TO_SERVER', { value: 2 }); 46 47 Zotero.defineProperty(this, 'baseURL', { 48 get: () => { 49 let url = options.baseURL || Zotero.Prefs.get("api.url") || ZOTERO_CONFIG.API_URL; 50 if (!url.endsWith('/')) { 51 url += '/'; 52 } 53 return url; 54 } 55 }); 56 this.apiVersion = options.apiVersion || ZOTERO_CONFIG.API_VERSION; 57 58 // Allows tests to set apiKey in options or as property, overriding login manager 59 var _apiKey = options.apiKey; 60 Zotero.defineProperty(this, 'apiKey', { set: val => _apiKey = val }); 61 62 Components.utils.import("resource://zotero/concurrentCaller.js"); 63 this.caller = new ConcurrentCaller({ 64 numConcurrent: 4, 65 stopOnError, 66 logger: msg => Zotero.debug(msg), 67 onError: e => Zotero.logError(e) 68 }); 69 70 var _enabled = false; 71 var _autoSyncTimer; 72 var _delaySyncUntil; 73 var _delayPromises = []; 74 var _firstInSession = true; 75 var _syncInProgress = false; 76 var _stopping = false; 77 var _manualSyncRequired = false; // TODO: make public? 78 79 var _currentEngine = null; 80 var _storageControllers = {}; 81 82 var _lastSyncStatus; 83 var _currentSyncStatusLabel; 84 var _currentLastSyncLabel; 85 var _errors = []; 86 87 Zotero.addShutdownListener(() => this.stop()); 88 89 this.getAPIClient = function (options = {}) { 90 return new Zotero.Sync.APIClient({ 91 baseURL: this.baseURL, 92 apiVersion: this.apiVersion, 93 apiKey: options.apiKey, 94 caller: this.caller 95 }); 96 } 97 98 99 /** 100 * Begin a sync session 101 * 102 * @param {Object} [options] 103 * @param {Boolean} [options.background=false] Whether this is a background request, which 104 * prevents some alerts from being shown 105 * @param {Integer[]} [options.libraries] IDs of libraries to sync; skipped libraries must 106 * be removed if unwanted 107 * @param {Function} [options.onError] Function to pass errors to instead of 108 * handling internally (used for testing) 109 */ 110 this.sync = Zotero.serial(function (options = {}) { 111 return this._sync(options); 112 }); 113 114 115 this._sync = Zotero.Promise.coroutine(function* (options) { 116 // Clear message list 117 _errors = []; 118 119 // Shouldn't be possible because of serial() 120 if (_syncInProgress) { 121 let msg = Zotero.getString('sync.error.syncInProgress'); 122 let e = new Zotero.Error(msg, 0, { dialogButtonText: null, frontWindowOnly: true }); 123 this.updateIcons(e); 124 return false; 125 } 126 _syncInProgress = true; 127 _stopping = false; 128 129 try { 130 yield Zotero.Notifier.trigger('start', 'sync', []); 131 132 let apiKey = yield _getAPIKey(); 133 if (!apiKey) { 134 throw new Zotero.Error("API key not set", Zotero.Error.ERROR_API_KEY_NOT_SET); 135 } 136 137 if (_firstInSession) { 138 options.firstInSession = true; 139 _firstInSession = false; 140 } 141 142 this.updateIcons('animate'); 143 144 // If a delay is set (e.g., from the connector target selector), wait to sync 145 while (_delaySyncUntil && new Date() < _delaySyncUntil) { 146 this.setSyncStatus(Zotero.getString('sync.status.waiting')); 147 let delay = _delaySyncUntil - new Date(); 148 Zotero.debug(`Waiting ${delay} ms to sync`); 149 yield Zotero.Promise.delay(delay); 150 } 151 152 // If paused, wait until we're done 153 while (true) { 154 if (_delayPromises.some(p => p.isPending())) { 155 this.setSyncStatus(Zotero.getString('sync.status.waiting')); 156 Zotero.debug("Syncing is paused -- waiting to sync"); 157 yield Zotero.Promise.all(_delayPromises); 158 // If more were added, continue 159 if (_delayPromises.some(p => p.isPending())) { 160 continue; 161 } 162 _delayPromises = []; 163 } 164 break; 165 } 166 167 // purgeDataObjects() starts a transaction, so if there's an active one then show a 168 // nice message and wait until there's not. Another transaction could still start 169 // before purgeDataObjects() and result in a wait timeout, but this should reduce the 170 // frequency of that. 171 while (Zotero.DB.inTransaction()) { 172 this.setSyncStatus(Zotero.getString('sync.status.waiting')); 173 Zotero.debug("Transaction in progress -- waiting to sync"); 174 yield Zotero.DB.waitForTransaction('sync'); 175 _stopCheck(); 176 } 177 178 this.setSyncStatus(Zotero.getString('sync.status.preparing')); 179 180 // Purge deleted objects so they don't cause sync errors (e.g., long tags) 181 yield Zotero.purgeDataObjects(true); 182 183 let client = this.getAPIClient({ apiKey }); 184 let keyInfo = yield this.checkAccess(client, options); 185 186 _stopCheck(); 187 188 let emptyLibraryContinue = yield this.checkEmptyLibrary(keyInfo); 189 if (!emptyLibraryContinue) { 190 Zotero.debug("Syncing cancelled because user library is empty"); 191 return false; 192 } 193 194 let wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 195 .getService(Components.interfaces.nsIWindowMediator); 196 let lastWin = wm.getMostRecentWindow("navigator:browser"); 197 if (!(yield Zotero.Sync.Data.Local.checkUser(lastWin, keyInfo.userID, keyInfo.username))) { 198 Zotero.debug("User cancelled sync on username mismatch"); 199 return false; 200 } 201 202 let engineOptions = { 203 userID: keyInfo.userID, 204 apiClient: client, 205 caller: this.caller, 206 setStatus: this.setSyncStatus.bind(this), 207 stopOnError, 208 onError: function (e) { 209 if (options.onError) { 210 options.onError(e); 211 } 212 else { 213 this.addError(e); 214 } 215 }.bind(this), 216 background: !!options.background, 217 firstInSession: _firstInSession, 218 resetMode: options.resetMode 219 }; 220 221 var librariesToSync = options.libraries = yield this.checkLibraries( 222 client, 223 options, 224 keyInfo, 225 options.libraries ? Array.from(options.libraries) : [] 226 ); 227 228 _stopCheck(); 229 230 // If items not yet loaded for libraries we need, load them now 231 for (let libraryID of librariesToSync) { 232 let library = Zotero.Libraries.get(libraryID); 233 if (!library.getDataLoaded('item')) { 234 yield library.waitForDataLoad('item'); 235 } 236 } 237 238 _stopCheck(); 239 240 // Sync data and files, and then repeat if necessary 241 let attempt = 1; 242 let successfulLibraries = new Set(librariesToSync); 243 while (librariesToSync.length) { 244 _stopCheck(); 245 246 if (attempt > 3) { 247 // TODO: Back off and/or nicer error 248 throw new Error("Too many sync attempts -- stopping"); 249 } 250 let nextLibraries = yield _doDataSync(librariesToSync, engineOptions); 251 // Remove failed libraries from the successful set 252 Zotero.Utilities.arrayDiff(librariesToSync, nextLibraries).forEach(libraryID => { 253 successfulLibraries.delete(libraryID); 254 }); 255 256 _stopCheck(); 257 258 // Run file sync on all libraries that passed the last data sync 259 librariesToSync = yield _doFileSync(nextLibraries, engineOptions); 260 if (librariesToSync.length) { 261 attempt++; 262 continue; 263 } 264 265 _stopCheck(); 266 267 // Run full-text sync on all libraries that haven't failed a data sync 268 librariesToSync = yield _doFullTextSync([...successfulLibraries], engineOptions); 269 if (librariesToSync.length) { 270 attempt++; 271 continue; 272 } 273 break; 274 } 275 } 276 catch (e) { 277 if (e instanceof Zotero.HTTP.BrowserOfflineException) { 278 let msg = Zotero.getString('general.browserIsOffline', Zotero.appName); 279 e = new Zotero.Error(msg, 0, { dialogButtonText: null }) 280 Zotero.logError(e); 281 _errors = []; 282 } 283 284 if (e instanceof Zotero.Sync.UserCancelledException) { 285 Zotero.debug("Sync was cancelled"); 286 } 287 else if (options.onError) { 288 options.onError(e); 289 } 290 else { 291 this.addError(e); 292 } 293 } 294 finally { 295 yield this.end(options); 296 297 if (options.restartSync) { 298 delete options.restartSync; 299 Zotero.debug("Restarting sync"); 300 yield this._sync(options); 301 return; 302 } 303 304 Zotero.debug("Done syncing"); 305 Zotero.Notifier.trigger('finish', 'sync', librariesToSync || []); 306 } 307 }); 308 309 310 /** 311 * Check key for current user info and return access info 312 */ 313 this.checkAccess = Zotero.Promise.coroutine(function* (client, options={}) { 314 var json = yield client.getKeyInfo(options); 315 Zotero.debug(json); 316 if (!json) { 317 throw new Zotero.Error("API key not set", Zotero.Error.ERROR_API_KEY_INVALID); 318 } 319 320 // Sanity check 321 if (!json.userID) throw new Error("userID not found in key response"); 322 if (!json.username) throw new Error("username not found in key response"); 323 if (!json.access) throw new Error("'access' not found in key response"); 324 325 return json; 326 }); 327 328 329 // Prompt if library empty and there is no userID stored 330 this.checkEmptyLibrary = Zotero.Promise.coroutine(function* (keyInfo) { 331 let library = Zotero.Libraries.userLibrary; 332 let feeds = Zotero.Feeds.getAll(); 333 let userID = Zotero.Users.getCurrentUserID(); 334 335 if (!userID) { 336 let hasItems = yield library.hasItems(); 337 if (!hasItems && feeds.length <= 0 && !Zotero.resetDataDir) { 338 let ps = Services.prompt; 339 let index = ps.confirmEx( 340 null, 341 Zotero.getString('general.warning'), 342 Zotero.getString('account.warning.emptyLibrary', [keyInfo.username, Zotero.clientName]) + "\n\n" 343 + Zotero.getString('account.warning.existingDataElsewhere', Zotero.clientName), 344 (ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING) 345 + (ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL) 346 + (ps.BUTTON_POS_2 * ps.BUTTON_TITLE_IS_STRING), 347 Zotero.getString('sync.sync'), 348 null, 349 Zotero.getString('dataDir.changeDataDirectory'), 350 null, {} 351 ); 352 if (index == 1) { 353 return false; 354 } 355 else if (index == 2) { 356 var win = Services.wm.getMostRecentWindow("navigator:browser"); 357 win.openDialog("chrome://zotero/content/preferences/preferences.xul", null, null, { 358 pane: 'zotero-prefpane-advanced', 359 tabIndex: 1 360 }); 361 return false; 362 } 363 } 364 } 365 return true; 366 }); 367 368 369 /** 370 * @return {Promise<Integer[]> - IDs of libraries to sync 371 */ 372 this.checkLibraries = Zotero.Promise.coroutine(function* (client, options, keyInfo, libraries = []) { 373 var access = keyInfo.access; 374 375 var syncAllLibraries = !libraries || !libraries.length; 376 377 // TODO: Ability to remove or disable editing of user library? 378 379 if (syncAllLibraries) { 380 if (access.user && access.user.library) { 381 libraries = [Zotero.Libraries.userLibraryID]; 382 let skippedLibraries = Zotero.Sync.Data.Local.getSkippedLibraries(); 383 384 // If syncing all libraries, remove skipped libraries 385 if (skippedLibraries.length) { 386 Zotero.debug("Skipped libraries:"); 387 Zotero.debug(skippedLibraries); 388 libraries = Zotero.Utilities.arrayDiff(libraries, skippedLibraries); 389 } 390 } 391 } 392 else { 393 // Check access to specified libraries 394 for (let libraryID of libraries) { 395 let type = Zotero.Libraries.get(libraryID).libraryType; 396 if (type == 'user') { 397 if (!access.user || !access.user.library) { 398 // TODO: Alert 399 throw new Error("Key does not have access to library " + libraryID); 400 } 401 } 402 } 403 } 404 405 // 406 // Check group access 407 // 408 let remotelyMissingGroups = []; 409 let groupsToDownload = []; 410 411 if (!Zotero.Utilities.isEmpty(access.groups)) { 412 // TEMP: Require all-group access for now 413 if (access.groups.all) { 414 415 } 416 else { 417 throw new Error("Full group access is currently required"); 418 } 419 420 let remoteGroupVersions = yield client.getGroupVersions(keyInfo.userID); 421 let remoteGroupIDs = Object.keys(remoteGroupVersions).map(id => parseInt(id)); 422 let skippedGroups = Zotero.Sync.Data.Local.getSkippedGroups(); 423 424 // Remove skipped groups 425 if (syncAllLibraries) { 426 let newGroups = Zotero.Utilities.arrayDiff(remoteGroupIDs, skippedGroups); 427 Zotero.Utilities.arrayDiff(remoteGroupIDs, newGroups) 428 .forEach(id => { delete remoteGroupVersions[id] }); 429 remoteGroupIDs = newGroups; 430 } 431 432 for (let id in remoteGroupVersions) { 433 id = parseInt(id); 434 let group = Zotero.Groups.get(id); 435 436 if (syncAllLibraries) { 437 // If syncing all libraries, mark any that don't exist, are outdated, or are 438 // archived locally for update. Group is added to the library list after downloading. 439 if (!group || group.version < remoteGroupVersions[id] || group.archived) { 440 Zotero.debug(`Marking group ${id} to download`); 441 groupsToDownload.push(id); 442 } 443 // If not outdated, just add to library list 444 else { 445 Zotero.debug(`Adding group library ${group.libraryID} to sync`); 446 libraries.push(group.libraryID); 447 } 448 } 449 else { 450 // If specific libraries were provided, ignore remote groups that don't 451 // exist locally or aren't in the given list 452 if (!group || libraries.indexOf(group.libraryID) == -1) { 453 continue; 454 } 455 // If group metadata is outdated, mark for update 456 if (group.version < remoteGroupVersions[id]) { 457 groupsToDownload.push(id); 458 } 459 } 460 } 461 462 // Get local groups (all if syncing all libraries or just selected ones) that don't 463 // exist remotely 464 // TODO: Use explicit removals? 465 let localGroups; 466 if (syncAllLibraries) { 467 localGroups = Zotero.Groups.getAll() 468 .map(g => g.id) 469 // Don't include skipped groups 470 .filter(id => skippedGroups.indexOf(id) == -1); 471 } 472 else { 473 localGroups = libraries 474 .filter(id => Zotero.Libraries.get(id).libraryType == 'group') 475 .map(id => Zotero.Groups.getGroupIDFromLibraryID(id)) 476 } 477 Zotero.debug("Local groups:"); 478 Zotero.debug(localGroups); 479 remotelyMissingGroups = Zotero.Utilities.arrayDiff(localGroups, remoteGroupIDs) 480 .map(id => Zotero.Groups.get(id)); 481 } 482 // No group access 483 else { 484 remotelyMissingGroups = Zotero.Groups.getAll(); 485 } 486 487 if (remotelyMissingGroups.length) { 488 // TODO: What about explicit deletions? 489 490 let removedGroups = []; 491 let keptGroups = []; 492 493 let ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] 494 .getService(Components.interfaces.nsIPromptService); 495 let buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) 496 + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING) 497 + (ps.BUTTON_POS_2) * (ps.BUTTON_TITLE_IS_STRING) 498 + ps.BUTTON_DELAY_ENABLE; 499 500 // Prompt for each group 501 // 502 // TODO: Localize 503 for (let group of remotelyMissingGroups) { 504 // Ignore remotely missing archived groups 505 if (group.archived) { 506 groupsToDownload = groupsToDownload.filter(groupID => groupID != group.id); 507 continue; 508 } 509 510 let msg; 511 // If all-groups access but group is missing, user left it 512 if (access.groups && access.groups.all) { 513 msg = "You are no longer a member of the group \u2018" + group.name + "\u2019."; 514 } 515 // If not all-groups access, key might just not have access 516 else { 517 msg = "You no longer have access to the group \u2018" + group.name + "\u2019."; 518 } 519 520 msg += "\n\n" + "Would you like to remove it from this computer or keep it " 521 + "as a read-only library?"; 522 523 let index = ps.confirmEx( 524 null, 525 "Group Not Found", 526 msg, 527 buttonFlags, 528 "Remove Group", 529 // TODO: Any way to have Esc trigger extra1 instead so it doesn't 530 // have to be in this order? 531 "Cancel Sync", 532 "Keep Group", 533 null, {} 534 ); 535 536 if (index == 0) { 537 removedGroups.push(group); 538 } 539 else if (index == 1) { 540 Zotero.debug("Cancelling sync"); 541 return []; 542 } 543 else if (index == 2) { 544 keptGroups.push(group); 545 } 546 } 547 548 let removedLibraryIDs = []; 549 for (let group of removedGroups) { 550 removedLibraryIDs.push(group.libraryID); 551 yield group.eraseTx(); 552 } 553 libraries = Zotero.Utilities.arrayDiff(libraries, removedLibraryIDs); 554 555 let keptLibraryIDs = []; 556 for (let group of keptGroups) { 557 keptLibraryIDs.push(group.libraryID); 558 group.editable = false; 559 group.archived = true; 560 yield group.saveTx(); 561 } 562 libraries = Zotero.Utilities.arrayDiff(libraries, keptLibraryIDs); 563 } 564 565 // Update metadata and permissions on missing or outdated groups 566 for (let groupID of groupsToDownload) { 567 let info = yield client.getGroup(groupID); 568 if (!info) { 569 throw new Error("Group " + groupID + " not found"); 570 } 571 let group = Zotero.Groups.get(groupID); 572 if (group) { 573 // Check if the user's permissions for the group have changed, and prompt to reset 574 // data if so 575 let { editable, filesEditable } = Zotero.Groups.getPermissionsFromJSON( 576 info.data, keyInfo.userID 577 ); 578 let keepGoing = yield Zotero.Sync.Data.Local.checkLibraryForAccess( 579 null, group.libraryID, editable, filesEditable 580 ); 581 // User chose to skip library 582 if (!keepGoing) { 583 Zotero.debug("Skipping sync of group " + group.id); 584 continue; 585 } 586 } 587 else { 588 group = new Zotero.Group; 589 group.id = groupID; 590 } 591 group.version = info.version; 592 group.archived = false; 593 group.fromJSON(info.data, Zotero.Users.getCurrentUserID()); 594 yield group.saveTx(); 595 596 // Add group to library list 597 libraries.push(group.libraryID); 598 } 599 600 // Note: If any non-group library types become archivable, they'll need to be unarchived here. 601 Zotero.debug("Final libraries to sync:"); 602 Zotero.debug(libraries); 603 604 return [...new Set(libraries)]; 605 }); 606 607 608 /** 609 * Run sync engine for passed libraries 610 * 611 * @param {Integer[]} libraries 612 * @param {Object} options 613 * @param {Boolean} skipUpdateLastSyncTime 614 * @return {Integer[]} - Array of libraryIDs that completed successfully 615 */ 616 var _doDataSync = Zotero.Promise.coroutine(function* (libraries, options, skipUpdateLastSyncTime) { 617 var successfulLibraries = []; 618 for (let libraryID of libraries) { 619 _stopCheck(); 620 try { 621 let opts = {}; 622 Object.assign(opts, options); 623 opts.libraryID = libraryID; 624 625 _currentEngine = new Zotero.Sync.Data.Engine(opts); 626 yield _currentEngine.start(); 627 _currentEngine = null; 628 successfulLibraries.push(libraryID); 629 } 630 catch (e) { 631 if (e instanceof Zotero.Sync.UserCancelledException) { 632 if (e.advanceToNextLibrary) { 633 Zotero.debug("Sync cancelled for library " + libraryID + " -- " 634 + "advancing to next library"); 635 continue; 636 } 637 throw e; 638 } 639 640 Zotero.debug("Sync failed for library " + libraryID, 1); 641 Zotero.logError(e); 642 this.checkError(e); 643 options.onError(e); 644 if (stopOnError || e.fatal) { 645 Zotero.debug("Stopping on error", 1); 646 options.caller.stop(); 647 break; 648 } 649 } 650 } 651 // Update last-sync time if any libraries synced 652 // TEMP: Do we want to show updated time if some libraries haven't synced? 653 if (!libraries.length || successfulLibraries.length) { 654 yield Zotero.Sync.Data.Local.updateLastSyncTime(); 655 } 656 return successfulLibraries; 657 }.bind(this)); 658 659 660 /** 661 * @return {Integer[]} - Array of libraries that need data syncing again 662 */ 663 var _doFileSync = Zotero.Promise.coroutine(function* (libraries, options) { 664 Zotero.debug("Starting file syncing"); 665 var resyncLibraries = [] 666 for (let libraryID of libraries) { 667 _stopCheck(); 668 let libraryName = Zotero.Libraries.get(libraryID).name; 669 this.setSyncStatus( 670 Zotero.getString('sync.status.syncingFilesInLibrary', libraryName) 671 ); 672 try { 673 let opts = { 674 onProgress: (progress, progressMax) => { 675 var remaining = progressMax - progress; 676 this.setSyncStatus( 677 Zotero.getString( 678 'sync.status.syncingFilesInLibraryWithRemaining', 679 [libraryName, remaining], 680 remaining 681 ) 682 ); 683 } 684 }; 685 Object.assign(opts, options); 686 opts.libraryID = libraryID; 687 688 let mode = Zotero.Sync.Storage.Local.getModeForLibrary(libraryID); 689 opts.controller = this.getStorageController(mode, opts); 690 691 let tries = 3; 692 while (true) { 693 if (tries == 0) { 694 throw new Error("Too many file sync attempts for library " + libraryID); 695 } 696 tries--; 697 _currentEngine = new Zotero.Sync.Storage.Engine(opts); 698 let results = yield _currentEngine.start(); 699 _currentEngine = null; 700 if (results.syncRequired) { 701 resyncLibraries.push(libraryID); 702 } 703 else if (results.fileSyncRequired) { 704 Zotero.debug("Another file sync required -- restarting"); 705 continue; 706 } 707 break; 708 } 709 } 710 catch (e) { 711 if (e instanceof Zotero.Sync.UserCancelledException) { 712 if (e.advanceToNextLibrary) { 713 Zotero.debug("Storage sync cancelled for library " + libraryID + " -- " 714 + "advancing to next library"); 715 continue; 716 } 717 throw e; 718 } 719 720 Zotero.debug("File sync failed for library " + libraryID); 721 Zotero.logError(e); 722 this.checkError(e); 723 options.onError(e); 724 if (stopOnError || e.fatal) { 725 options.caller.stop(); 726 break; 727 } 728 } 729 } 730 Zotero.debug("Done with file syncing"); 731 if (resyncLibraries.length) { 732 Zotero.debug("Libraries to resync: " + resyncLibraries.join(", ")); 733 } 734 return resyncLibraries; 735 }.bind(this)); 736 737 738 /** 739 * @return {Integer[]} - Array of libraries that need data syncing again 740 */ 741 var _doFullTextSync = Zotero.Promise.coroutine(function* (libraries, options) { 742 if (!Zotero.Prefs.get("sync.fulltext.enabled")) return []; 743 744 Zotero.debug("Starting full-text syncing"); 745 this.setSyncStatus(Zotero.getString('sync.status.syncingFullText')); 746 var resyncLibraries = []; 747 for (let libraryID of libraries) { 748 _stopCheck(); 749 try { 750 let opts = {}; 751 Object.assign(opts, options); 752 opts.libraryID = libraryID; 753 754 _currentEngine = new Zotero.Sync.Data.FullTextEngine(opts); 755 yield _currentEngine.start(); 756 _currentEngine = null; 757 } 758 catch (e) { 759 if (e instanceof Zotero.Sync.UserCancelledException) { 760 throw e; 761 } 762 763 if (e instanceof Zotero.HTTP.UnexpectedStatusException && e.status == 412) { 764 resyncLibraries.push(libraryID); 765 continue; 766 } 767 Zotero.debug("Full-text sync failed for library " + libraryID); 768 Zotero.logError(e); 769 this.checkError(e); 770 options.onError(e); 771 if (stopOnError || e.fatal) { 772 options.caller.stop(); 773 break; 774 } 775 } 776 } 777 Zotero.debug("Done with full-text syncing"); 778 if (resyncLibraries.length) { 779 Zotero.debug("Libraries to resync: " + resyncLibraries.join(", ")); 780 } 781 return resyncLibraries; 782 }.bind(this)); 783 784 785 /** 786 * Get a storage controller for a given mode ('zfs', 'webdav'), 787 * caching it if necessary 788 */ 789 this.getStorageController = function (mode, options) { 790 if (_storageControllers[mode]) { 791 return _storageControllers[mode]; 792 } 793 var modeClass = Zotero.Sync.Storage.Utilities.getClassForMode(mode); 794 return _storageControllers[mode] = new modeClass(options); 795 }, 796 797 798 // TODO: Call on API key change 799 this.resetStorageController = function (mode) { 800 delete _storageControllers[mode]; 801 }, 802 803 804 /** 805 * Download a single file on demand (not within a sync process) 806 */ 807 this.downloadFile = Zotero.Promise.coroutine(function* (item, requestCallbacks) { 808 if (Zotero.HTTP.browserIsOffline()) { 809 Zotero.debug("Browser is offline", 2); 810 return false; 811 } 812 813 var apiKey = yield _getAPIKey(); 814 if (!apiKey) { 815 Zotero.debug("API key not set -- skipping download"); 816 return false; 817 } 818 819 // TEMP 820 var options = {}; 821 822 var itemID = item.id; 823 var modeClass = Zotero.Sync.Storage.Local.getClassForLibrary(item.libraryID); 824 var controller = new modeClass({ 825 apiClient: this.getAPIClient({apiKey }) 826 }); 827 828 // TODO: verify WebDAV on-demand? 829 if (!controller.verified) { 830 Zotero.debug("File syncing is not active for item's library -- skipping download"); 831 return false; 832 } 833 834 if (!item.isImportedAttachment()) { 835 throw new Error("Not an imported attachment"); 836 } 837 838 if (yield item.getFilePathAsync()) { 839 Zotero.debug("File already exists -- replacing"); 840 } 841 842 // TODO: start sync icon? 843 // TODO: create queue for cancelling 844 845 if (!requestCallbacks) { 846 requestCallbacks = {}; 847 } 848 var onStart = function (request) { 849 return controller.downloadFile(request); 850 }; 851 var request = new Zotero.Sync.Storage.Request({ 852 type: 'download', 853 libraryID: item.libraryID, 854 name: item.libraryKey, 855 onStart: requestCallbacks.onStart 856 ? [onStart, requestCallbacks.onStart] 857 : [onStart] 858 }); 859 return request.start(); 860 }); 861 862 863 this.stop = function () { 864 this.setSyncStatus(Zotero.getString('sync.stopping')); 865 _stopping = true; 866 if (_currentEngine) { 867 _currentEngine.stop(); 868 } 869 } 870 871 872 this.end = Zotero.Promise.coroutine(function* (options) { 873 _syncInProgress = false; 874 yield this.checkErrors(_errors, options); 875 if (!options.restartSync) { 876 this.updateIcons(_errors); 877 } 878 _errors = []; 879 }); 880 881 882 /** 883 * @param {Integer} timeout - Timeout in seconds 884 * @param {Boolean} [recurring=false] 885 * @param {Object} [options] - Sync options 886 */ 887 this.setSyncTimeout = function (timeout, recurring, options = {}) { 888 if (!Zotero.Prefs.get('sync.autoSync') || !this.enabled) { 889 return; 890 } 891 892 if (!timeout) { 893 throw new Error("Timeout not provided"); 894 } 895 896 if (_autoSyncTimer) { 897 Zotero.debug("Cancelling auto-sync timer"); 898 _autoSyncTimer.cancel(); 899 } 900 else { 901 _autoSyncTimer = Components.classes["@mozilla.org/timer;1"]. 902 createInstance(Components.interfaces.nsITimer); 903 } 904 905 var mergedOpts = { 906 background: true 907 }; 908 Object.assign(mergedOpts, options); 909 910 // Implements nsITimerCallback 911 var callback = { 912 notify: async function (timer) { 913 if (!_getAPIKey()) { 914 return; 915 } 916 917 // If a delay is set (e.g., from the connector target selector), wait to sync. 918 // We do this in sync() too for manual syncs, but no need to start spinning if 919 // it's just an auto-sync. 920 while (_delaySyncUntil && new Date() < _delaySyncUntil) { 921 let delay = _delaySyncUntil - new Date(); 922 Zotero.debug(`Waiting ${delay} ms to start auto-sync`); 923 await Zotero.Promise.delay(delay); 924 } 925 926 if (Zotero.locked) { 927 Zotero.debug('Zotero is locked -- skipping auto-sync', 4); 928 return; 929 } 930 931 if (_syncInProgress) { 932 Zotero.debug('Sync already in progress -- skipping auto-sync', 4); 933 return; 934 } 935 936 if (_manualSyncRequired) { 937 Zotero.debug('Manual sync required -- skipping auto-sync', 4); 938 return; 939 } 940 941 this.sync(mergedOpts); 942 }.bind(this) 943 } 944 945 if (recurring) { 946 Zotero.debug('Setting auto-sync interval to ' + timeout + ' seconds'); 947 _autoSyncTimer.initWithCallback( 948 callback, timeout * 1000, Components.interfaces.nsITimer.TYPE_REPEATING_SLACK 949 ); 950 } 951 else { 952 if (_syncInProgress) { 953 Zotero.debug('Sync in progress -- not setting auto-sync timeout', 4); 954 return; 955 } 956 957 Zotero.debug('Setting auto-sync timeout to ' + timeout + ' seconds'); 958 _autoSyncTimer.initWithCallback( 959 callback, timeout * 1000, Components.interfaces.nsITimer.TYPE_ONE_SHOT 960 ); 961 } 962 } 963 964 965 this.clearSyncTimeout = function () { 966 if (_autoSyncTimer) { 967 _autoSyncTimer.cancel(); 968 } 969 } 970 971 972 this.delaySync = function (ms) { 973 _delaySyncUntil = new Date(Date.now() + ms); 974 }; 975 976 977 /** 978 * Delay syncs until the returned function is called 979 * 980 * @return {Function} - Resolve function 981 */ 982 this.delayIndefinite = function () { 983 var resolve; 984 var promise = new Zotero.Promise(function () { 985 resolve = arguments[0]; 986 }); 987 _delayPromises.push(promise); 988 return resolve; 989 }; 990 991 992 /** 993 * Trigger updating of the main sync icon, the sync error icon, and 994 * library-specific sync error icons across all windows 995 */ 996 this.addError = function (e, libraryID) { 997 if (e.added) return; 998 e.added = true; 999 if (libraryID) { 1000 e.libraryID = libraryID; 1001 } 1002 Zotero.logError(e); 1003 _errors.push(this.parseError(e)); 1004 } 1005 1006 1007 this.getErrorsByLibrary = function (libraryID) { 1008 return _errors.filter(e => e.libraryID === libraryID); 1009 } 1010 1011 1012 /** 1013 * Get most severe error type from an array of parsed errors 1014 */ 1015 this.getPrimaryErrorType = function (errors) { 1016 // Set highest priority error as the primary (sync error icon) 1017 var errorTypes = { 1018 info: 1, 1019 warning: 2, 1020 error: 3, 1021 upgrade: 4, 1022 1023 // Skip these 1024 animate: -1 1025 }; 1026 var state = false; 1027 for (let i = 0; i < errors.length; i++) { 1028 let e = errors[i]; 1029 1030 let errorType = e.errorType; 1031 1032 if (e.fatal) { 1033 return 'error'; 1034 } 1035 1036 if (!errorType || errorTypes[errorType] < 0) { 1037 continue; 1038 } 1039 if (!state || errorTypes[errorType] > errorTypes[state]) { 1040 state = errorType; 1041 } 1042 } 1043 return state; 1044 } 1045 1046 1047 this.checkErrors = Zotero.Promise.coroutine(function* (errors, options = {}) { 1048 for (let e of errors) { 1049 let handled = yield this.checkError(e, options); 1050 if (handled) { 1051 break; 1052 } 1053 } 1054 }); 1055 1056 1057 this.checkError = Zotero.Promise.coroutine(function* (e, options = {}) { 1058 if (e.name && e.name == 'Zotero Error') { 1059 switch (e.error) { 1060 case Zotero.Error.ERROR_API_KEY_NOT_SET: 1061 case Zotero.Error.ERROR_API_KEY_INVALID: 1062 // TODO: the setTimeout() call below should just simulate a click on the sync error icon 1063 // instead of creating its own dialog, but updateIcons() doesn't yet provide full control 1064 // over dialog title and primary button text/action, which is why this version of the 1065 // dialog is a bit uglier than the manual click version 1066 // TODO: localize (=>done) and combine with below (=>?) 1067 var msg = Zotero.getString('sync.error.invalidLogin.text'); 1068 e.message = msg; 1069 e.dialogButtonText = Zotero.getString('sync.openSyncPreferences'); 1070 e.dialogButtonCallback = function () { 1071 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1072 .getService(Components.interfaces.nsIWindowMediator); 1073 var win = wm.getMostRecentWindow("navigator:browser"); 1074 win.ZoteroPane.openPreferences("zotero-prefpane-sync"); 1075 }; 1076 1077 // Manual click 1078 if (!options.background) { 1079 setTimeout(function () { 1080 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1081 .getService(Components.interfaces.nsIWindowMediator); 1082 var win = wm.getMostRecentWindow("navigator:browser"); 1083 1084 var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] 1085 .getService(Components.interfaces.nsIPromptService); 1086 var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) 1087 + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL); 1088 if (e.error == Zotero.Error.ERROR_API_KEY_NOT_SET) { 1089 var title = Zotero.getString('sync.error.usernameNotSet'); 1090 var msg = Zotero.getString('sync.error.usernameNotSet.text'); 1091 } 1092 else { 1093 var title = Zotero.getString('sync.error.invalidLogin'); 1094 var msg = Zotero.getString('sync.error.invalidLogin.text'); 1095 } 1096 var index = ps.confirmEx( 1097 win, 1098 title, 1099 msg, 1100 buttonFlags, 1101 Zotero.getString('sync.openSyncPreferences'), 1102 null, null, null, {} 1103 ); 1104 1105 if (index == 0) { 1106 Zotero.Utilities.Internal.openPreferences("zotero-prefpane-sync"); 1107 return; 1108 } 1109 }, 1); 1110 } 1111 break; 1112 } 1113 } 1114 else if (e.name && e.name == 'ZoteroObjectUploadError') { 1115 let { code, data, objectType, object } = e; 1116 1117 if (code == 413) { 1118 // Collection name too long 1119 if (objectType == 'collection' && data && data.value) { 1120 e.message = Zotero.getString('sync.error.collectionTooLong', [data.value]); 1121 1122 e.dialogButtonText = Zotero.getString('pane.collections.showCollectionInLibrary'); 1123 e.dialogButtonCallback = () => { 1124 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1125 .getService(Components.interfaces.nsIWindowMediator); 1126 var win = wm.getMostRecentWindow("navigator:browser"); 1127 win.ZoteroPane.collectionsView.selectCollection(object.id); 1128 }; 1129 } 1130 else if (objectType == 'item') { 1131 // Tag too long 1132 if (data && data.tag !== undefined) { 1133 // Show long tag fixer and handle result 1134 e.dialogButtonText = Zotero.getString('general.fix'); 1135 e.dialogButtonCallback = Zotero.Promise.coroutine(function* () { 1136 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1137 .getService(Components.interfaces.nsIWindowMediator); 1138 var lastWin = wm.getMostRecentWindow("navigator:browser"); 1139 1140 // Open long tag fixer for every long tag in every editable library we're syncing 1141 var editableLibraries = options.libraries 1142 .filter(x => Zotero.Libraries.get(x).editable); 1143 for (let libraryID of editableLibraries) { 1144 let oldTagIDs = yield Zotero.Tags.getLongTagsInLibrary(libraryID); 1145 for (let oldTagID of oldTagIDs) { 1146 let oldTag = Zotero.Tags.getName(oldTagID); 1147 let dataOut = { result: null }; 1148 lastWin.openDialog( 1149 'chrome://zotero/content/longTagFixer.xul', 1150 '', 1151 'chrome,modal,centerscreen', 1152 oldTag, 1153 dataOut 1154 ); 1155 // If dialog was cancelled, stop 1156 if (!dataOut.result) { 1157 return; 1158 } 1159 switch (dataOut.result.op) { 1160 case 'split': 1161 for (let libraryID of editableLibraries) { 1162 let itemIDs = yield Zotero.Tags.getTagItems(libraryID, oldTagID); 1163 yield Zotero.DB.executeTransaction(function* () { 1164 for (let itemID of itemIDs) { 1165 let item = yield Zotero.Items.getAsync(itemID); 1166 for (let tag of dataOut.result.tags) { 1167 item.addTag(tag); 1168 } 1169 item.removeTag(oldTag); 1170 yield item.save(); 1171 } 1172 yield Zotero.Tags.purge(oldTagID); 1173 }); 1174 } 1175 break; 1176 1177 case 'edit': 1178 for (let libraryID of editableLibraries) { 1179 let itemIDs = yield Zotero.Tags.getTagItems(libraryID, oldTagID); 1180 yield Zotero.DB.executeTransaction(function* () { 1181 for (let itemID of itemIDs) { 1182 let item = yield Zotero.Items.getAsync(itemID); 1183 item.replaceTag(oldTag, dataOut.result.tag); 1184 yield item.save(); 1185 } 1186 }); 1187 } 1188 break; 1189 1190 case 'delete': 1191 for (let libraryID of editableLibraries) { 1192 yield Zotero.Tags.removeFromLibrary(libraryID, oldTagID); 1193 } 1194 break; 1195 } 1196 } 1197 } 1198 1199 options.restartSync = true; 1200 }); 1201 } 1202 else { 1203 // Note too long 1204 if (object.isNote() || object.isAttachment()) { 1205 // Throw an error that adds a button for selecting the item to the sync error dialog 1206 if (e.message.includes('<img src="data:image')) { 1207 e.message = Zotero.getString('sync.error.noteEmbeddedImage'); 1208 } 1209 else if (e.message.match(/^Note '.*' too long for item/)) { 1210 e.message = Zotero.getString( 1211 'sync.error.noteTooLong', 1212 Zotero.Utilities.ellipsize(object.getNoteTitle(), 40) 1213 ); 1214 } 1215 } 1216 // Field or creator too long 1217 else if (data && data.field) { 1218 e.message = (data.field == 'creator' 1219 ? Zotero.getString( 1220 'sync.error.creatorTooLong', 1221 [data.value] 1222 ) 1223 : Zotero.getString( 1224 'sync.error.fieldTooLong', 1225 [data.field, data.value] 1226 )) 1227 + '\n\n' 1228 + Zotero.getString( 1229 'sync.error.reportSiteIssuesToForums', 1230 Zotero.clientName 1231 ); 1232 } 1233 1234 // Include "Show Item in Library" button 1235 e.dialogButtonText = Zotero.getString('pane.items.showItemInLibrary'); 1236 e.dialogButtonCallback = () => { 1237 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1238 .getService(Components.interfaces.nsIWindowMediator); 1239 var win = wm.getMostRecentWindow("navigator:browser"); 1240 win.ZoteroPane.selectItem(object.id); 1241 }; 1242 } 1243 } 1244 1245 // If not a background sync, show dialog immediately 1246 if (!options.background && e.dialogButtonCallback) { 1247 let maybePromise = e.dialogButtonCallback(); 1248 if (maybePromise && maybePromise.then) { 1249 yield maybePromise; 1250 } 1251 } 1252 } 1253 } 1254 }); 1255 1256 1257 /** 1258 * Set the sync icon and sync error icon across all windows 1259 * 1260 * @param {Error|Error[]|'animate'} errors - An error, an array of errors, or 'animate' to 1261 * spin the icon. An empty array will reset the 1262 * icons. 1263 */ 1264 this.updateIcons = function (errors) { 1265 if (typeof errors == 'string') { 1266 var state = errors; 1267 errors = []; 1268 } 1269 else { 1270 if (!Array.isArray(errors)) { 1271 errors = [errors]; 1272 } 1273 var state = this.getPrimaryErrorType(errors); 1274 } 1275 1276 // Refresh source list 1277 //yield Zotero.Notifier.trigger('redraw', 'collection', []); 1278 1279 if (errors.length == 1 && errors[0].frontWindowOnly) { 1280 // Fake an nsISimpleEnumerator with just the topmost window 1281 var enumerator = { 1282 _returned: false, 1283 hasMoreElements: function () { 1284 return !this._returned; 1285 }, 1286 getNext: function () { 1287 if (this._returned) { 1288 throw ("No more windows to return in Zotero.Sync.Runner.updateIcons()"); 1289 } 1290 this._returned = true; 1291 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1292 .getService(Components.interfaces.nsIWindowMediator); 1293 return wm.getMostRecentWindow("navigator:browser"); 1294 } 1295 }; 1296 } 1297 // Update all windows 1298 else { 1299 var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] 1300 .getService(Components.interfaces.nsIWindowMediator); 1301 var enumerator = wm.getEnumerator('navigator:browser'); 1302 } 1303 1304 while (enumerator.hasMoreElements()) { 1305 var win = enumerator.getNext(); 1306 if (!win.ZoteroPane) continue; 1307 var doc = win.ZoteroPane.document; 1308 1309 // Update sync error icon 1310 var icon = doc.getElementById('zotero-tb-sync-error'); 1311 this.updateErrorIcon(icon, state, errors); 1312 1313 // Update sync icon 1314 var syncIcon = doc.getElementById('zotero-tb-sync'); 1315 var stopIcon = doc.getElementById('zotero-tb-sync-stop'); 1316 if (state == 'animate') { 1317 syncIcon.setAttribute('status', state); 1318 // Disable button while spinning 1319 syncIcon.disabled = true; 1320 stopIcon.hidden = false; 1321 } 1322 else { 1323 syncIcon.removeAttribute('status'); 1324 syncIcon.disabled = false; 1325 stopIcon.hidden = true; 1326 } 1327 } 1328 1329 // Clear status 1330 this.setSyncStatus(); 1331 } 1332 1333 1334 /** 1335 * Set the sync icon tooltip message 1336 */ 1337 this.setSyncStatus = function (msg) { 1338 _lastSyncStatus = msg; 1339 1340 // If a label is registered, update it 1341 if (_currentSyncStatusLabel) { 1342 _updateSyncStatusLabel(); 1343 } 1344 } 1345 1346 1347 this.parseError = function (e) { 1348 if (!e) { 1349 return { parsed: true }; 1350 } 1351 1352 // Already parsed 1353 if (e.parsed) { 1354 return e; 1355 } 1356 1357 e.parsed = true; 1358 e.errorType = e.errorType ? e.errorType : 'error'; 1359 1360 return e; 1361 } 1362 1363 1364 /** 1365 * Set the state of the sync error icon and add an onclick to populate 1366 * the error panel 1367 */ 1368 this.updateErrorIcon = function (icon, state, errors) { 1369 if (!errors || !errors.length) { 1370 icon.hidden = true; 1371 icon.onclick = null; 1372 return; 1373 } 1374 1375 icon.hidden = false; 1376 icon.setAttribute('state', state); 1377 var self = this; 1378 icon.onclick = function () { 1379 var panel = self.updateErrorPanel(this.ownerDocument, errors); 1380 panel.openPopup(this, "after_end", 16, 0, false, false); 1381 }; 1382 } 1383 1384 1385 this.updateErrorPanel = function (doc, errors) { 1386 var panel = doc.getElementById('zotero-sync-error-panel'); 1387 1388 // Clear existing panel content 1389 while (panel.hasChildNodes()) { 1390 panel.removeChild(panel.firstChild); 1391 } 1392 1393 for (let e of errors) { 1394 var box = doc.createElement('vbox'); 1395 var label = doc.createElement('label'); 1396 if (e.libraryID !== undefined) { 1397 label.className = "zotero-sync-error-panel-library-name"; 1398 if (e.libraryID == 0) { 1399 var libraryName = Zotero.getString('pane.collections.library'); 1400 } 1401 else { 1402 let group = Zotero.Groups.getByLibraryID(e.libraryID); 1403 var libraryName = group.name; 1404 } 1405 label.setAttribute('value', libraryName); 1406 } 1407 var content = doc.createElement('hbox'); 1408 var buttons = doc.createElement('hbox'); 1409 buttons.pack = 'end'; 1410 box.appendChild(label); 1411 box.appendChild(content); 1412 box.appendChild(buttons); 1413 1414 // Show our own error mesages directly 1415 if (e instanceof Zotero.Error) { 1416 var msg = e.message; 1417 } 1418 // For unexpected ones, just show a generic message 1419 else { 1420 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 1421 msg = Zotero.Utilities.ellipsize(e.xmlhttp.responseText, 1000, true); 1422 } 1423 else { 1424 // TODO: Localize 1425 var msg = "An error occurred during syncing:\n\n" + e.message; 1426 } 1427 } 1428 1429 var desc = doc.createElement('description'); 1430 desc.textContent = msg; 1431 // Make the text selectable 1432 desc.setAttribute('style', '-moz-user-select: text; cursor: text'); 1433 content.appendChild(desc); 1434 1435 /*// If not an error and there's no explicit button text, don't show 1436 // button to report errors 1437 if (e.errorType != 'error' && e.dialogButtonText === undefined) { 1438 e.dialogButtonText = null; 1439 }*/ 1440 1441 if (e.dialogButtonText !== null) { 1442 if (e.dialogButtonText === undefined) { 1443 var buttonText = Zotero.getString('errorReport.reportError'); 1444 var buttonCallback = function () { 1445 doc.defaultView.ZoteroPane.reportErrors(); 1446 }; 1447 } 1448 else { 1449 var buttonText = e.dialogButtonText; 1450 var buttonCallback = e.dialogButtonCallback; 1451 } 1452 1453 var button = doc.createElement('button'); 1454 button.setAttribute('label', buttonText); 1455 button.onclick = function () { 1456 buttonCallback(); 1457 panel.hidePopup(); 1458 }; 1459 buttons.appendChild(button); 1460 } 1461 1462 panel.appendChild(box) 1463 break; 1464 } 1465 1466 return panel; 1467 } 1468 1469 1470 /** 1471 * Register labels in sync icon tooltip to receive updates 1472 * 1473 * If no label passed, unregister current label 1474 * 1475 * @param {Tooltip} [tooltip] 1476 */ 1477 this.registerSyncStatus = function (tooltip) { 1478 if (tooltip) { 1479 _currentSyncStatusLabel = tooltip.firstChild.nextSibling; 1480 _currentLastSyncLabel = tooltip.firstChild.nextSibling.nextSibling; 1481 } 1482 else { 1483 _currentSyncStatusLabel = null; 1484 _currentLastSyncLabel = null; 1485 } 1486 if (_currentSyncStatusLabel) { 1487 _updateSyncStatusLabel(); 1488 } 1489 } 1490 1491 1492 this.createAPIKeyFromCredentials = Zotero.Promise.coroutine(function* (username, password) { 1493 var client = this.getAPIClient(); 1494 var json = yield client.createAPIKeyFromCredentials(username, password); 1495 if (!json) { 1496 return false; 1497 } 1498 1499 // Sanity check 1500 if (!json.userID) throw new Error("userID not found in key response"); 1501 if (!json.username) throw new Error("username not found in key response"); 1502 if (!json.access) throw new Error("'access' not found in key response"); 1503 1504 Zotero.Sync.Data.Local.setAPIKey(json.key); 1505 1506 return json; 1507 }) 1508 1509 1510 this.deleteAPIKey = Zotero.Promise.coroutine(function* (){ 1511 var apiKey = yield Zotero.Sync.Data.Local.getAPIKey(); 1512 var client = this.getAPIClient({apiKey}); 1513 Zotero.Sync.Data.Local.setAPIKey(); 1514 yield client.deleteAPIKey(); 1515 }) 1516 1517 1518 function _updateSyncStatusLabel() { 1519 if (_lastSyncStatus) { 1520 _currentSyncStatusLabel.value = _lastSyncStatus; 1521 _currentSyncStatusLabel.hidden = false; 1522 } 1523 else { 1524 _currentSyncStatusLabel.hidden = true; 1525 } 1526 1527 // Always update last sync time 1528 var lastSyncTime = Zotero.Sync.Data.Local.getLastSyncTime(); 1529 if (!lastSyncTime) { 1530 try { 1531 lastSyncTime = Zotero.Sync.Data.Local.getLastClassicSyncTime(); 1532 } 1533 catch (e) { 1534 Zotero.debug(e, 2); 1535 Components.utils.reportError(e); 1536 _currentLastSyncLabel.hidden = true; 1537 return; 1538 } 1539 } 1540 if (lastSyncTime) { 1541 var msg = Zotero.Date.toRelativeDate(lastSyncTime); 1542 } 1543 // Don't show "Not yet synced" if a sync is in progress 1544 else if (_syncInProgress) { 1545 _currentLastSyncLabel.hidden = true; 1546 return; 1547 } 1548 else { 1549 var msg = Zotero.getString('sync.status.notYetSynced'); 1550 } 1551 1552 _currentLastSyncLabel.value = Zotero.getString('sync.status.lastSync') + " " + msg; 1553 _currentLastSyncLabel.hidden = false; 1554 } 1555 1556 1557 var _getAPIKey = Zotero.Promise.method(function () { 1558 // Set as .apiKey on Runner in tests or set in login manager 1559 return _apiKey || Zotero.Sync.Data.Local.getAPIKey() 1560 }) 1561 1562 1563 function _stopCheck() { 1564 if (_stopping) { 1565 throw new Zotero.Sync.UserCancelledException; 1566 } 1567 } 1568 }