library.js (20350B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2015 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 Zotero.Library = function(params = {}) { 27 let objectType = this._objectType; 28 this._ObjectType = Zotero.Utilities.capitalize(objectType); 29 this._objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); 30 this._ObjectTypePlural = Zotero.Utilities.capitalize(this._objectTypePlural); 31 32 this._changed = {}; 33 34 this._dataLoaded = {}; 35 this._dataLoadedDeferreds = {}; 36 37 this._hasCollections = null; 38 this._hasSearches = null; 39 this._storageDownloadNeeded = false; 40 41 Zotero.Utilities.assignProps( 42 this, 43 params, 44 [ 45 'libraryType', 46 'editable', 47 'filesEditable', 48 'libraryVersion', 49 'storageVersion', 50 'lastSync', 51 'archived' 52 ] 53 ); 54 55 // Return a proxy so that we can disable the object once it's deleted 56 return new Proxy(this, { 57 get: function(obj, prop) { 58 if (obj._disabled && !(prop == 'libraryID' || prop == 'id')) { 59 throw new Error("Library (" + obj.libraryID + ") has been disabled"); 60 } 61 return obj[prop]; 62 } 63 }); 64 }; 65 66 /** 67 * Non-prototype properties 68 */ 69 // DB columns 70 Zotero.defineProperty(Zotero.Library, '_dbColumns', { 71 value: Object.freeze([ 72 'type', 'editable', 'filesEditable', 'version', 'storageVersion', 'lastSync', 'archived' 73 ]) 74 }); 75 76 // Converts DB column name to (internal) object property 77 Zotero.Library._colToProp = function(c) { 78 return "_library" + Zotero.Utilities.capitalize(c); 79 } 80 81 // Select all columns in a unique manner, so we can JOIN tables with same column names (e.g. version) 82 Zotero.defineProperty(Zotero.Library, '_rowSQLSelect', { 83 value: "L.libraryID, " + Zotero.Library._dbColumns.map(c => "L." + c + " AS " + Zotero.Library._colToProp(c)).join(", ") 84 + ", (SELECT COUNT(*)>0 FROM collections C WHERE C.libraryID=L.libraryID) AS hasCollections" 85 + ", (SELECT COUNT(*)>0 FROM savedSearches S WHERE S.libraryID=L.libraryID) AS hasSearches" 86 }); 87 88 // The actual select statement for above columns 89 Zotero.defineProperty(Zotero.Library, '_rowSQL', { 90 value: "SELECT " + Zotero.Library._rowSQLSelect + " FROM libraries L" 91 }); 92 93 /** 94 * Prototype properties 95 */ 96 Zotero.defineProperty(Zotero.Library.prototype, '_objectType', { 97 value: 'library' 98 }); 99 100 Zotero.defineProperty(Zotero.Library.prototype, '_childObjectTypes', { 101 value: Object.freeze(['item', 'collection', 'search']) 102 }); 103 104 // Valid library types 105 Zotero.defineProperty(Zotero.Library.prototype, 'libraryTypes', { 106 value: Object.freeze(['user']) 107 }); 108 109 // Immutable libraries 110 Zotero.defineProperty(Zotero.Library.prototype, 'fixedLibraries', { 111 value: Object.freeze(['user']) 112 }); 113 114 Zotero.defineProperty(Zotero.Library.prototype, 'libraryID', { 115 get: function() { return this._libraryID; }, 116 set: function(id) { throw new Error("Cannot change library ID"); } 117 }); 118 119 Zotero.defineProperty(Zotero.Library.prototype, 'id', { 120 get: function() { return this.libraryID; }, 121 set: function(val) { return this.libraryID = val; } 122 }); 123 124 Zotero.defineProperty(Zotero.Library.prototype, 'libraryType', { 125 get: function() { return this._get('_libraryType'); }, 126 set: function(v) { return this._set('_libraryType', v); } 127 }); 128 129 /** 130 * Get the library-type-specific id for the library (e.g., userID for user library, 131 * groupID for group library) 132 * 133 * @property 134 */ 135 Zotero.defineProperty(Zotero.Library.prototype, 'libraryTypeID', { 136 get: function () { 137 switch (this._libraryType) { 138 case 'user': 139 return Zotero.Users.getCurrentUserID(); 140 141 case 'group': 142 return Zotero.Groups.getGroupIDFromLibraryID(this._libraryID); 143 144 default: 145 throw new Error(`Tried to get library type id for ${this._libraryType} library`); 146 } 147 } 148 }); 149 150 Zotero.defineProperty(Zotero.Library.prototype, 'libraryVersion', { 151 get: function() { return this._get('_libraryVersion'); }, 152 set: function(v) { return this._set('_libraryVersion', v); } 153 }); 154 155 156 Zotero.defineProperty(Zotero.Library.prototype, 'syncable', { 157 get: function () { return this._libraryType != 'feed'; } 158 }); 159 160 161 Zotero.defineProperty(Zotero.Library.prototype, 'lastSync', { 162 get: function() { return this._get('_libraryLastSync'); } 163 }); 164 165 166 Zotero.defineProperty(Zotero.Library.prototype, 'name', { 167 get: function() { 168 if (this._libraryType == 'user') { 169 return Zotero.getString('pane.collections.library'); 170 } 171 172 // This property is provided by the extending objects (Group, Feed) for other library types 173 throw new Error('Unhandled library type "' + this._libraryType + '"'); 174 } 175 }); 176 177 Zotero.defineProperty(Zotero.Library.prototype, 'treeViewID', { 178 get: function () { 179 return "L" + this._libraryID 180 } 181 }); 182 183 Zotero.defineProperty(Zotero.Library.prototype, 'treeViewImage', { 184 get: function () { 185 return "chrome://zotero/skin/treesource-library" + Zotero.hiDPISuffix + ".png"; 186 } 187 }); 188 189 Zotero.defineProperty(Zotero.Library.prototype, 'hasTrash', { 190 value: true 191 }); 192 193 Zotero.defineProperty(Zotero.Library.prototype, 'allowsLinkedFiles', { 194 value: true 195 }); 196 197 // Create other accessors 198 (function() { 199 let accessors = ['editable', 'filesEditable', 'storageVersion', 'archived']; 200 for (let i=0; i<accessors.length; i++) { 201 let prop = Zotero.Library._colToProp(accessors[i]); 202 Zotero.defineProperty(Zotero.Library.prototype, accessors[i], { 203 get: function() { return this._get(prop); }, 204 set: function(v) { return this._set(prop, v); } 205 }) 206 } 207 })() 208 209 Zotero.defineProperty(Zotero.Library.prototype, 'storageDownloadNeeded', { 210 get: function () { return this._storageDownloadNeeded; }, 211 set: function (val) { this._storageDownloadNeeded = !!val; }, 212 }) 213 214 Zotero.Library.prototype._isValidProp = function(prop) { 215 let prefix = '_library'; 216 if (prop.indexOf(prefix) !== 0 || prop.length == prefix.length) { 217 return false; 218 } 219 220 let col = prop.substr(prefix.length); 221 col = col.charAt(0).toLowerCase() + col.substr(1); 222 223 return Zotero.Library._dbColumns.indexOf(col) != -1; 224 } 225 226 Zotero.Library.prototype._get = function(prop) { 227 if (!this._isValidProp(prop)) { 228 throw new Error('Unknown property "' + prop + '"'); 229 } 230 231 return this[prop]; 232 } 233 234 Zotero.Library.prototype._set = function(prop, val) { 235 if (!this._isValidProp(prop)) { 236 throw new Error('Unknown property "' + prop + '"'); 237 } 238 239 // Ensure proper format 240 switch(prop) { 241 case '_libraryType': 242 if (this.libraryTypes.indexOf(val) == -1) { 243 throw new Error('Invalid library type "' + val + '"'); 244 } 245 246 if (this.libraryID !== undefined) { 247 throw new Error("Library type cannot be changed for a saved library"); 248 } 249 250 if (this.fixedLibraries.indexOf(val) != -1) { 251 throw new Error('Cannot create library of type "' + val + '"'); 252 } 253 break; 254 255 case '_libraryEditable': 256 case '_libraryFilesEditable': 257 if (['user'].indexOf(this._libraryType) != -1) { 258 throw new Error('Cannot change ' + prop + ' for ' + this._libraryType + ' library'); 259 } 260 val = !!val; 261 262 // Setting 'editable' to false should also set 'filesEditable' to false 263 if (prop == '_libraryEditable' && !val) { 264 this._set('_libraryFilesEditable', false); 265 } 266 break; 267 268 case '_libraryVersion': 269 var newVal = Number.parseInt(val, 10); 270 if (newVal != val) { 271 throw new Error(`${prop} must be an integer (${typeof val} '${val}' given)`); 272 } 273 val = newVal 274 275 // Allow -1 to indicate that a full sync is needed 276 if (val < -1) throw new Error(prop + ' must not be less than -1'); 277 278 // Ensure that it is never decreasing, unless it is being set to -1 279 if (val != -1 && val < this._libraryVersion) throw new Error(prop + ' cannot decrease'); 280 281 break; 282 283 case '_libraryStorageVersion': 284 var newVal = parseInt(val); 285 if (newVal != val) { 286 throw new Error(`${prop} must be an integer (${typeof val} '${val}' given)`); 287 } 288 val = newVal; 289 290 // Ensure that it is never decreasing 291 if (val < this._libraryStorageVersion) throw new Error(prop + ' cannot decrease'); 292 break; 293 294 case '_libraryLastSync': 295 if (!val) { 296 val = false; 297 } else if (!(val instanceof Date)) { 298 throw new Error(prop + ' must be a Date object or falsy'); 299 } else { 300 // Storing to DB will drop milliseconds, so, for consistency, we drop it now 301 val = new Date(Math.floor(val.getTime()/1000) * 1000); 302 } 303 break; 304 305 case '_libraryArchived': 306 if (['user', 'feeds'].indexOf(this._libraryType) != -1) { 307 throw new Error('Cannot change ' + prop + ' for ' + this._libraryType + ' library'); 308 } 309 if (val && this._libraryEditable) { 310 throw new Error('Cannot set editable library as archived'); 311 } 312 val = !!val; 313 break; 314 } 315 316 if (this[prop] == val) return; // Unchanged 317 318 if (this._changed[prop]) { 319 // Catch attempts to re-set already set fields before saving 320 Zotero.debug('Warning: Attempting to set unsaved ' + this._objectType + ' property "' + prop + '"', 2, true); 321 } 322 323 this._changed[prop] = true; 324 this[prop] = val; 325 } 326 327 Zotero.Library.prototype._loadDataFromRow = function(row) { 328 if (this._libraryID !== undefined && this._libraryID !== row.libraryID) { 329 Zotero.debug("Warning: library ID changed in Zotero.Library._loadDataFromRow", 2, true); 330 } 331 332 this._libraryID = row.libraryID; 333 this._libraryType = row._libraryType; 334 335 this._libraryEditable = !!row._libraryEditable; 336 this._libraryFilesEditable = !!row._libraryFilesEditable; 337 this._libraryVersion = row._libraryVersion; 338 this._libraryStorageVersion = row._libraryStorageVersion; 339 this._libraryLastSync = row._libraryLastSync !== 0 ? new Date(row._libraryLastSync * 1000) : false; 340 this._libraryArchived = !!row._libraryArchived; 341 342 this._hasCollections = !!row.hasCollections; 343 this._hasSearches = !!row.hasSearches; 344 345 this._changed = {}; 346 } 347 348 Zotero.Library.prototype._reloadFromDB = Zotero.Promise.coroutine(function* () { 349 let sql = Zotero.Library._rowSQL + ' WHERE libraryID=?'; 350 let row = yield Zotero.DB.rowQueryAsync(sql, [this.libraryID]); 351 this._loadDataFromRow(row); 352 }); 353 354 /** 355 * Load object data in this library 356 */ 357 Zotero.Library.prototype.loadAllDataTypes = Zotero.Promise.coroutine(function* () { 358 yield Zotero.SyncedSettings.loadAll(this.libraryID); 359 yield Zotero.Collections.loadAll(this.libraryID); 360 yield Zotero.Searches.loadAll(this.libraryID); 361 yield Zotero.Items.loadAll(this.libraryID); 362 }); 363 364 // 365 // Methods to handle promises that are resolved when object data is loaded for the library 366 // 367 Zotero.Library.prototype.getDataLoaded = function (objectType) { 368 return this._dataLoaded[objectType] || null; 369 }; 370 371 Zotero.Library.prototype.setDataLoading = function (objectType) { 372 if (this._dataLoadedDeferreds[objectType]) { 373 throw new Error("Items already loading for library " + this.libraryID); 374 } 375 this._dataLoadedDeferreds[objectType] = Zotero.Promise.defer(); 376 }; 377 378 Zotero.Library.prototype.getDataLoadedPromise = function (objectType) { 379 return this._dataLoadedDeferreds[objectType] 380 ? this._dataLoadedDeferreds[objectType].promise : null; 381 }; 382 383 Zotero.Library.prototype.setDataLoaded = function (objectType) { 384 this._dataLoaded[objectType] = true; 385 this._dataLoadedDeferreds[objectType].resolve(); 386 }; 387 388 /** 389 * Wait for a given data type to load, loading it now if necessary 390 */ 391 Zotero.Library.prototype.waitForDataLoad = Zotero.Promise.coroutine(function* (objectType) { 392 if (this.getDataLoaded(objectType)) return; 393 394 let promise = this.getDataLoadedPromise(objectType); 395 // If items are already being loaded, wait for them 396 if (promise) { 397 yield promise; 398 } 399 // Otherwise load them now 400 else { 401 let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); 402 yield objectsClass.loadAll(this.libraryID); 403 } 404 }); 405 406 Zotero.Library.prototype.isChildObjectAllowed = function(type) { 407 return this._childObjectTypes.indexOf(type) != -1; 408 }; 409 410 Zotero.Library.prototype.updateLastSyncTime = function() { 411 this._set('_libraryLastSync', new Date()); 412 }; 413 414 Zotero.Library.prototype.saveTx = function(options) { 415 options = options || {}; 416 options.tx = true; 417 return this.save(options); 418 } 419 420 Zotero.Library.prototype.save = Zotero.Promise.coroutine(function* (options) { 421 options = options || {}; 422 var env = { 423 options: options, 424 transactionOptions: options.transactionOptions || {} 425 }; 426 427 if (!env.options.tx && !Zotero.DB.inTransaction()) { 428 Zotero.logError("save() called on Zotero.Library without a wrapping " 429 + "transaction -- use saveTx() instead", 2, true); 430 env.options.tx = true; 431 } 432 433 var proceed = yield this._initSave(env) 434 .catch(Zotero.Promise.coroutine(function* (e) { 435 if (!env.isNew && Zotero.Libraries.exists(this.libraryID)) { 436 // Reload from DB and reset this._changed, so this is not a permanent failure 437 yield this._reloadFromDB(); 438 } 439 throw e; 440 }).bind(this)); 441 442 if (!proceed) return false; 443 444 if (env.isNew) { 445 Zotero.debug('Saving data for new ' + this._objectType + ' to database', 4); 446 } 447 else { 448 Zotero.debug('Updating database with new ' + this._objectType + ' data', 4); 449 } 450 451 try { 452 env.notifierData = {}; 453 if (env.options.skipSelect) { 454 env.notifierData.skipSelect = true; 455 } 456 457 // Create transaction 458 if (env.options.tx) { 459 return Zotero.DB.executeTransaction(function* () { 460 yield this._saveData(env); 461 yield this._finalizeSave(env); 462 }.bind(this), env.transactionOptions); 463 } 464 // Use existing transaction 465 else { 466 Zotero.DB.requireTransaction(); 467 yield this._saveData(env); 468 yield this._finalizeSave(env); 469 } 470 } catch(e) { 471 Zotero.debug(e, 1); 472 throw e; 473 } 474 }); 475 476 Zotero.Library.prototype._initSave = Zotero.Promise.method(function(env) { 477 if (this._libraryID === undefined) { 478 env.isNew = true; 479 480 if (!this._libraryType) { 481 throw new Error("libraryType must be set before saving"); 482 } 483 484 if (typeof this._libraryEditable != 'boolean') { 485 throw new Error("editable must be set before saving"); 486 } 487 488 if (typeof this._libraryFilesEditable != 'boolean') { 489 throw new Error("filesEditable must be set before saving"); 490 } 491 } else { 492 Zotero.Libraries._ensureExists(this._libraryID); 493 494 if (!Object.keys(this._changed).length) { 495 Zotero.debug(`No data changed in ${this._objectType} ${this.id} -- not saving`, 4); 496 return false; 497 } 498 } 499 500 return true; 501 }); 502 503 Zotero.Library.prototype._saveData = Zotero.Promise.coroutine(function* (env) { 504 // Collect changed columns 505 let changedCols = [], 506 params = []; 507 for (let i=0; i<Zotero.Library._dbColumns.length; i++) { 508 let col = Zotero.Library._dbColumns[i]; 509 let prop = Zotero.Library._colToProp(col); 510 511 if (this._changed[prop]) { 512 changedCols.push(col); 513 514 let val = this[prop]; 515 if (col == 'lastSync') { 516 // convert to integer 517 val = val ? Math.floor(val.getTime() / 1000) : 0; 518 } 519 else if (typeof val == 'boolean') { 520 val = val ? 1 : 0; 521 } 522 523 params.push(val); 524 } 525 } 526 527 if (env.isNew) { 528 let id = Zotero.ID.get('libraries'); 529 changedCols.unshift('libraryID'); 530 params.unshift(id); 531 532 let sql = "INSERT INTO libraries (" + changedCols.join(", ") + ") " 533 + "VALUES (" + Array(params.length).fill("?").join(", ") + ")"; 534 yield Zotero.DB.queryAsync(sql, params); 535 536 this._libraryID = id; 537 } else if (changedCols.length) { 538 params.push(this.libraryID); 539 let sql = "UPDATE libraries SET " + changedCols.map(v => v + "=?").join(", ") 540 + " WHERE libraryID=?"; 541 yield Zotero.DB.queryAsync(sql, params); 542 543 // Since these are Zotero.Library properties, the 'modify' for the inheriting object may not 544 // get triggered, so call it here too 545 if (!env.options.skipNotifier && this.libraryType != 'user') { 546 Zotero.Notifier.queue('modify', this.libraryType, this.libraryTypeID); 547 } 548 } else { 549 Zotero.debug("Library data did not change for " + this._objectType + " " + this.id, 5); 550 } 551 }); 552 553 Zotero.Library.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) { 554 this._changed = {}; 555 556 if (env.isNew) { 557 // Re-fetch from DB to get auto-filled defaults 558 yield this._reloadFromDB(); 559 560 Zotero.Libraries.register(this); 561 562 yield this.loadAllDataTypes(); 563 } 564 }); 565 566 Zotero.Library.prototype.eraseTx = function(options) { 567 options = options || {}; 568 options.tx = true; 569 return this.erase(options); 570 }; 571 572 Zotero.Library.prototype.erase = Zotero.Promise.coroutine(function* (options) { 573 options = options || {}; 574 var env = { 575 options: options, 576 transactionOptions: options.transactionOptions || {} 577 }; 578 579 if (!env.options.tx && !Zotero.DB.inTransaction()) { 580 Zotero.logError("erase() called on Zotero." + this._ObjectType + " without a wrapping " 581 + "transaction -- use eraseTx() instead"); 582 Zotero.debug((new Error).stack, 2); 583 env.options.tx = true; 584 } 585 586 var proceed = yield this._initErase(env); 587 if (!proceed) return false; 588 589 Zotero.debug('Deleting ' + this._objectType + ' ' + this.id); 590 591 try { 592 env.notifierData = {}; 593 594 if (env.options.tx) { 595 yield Zotero.DB.executeTransaction(function* () { 596 yield this._eraseData(env); 597 yield this._finalizeErase(env); 598 }.bind(this), env.transactionOptions); 599 } else { 600 Zotero.DB.requireTransaction(); 601 yield this._eraseData(env); 602 yield this._finalizeErase(env); 603 } 604 } catch(e) { 605 Zotero.debug(e, 1); 606 throw e; 607 } 608 }); 609 610 Zotero.Library.prototype._initErase = Zotero.Promise.method(function(env) { 611 if (this.libraryID === undefined) { 612 throw new Error("Attempting to erase an unsaved library"); 613 } 614 615 Zotero.Libraries._ensureExists(this.libraryID); 616 617 if (this.fixedLibraries.indexOf(this._libraryType) != -1) { 618 throw new Error("Cannot erase library of type '" + this._libraryType + "'"); 619 } 620 621 return true; 622 }); 623 624 Zotero.Library.prototype._eraseData = Zotero.Promise.coroutine(function* (env) { 625 yield Zotero.DB.queryAsync("DELETE FROM libraries WHERE libraryID=?", this.libraryID); 626 // TODO: Emit event so this doesn't have to be here 627 yield Zotero.Fulltext.clearLibraryVersion(this.libraryID); 628 }); 629 630 Zotero.Library.prototype._finalizeErase = Zotero.Promise.coroutine(function* (env) { 631 Zotero.Libraries.unregister(this.libraryID); 632 633 // Clear cached child objects 634 for (let i=0; i<this._childObjectTypes.length; i++) { 635 let type = this._childObjectTypes[i]; 636 Zotero.DataObjectUtilities.getObjectsClassForObjectType(type) 637 .dropDeadObjectsFromCache(); 638 } 639 640 this._disabled = true; 641 }); 642 643 Zotero.Library.prototype.hasCollections = function () { 644 if (this._hasCollections === null) { 645 throw new Error("Collection data has not been loaded"); 646 } 647 648 return this._hasCollections; 649 } 650 651 Zotero.Library.prototype.updateCollections = Zotero.Promise.coroutine(function* () { 652 let sql = 'SELECT COUNT(*)>0 FROM collections WHERE libraryID=?'; 653 this._hasCollections = !!(yield Zotero.DB.valueQueryAsync(sql, this.libraryID)); 654 }); 655 656 Zotero.Library.prototype.hasSearches = function () { 657 if (this._hasSearches === null) { 658 throw new Error("Saved search data has not been loaded"); 659 } 660 661 return this._hasSearches; 662 } 663 664 Zotero.Library.prototype.updateSearches = Zotero.Promise.coroutine(function* () { 665 let sql = 'SELECT COUNT(*)>0 FROM savedSearches WHERE libraryID=?'; 666 this._hasSearches = !!(yield Zotero.DB.valueQueryAsync(sql, this.libraryID)); 667 }); 668 669 Zotero.Library.prototype.hasItems = Zotero.Promise.coroutine(function* () { 670 if (!this.id) { 671 throw new Error("Library is not saved yet"); 672 } 673 let sql = 'SELECT COUNT(*)>0 FROM items WHERE libraryID=?'; 674 // Don't count old <=4.0 Quick Start Guide items 675 if (this.libraryID == Zotero.Libraries.userLibraryID) { 676 sql += "AND key NOT IN ('ABCD2345', 'ABCD3456')"; 677 } 678 return !!(yield Zotero.DB.valueQueryAsync(sql, this.libraryID)); 679 }); 680 681 Zotero.Library.prototype.hasItem = function (item) { 682 if (!(item instanceof Zotero.Item)) { 683 throw new Error("item must be a Zotero.Item"); 684 } 685 return item.libraryID == this.libraryID; 686 }