item.js (125952B)
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 27 /* 28 * Constructor for Item object 29 */ 30 Zotero.Item = function(itemTypeOrID) { 31 if (arguments[1] || arguments[2]) { 32 throw ("Zotero.Item constructor only takes one parameter"); 33 } 34 35 Zotero.Item._super.apply(this); 36 37 this._disabled = false; 38 39 // loadPrimaryData (additional properties in dataObject.js) 40 this._itemTypeID = null; 41 this._firstCreator = null; 42 this._sortCreator = null; 43 this._attachmentCharset = null; 44 this._attachmentLinkMode = null; 45 this._attachmentContentType = null; 46 this._attachmentPath = null; 47 this._attachmentSyncState = 0; 48 this._attachmentSyncedModificationTime = null; 49 this._attachmentSyncedHash = null; 50 51 // loadCreators 52 this._creators = []; 53 this._creatorIDs = []; 54 55 // loadItemData 56 this._itemData = null; 57 this._noteTitle = null; 58 this._noteText = null; 59 this._displayTitle = null; 60 61 // loadChildItems 62 this._attachments = null; 63 this._notes = null; 64 65 this._tags = []; 66 this._collections = []; 67 68 this._bestAttachmentState = null; 69 this._fileExists = null; 70 71 this._deleted = null; 72 this._hasNote = null; 73 74 this._noteAccessTime = null; 75 76 if (itemTypeOrID) { 77 // setType initializes type-specific properties in this._itemData 78 this.setType(Zotero.ItemTypes.getID(itemTypeOrID)); 79 } 80 } 81 82 Zotero.extendClass(Zotero.DataObject, Zotero.Item); 83 84 Zotero.Item.prototype._objectType = 'item'; 85 Zotero.defineProperty(Zotero.Item.prototype, 'ContainerObjectsClass', { 86 get: function() { return Zotero.Collections; } 87 }); 88 89 Zotero.Item.prototype._dataTypes = Zotero.Item._super.prototype._dataTypes.concat([ 90 'creators', 91 'itemData', 92 'note', 93 'childItems', 94 // 'relatedItems', // TODO: remove 95 'tags', 96 'collections', 97 'relations' 98 ]); 99 100 Zotero.defineProperty(Zotero.Item.prototype, 'id', { 101 get: function() { return this._id; }, 102 set: function(val) { return this.setField('id', val); } 103 }); 104 Zotero.defineProperty(Zotero.Item.prototype, 'itemID', { 105 get: function() { 106 Zotero.debug("Item.itemID is deprecated -- use Item.id"); 107 return this._id; 108 }, 109 enumerable: false 110 }); 111 Zotero.defineProperty(Zotero.Item.prototype, 'libraryID', { 112 get: function() { return this._libraryID; }, 113 set: function(val) { return this.setField('libraryID', val); } 114 }); 115 Zotero.defineProperty(Zotero.Item.prototype, 'key', { 116 get: function() { return this._key; }, 117 set: function(val) { return this.setField('key', val); } 118 }); 119 Zotero.defineProperty(Zotero.Item.prototype, 'itemTypeID', { 120 get: function() { return this._itemTypeID; } 121 }); 122 Zotero.defineProperty(Zotero.Item.prototype, 'dateAdded', { 123 get: function() { return this._dateAdded; }, 124 set: function(val) { return this.setField('dateAdded', val); } 125 }); 126 Zotero.defineProperty(Zotero.Item.prototype, 'dateModified', { 127 get: function() { return this._dateModified; }, 128 set: function(val) { return this.setField('dateModified', val); } 129 }); 130 Zotero.defineProperty(Zotero.Item.prototype, 'version', { 131 get: function() { return this._version; }, 132 set: function(val) { return this.setField('version', val); } 133 }); 134 Zotero.defineProperty(Zotero.Item.prototype, 'synced', { 135 get: function() { return this._synced; }, 136 set: function(val) { return this.setField('synced', val); } 137 }); 138 139 // .parentKey and .parentID defined in dataObject.js, but create aliases 140 Zotero.defineProperty(Zotero.Item.prototype, 'parentItemID', { 141 get: function() { return this.parentID; }, 142 set: function(val) { return this.parentID = val; } 143 }); 144 Zotero.defineProperty(Zotero.Item.prototype, 'parentItemKey', { 145 get: function() { return this.parentKey; }, 146 set: function(val) { return this.parentKey = val; } 147 }); 148 Zotero.defineProperty(Zotero.Item.prototype, 'parentItem', { 149 get: function() { return Zotero.Items.get(this.parentID) || undefined; }, 150 }); 151 152 153 Zotero.defineProperty(Zotero.Item.prototype, 'firstCreator', { 154 get: function() { return this._firstCreator; } 155 }); 156 Zotero.defineProperty(Zotero.Item.prototype, 'sortCreator', { 157 get: function() { return this._sortCreator; } 158 }); 159 Zotero.defineProperty(Zotero.Item.prototype, 'relatedItems', { 160 get: function() { return this._getRelatedItems(); } 161 }); 162 163 Zotero.defineProperty(Zotero.Item.prototype, 'treeViewID', { 164 get: function () { 165 return this.id 166 } 167 }); 168 169 Zotero.Item.prototype.getID = function() { 170 Zotero.debug('Item.getID() is deprecated -- use Item.id'); 171 return this._id; 172 } 173 174 Zotero.Item.prototype.getType = function() { 175 Zotero.debug('Item.getType() is deprecated -- use Item.itemTypeID'); 176 return this._itemTypeID; 177 } 178 179 Zotero.Item.prototype.isPrimaryField = function (fieldName) { 180 Zotero.debug("Zotero.Item.isPrimaryField() is deprecated -- use Zotero.Items.isPrimaryField()"); 181 return this.ObjectsClass.isPrimaryField(fieldName); 182 } 183 184 Zotero.Item.prototype._get = function () { 185 throw new Error("_get is not valid for items"); 186 } 187 188 Zotero.Item.prototype._set = function () { 189 throw new Error("_set is not valid for items"); 190 } 191 192 Zotero.Item.prototype._setParentKey = function() { 193 if (!this.isNote() && !this.isAttachment()) { 194 throw new Error("_setParentKey() can only be called on items of type 'note' or 'attachment'"); 195 } 196 197 Zotero.Item._super.prototype._setParentKey.apply(this, arguments); 198 } 199 200 ////////////////////////////////////////////////////////////////////////////// 201 // 202 // Public Zotero.Item methods 203 // 204 ////////////////////////////////////////////////////////////////////////////// 205 /* 206 * Retrieves an itemData field value 207 * 208 * @param {String|Integer} field fieldID or fieldName 209 * @param {Boolean} [unformatted] Skip any special processing of DB value 210 * (e.g. multipart date field) 211 * @param {Boolean} includeBaseMapped If true and field is a base field, returns 212 * value of type-specific field instead 213 * (e.g. 'label' for 'publisher' in 'audioRecording') 214 * @return {String} Value as string or empty string if value is not present 215 */ 216 Zotero.Item.prototype.getField = function(field, unformatted, includeBaseMapped) { 217 if (field != 'id') this._disabledCheck(); 218 219 //Zotero.debug('Requesting field ' + field + ' for item ' + this._id, 4); 220 221 this._requireData('primaryData'); 222 223 // TODO: Add sortCreator 224 if (field === 'firstCreator' && !this._id) { 225 // Hack to get a firstCreator for an unsaved item 226 let creatorsData = this.getCreators(true); 227 return Zotero.Items.getFirstCreatorFromData(this.itemTypeID, creatorsData); 228 } else if (field === 'id' || this.ObjectsClass.isPrimaryField(field)) { 229 var privField = '_' + field; 230 //Zotero.debug('Returning ' + (this[privField] ? this[privField] : '') + ' (typeof ' + typeof this[privField] + ')'); 231 return this[privField]; 232 } else if (field == 'year') { 233 return this.getField('date', true, true).substr(0,4); 234 } 235 236 if (this.isNote()) { 237 switch (Zotero.ItemFields.getName(field)) { 238 case 'title': 239 return this.getNoteTitle(); 240 241 default: 242 return ''; 243 } 244 } 245 246 if (includeBaseMapped) { 247 var fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase( 248 this._itemTypeID, field 249 ); 250 } 251 252 if (!fieldID) { 253 var fieldID = Zotero.ItemFields.getID(field); 254 } 255 256 let value = this._itemData[fieldID]; 257 258 if (value === undefined) { 259 //Zotero.debug("Field '" + field + "' doesn't exist for item type " + this._itemTypeID + " in Item.getField()"); 260 return ''; 261 } 262 263 // If the item is identified (has an id or key), this field has to be populated 264 if (this._identified && value === null && !this._loaded.itemData) { 265 throw new Zotero.Exception.UnloadedDataException( 266 "Item data not loaded and field '" + field + "' not set for item " + this.libraryKey, 267 "itemData" 268 ); 269 } 270 271 value = (value !== null && value !== false) ? value : ''; 272 273 if (!unformatted) { 274 // Multipart date fields 275 // TEMP - filingDate 276 if (Zotero.ItemFields.isFieldOfBase(fieldID, 'date') || field == 'filingDate') { 277 value = Zotero.Date.multipartToStr(value); 278 } 279 } 280 //Zotero.debug('Returning ' + value); 281 return value; 282 } 283 284 285 /** 286 * @param {Boolean} asNames 287 * @return {Integer[]|String[]} 288 */ 289 Zotero.Item.prototype.getUsedFields = function(asNames) { 290 this._requireData('itemData'); 291 292 return Object.keys(this._itemData) 293 .filter(id => this._itemData[id] !== false && this._itemData[id] !== null) 294 .map(id => asNames ? Zotero.ItemFields.getName(id) : parseInt(id)); 295 }; 296 297 298 299 /* 300 * Populate basic item data from a database row 301 */ 302 Zotero.Item.prototype.loadFromRow = function(row, reload) { 303 // If necessary or reloading, set the type and reinitialize this._itemData 304 if (reload || (!this._itemTypeID && row.itemTypeID)) { 305 this.setType(row.itemTypeID, true); 306 } 307 308 this._parseRowData(row); 309 this._finalizeLoadFromRow(row); 310 } 311 312 Zotero.Item.prototype._parseRowData = function(row) { 313 var primaryFields = this.ObjectsClass.primaryFields; 314 for (let i=0; i<primaryFields.length; i++) { 315 let col = primaryFields[i]; 316 317 try { 318 var val = row[col]; 319 } 320 catch (e) { 321 Zotero.debug('Skipping missing field ' + col); 322 continue; 323 } 324 325 //Zotero.debug("Setting field '" + col + "' to '" + val + "' for item " + this.id); 326 327 switch (col) { 328 // Unchanged 329 case 'libraryID': 330 case 'itemTypeID': 331 case 'attachmentSyncState': 332 case 'attachmentSyncedHash': 333 case 'attachmentSyncedModificationTime': 334 break; 335 336 case 'itemID': 337 col = 'id'; 338 break; 339 340 // Integer or 0 341 case 'version': 342 val = val ? parseInt(val) : 0; 343 break; 344 345 // Value or false 346 case 'parentKey': 347 val = val || false; 348 break; 349 350 // Integer or false if falsy 351 case 'parentID': 352 val = val ? parseInt(val) : false; 353 break; 354 355 case 'attachmentLinkMode': 356 val = val !== null 357 ? parseInt(val) 358 // Shouldn't happen 359 : Zotero.Attachments.LINK_MODE_IMPORTED_URL; 360 break; 361 362 case 'attachmentPath': 363 // Ignore .zotero* files that were relinked before we started blocking them 364 if (!val || val.startsWith('.zotero')) { 365 val = ''; 366 } 367 break; 368 369 // Boolean 370 case 'synced': 371 case 'deleted': 372 case 'inPublications': 373 val = !!val; 374 break; 375 376 default: 377 val = val ? val : ''; 378 } 379 380 this['_' + col] = val; 381 } 382 } 383 384 Zotero.Item.prototype._finalizeLoadFromRow = function(row) { 385 this._loaded.primaryData = true; 386 this._clearChanged('primaryData'); 387 this._clearChanged('attachmentData'); 388 this._identified = true; 389 } 390 391 392 /* 393 * Set or change the item's type 394 */ 395 Zotero.Item.prototype.setType = function(itemTypeID, loadIn) { 396 if (itemTypeID == this._itemTypeID) { 397 return true; 398 } 399 400 // Adjust 'note' data type based on whether the item is an attachment or note 401 var isAttachment = Zotero.ItemTypes.getID('attachment') == itemTypeID; 402 var isNote = Zotero.ItemTypes.getID('note') == itemTypeID; 403 this._skipDataTypeLoad.note = !(isAttachment || isNote); 404 405 var oldItemTypeID = this._itemTypeID; 406 if (oldItemTypeID) { 407 if (loadIn) { 408 throw new Error('Cannot change type in loadIn mode'); 409 } 410 411 // Changing the item type can affect fields and creators, so they need to be loaded 412 this._requireData('itemData'); 413 this._requireData('creators'); 414 415 var copiedFields = []; 416 var newNotifierFields = []; 417 418 // Special cases handled below 419 var bookTypeID = Zotero.ItemTypes.getID('book'); 420 var bookSectionTypeID = Zotero.ItemTypes.getID('bookSection'); 421 422 var obsoleteFields = this.getFieldsNotInType(itemTypeID); 423 if (obsoleteFields) { 424 // Move bookTitle to title and clear short title when going from 425 // bookSection to book if there's not also a title 426 if (oldItemTypeID == bookSectionTypeID && itemTypeID == bookTypeID) { 427 var titleFieldID = Zotero.ItemFields.getID('title'); 428 var bookTitleFieldID = Zotero.ItemFields.getID('bookTitle'); 429 var shortTitleFieldID = Zotero.ItemFields.getID('shortTitle'); 430 if (this._itemData[bookTitleFieldID] && !this._itemData[titleFieldID]) { 431 copiedFields.push([titleFieldID, this._itemData[bookTitleFieldID]]); 432 newNotifierFields.push(titleFieldID); 433 if (this._itemData[shortTitleFieldID]) { 434 this.setField(shortTitleFieldID, false); 435 } 436 } 437 } 438 439 for (let oldFieldID of obsoleteFields) { 440 // Try to get a base type for this field 441 var baseFieldID = 442 Zotero.ItemFields.getBaseIDFromTypeAndField(oldItemTypeID, oldFieldID); 443 444 if (baseFieldID) { 445 var newFieldID = 446 Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, baseFieldID); 447 448 // If so, save value to copy to new field 449 if (newFieldID) { 450 copiedFields.push([newFieldID, this.getField(oldFieldID)]); 451 } 452 } 453 454 // Clear old field 455 /* 456 delete this._itemData[oldFieldID]; 457 if (!this._changed.itemData) { 458 this._changed.itemData = {}; 459 } 460 this._changed.itemData[oldFieldID] = true; 461 */ 462 this.setField(oldFieldID, false); 463 } 464 } 465 466 // Move title to bookTitle and clear shortTitle when going from book to bookSection 467 if (oldItemTypeID == bookTypeID && itemTypeID == bookSectionTypeID) { 468 var titleFieldID = Zotero.ItemFields.getID('title'); 469 var bookTitleFieldID = Zotero.ItemFields.getID('bookTitle'); 470 var shortTitleFieldID = Zotero.ItemFields.getID('shortTitle'); 471 if (this._itemData[titleFieldID]) { 472 copiedFields.push([bookTitleFieldID, this._itemData[titleFieldID]]); 473 newNotifierFields.push(bookTitleFieldID); 474 this.setField(titleFieldID, false); 475 } 476 if (this._itemData[shortTitleFieldID]) { 477 this.setField(shortTitleFieldID, false); 478 } 479 } 480 481 for (var fieldID in this._itemData) { 482 if (this._itemData[fieldID] && 483 (!obsoleteFields || obsoleteFields.indexOf(fieldID) == -1)) { 484 copiedFields.push([fieldID, this.getField(fieldID)]); 485 } 486 } 487 } 488 489 this._itemTypeID = itemTypeID; 490 491 // If there's an existing type 492 if (oldItemTypeID) { 493 // Reset custom creator types to the default 494 let creators = this.getCreators(); 495 if (creators.length) { 496 let removeAll = !Zotero.CreatorTypes.itemTypeHasCreators(itemTypeID); 497 for (let i=0; i<creators.length; i++) { 498 // Remove all creators if new item type doesn't have any 499 if (removeAll) { 500 this.removeCreator(i); 501 continue; 502 } 503 504 if (!Zotero.CreatorTypes.isValidForItemType(creators[i].creatorTypeID, itemTypeID)) { 505 // Convert existing primary creator type to new item type's 506 // primary creator type, or contributor (creatorTypeID 2) 507 // if none or not currently primary 508 let oldPrimary = Zotero.CreatorTypes.getPrimaryIDForType(oldItemTypeID); 509 let newPrimary = false; 510 if (oldPrimary == creators[i].creatorTypeID) { 511 newPrimary = Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID); 512 } 513 creators[i].creatorTypeID = newPrimary ? newPrimary : 2; 514 515 this.setCreator(i, creators[i]); 516 } 517 } 518 } 519 } 520 521 // Initialize this._itemData with type-specific fields 522 this._itemData = {}; 523 var fields = Zotero.ItemFields.getItemTypeFields(itemTypeID); 524 for (let fieldID of fields) { 525 this._itemData[fieldID] = null; 526 } 527 528 // DEBUG: clear change item data? 529 530 if (copiedFields) { 531 for (let f of copiedFields) { 532 // For fields that we moved to different fields in the new type 533 // (e.g., book -> bookTitle), mark the old value as explicitly 534 // false in previousData (since otherwise it would be null) 535 if (newNotifierFields.indexOf(f[0]) != -1) { 536 this._markFieldChange(Zotero.ItemFields.getName(f[0]), false); 537 this.setField(f[0], f[1]); 538 } 539 // For fields that haven't changed, clear from previousData 540 // after setting 541 else { 542 this.setField(f[0], f[1]); 543 this._clearFieldChange(Zotero.ItemFields.getName(f[0])); 544 } 545 } 546 } 547 548 if (loadIn) { 549 this._loaded['itemData'] = false; 550 } 551 else { 552 if (oldItemTypeID) { 553 this._markFieldChange('itemType', Zotero.ItemTypes.getName(oldItemTypeID)); 554 } 555 if (!this._changed.primaryData) { 556 this._changed.primaryData = {}; 557 } 558 this._changed.primaryData.itemTypeID = true; 559 } 560 561 return true; 562 } 563 564 565 /* 566 * Find existing fields from current type that aren't in another 567 * 568 * If _allowBaseConversion_, don't return fields that can be converted 569 * via base fields (e.g. label => publisher => studio) 570 */ 571 Zotero.Item.prototype.getFieldsNotInType = function (itemTypeID, allowBaseConversion) { 572 var fieldIDs = []; 573 574 for (var field in this._itemData) { 575 if (this._itemData[field]) { 576 var fieldID = Zotero.ItemFields.getID(field); 577 if (Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) { 578 continue; 579 } 580 581 if (allowBaseConversion) { 582 var baseID = Zotero.ItemFields.getBaseIDFromTypeAndField(this.itemTypeID, field); 583 if (baseID) { 584 var newFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, baseID); 585 if (newFieldID) { 586 continue; 587 } 588 } 589 } 590 591 fieldIDs.push(fieldID); 592 } 593 } 594 /* 595 var sql = "SELECT fieldID FROM itemTypeFields WHERE itemTypeID=?1 AND " 596 + "fieldID IN (SELECT fieldID FROM itemData WHERE itemID=?2) AND " 597 + "fieldID NOT IN (SELECT fieldID FROM itemTypeFields WHERE itemTypeID=?3)"; 598 599 if (allowBaseConversion) { 600 // Not the type-specific field for a base field in the new type 601 sql += " AND fieldID NOT IN (SELECT fieldID FROM baseFieldMappings " 602 + "WHERE itemTypeID=?1 AND baseFieldID IN " 603 + "(SELECT fieldID FROM itemTypeFields WHERE itemTypeID=?3)) AND "; 604 // And not a base field with a type-specific field in the new type 605 sql += "fieldID NOT IN (SELECT baseFieldID FROM baseFieldMappings " 606 + "WHERE itemTypeID=?3) AND "; 607 // And not the type-specific field for a base field that has 608 // a type-specific field in the new type 609 sql += "fieldID NOT IN (SELECT fieldID FROM baseFieldMappings " 610 + "WHERE itemTypeID=?1 AND baseFieldID IN " 611 + "(SELECT baseFieldID FROM baseFieldMappings WHERE itemTypeID=?3))"; 612 } 613 614 return Zotero.DB.columnQuery(sql, [this.itemTypeID, this.id, { int: itemTypeID }]); 615 */ 616 if (!fieldIDs.length) { 617 return false; 618 } 619 620 return fieldIDs; 621 } 622 623 624 /* 625 * Set a field value, loading existing itemData first if necessary 626 * 627 * Field can be passed as fieldID or fieldName 628 */ 629 Zotero.Item.prototype.setField = function(field, value, loadIn) { 630 this._disabledCheck(); 631 632 if (value === undefined) { 633 throw new Error(`'${field}' value cannot be undefined`); 634 } 635 636 // Normalize values 637 if (typeof value == 'number') { 638 value = "" + value; 639 } 640 else if (typeof value == 'string') { 641 value = value.trim().normalize(); 642 } 643 if (value === "" || value === null || value === false) { 644 value = false; 645 } 646 647 //Zotero.debug("Setting field '" + field + "' to '" + value + "' (loadIn: " + (loadIn ? 'true' : 'false') + ") for item " + this.id + " "); 648 649 if (!field) { 650 throw new Error("Field not specified"); 651 } 652 653 if (field == 'id' || field == 'libraryID' || field == 'key') { 654 return this._setIdentifier(field, value); 655 } 656 657 // Primary field 658 if (this.ObjectsClass.isPrimaryField(field)) { 659 this._requireData('primaryData'); 660 661 if (loadIn) { 662 throw new Error('Cannot set primary field ' + field + ' in loadIn mode in Zotero.Item.setField()'); 663 } 664 665 switch (field) { 666 case 'itemTypeID': 667 break; 668 669 case 'dateAdded': 670 case 'dateModified': 671 // Accept ISO dates 672 if (Zotero.Date.isISODate(value)) { 673 let d = Zotero.Date.isoToDate(value); 674 value = Zotero.Date.dateToSQL(d, true); 675 } 676 677 // Make sure it's valid 678 let date = Zotero.Date.sqlToDate(value, true); 679 if (!date) throw new Error("Invalid SQL date: " + value); 680 681 value = Zotero.Date.dateToSQL(date, true); 682 break; 683 684 case 'version': 685 value = parseInt(value); 686 break; 687 688 case 'synced': 689 value = !!value; 690 break; 691 692 default: 693 throw new Error('Primary field ' + field + ' cannot be changed in Zotero.Item.setField()'); 694 695 } 696 697 /* 698 if (!Zotero.ItemFields.validate(field, value)) { 699 throw("Value '" + value + "' of type " + typeof value + " does not validate for field '" + field + "' in Zotero.Item.setField()"); 700 } 701 */ 702 703 // If field value has changed 704 if (this['_' + field] === value) { 705 if (field == 'synced') { 706 Zotero.debug("Setting synced to " + value); 707 } 708 else { 709 Zotero.debug("Field '" + field + "' has not changed", 4); 710 return false; 711 } 712 } 713 else { 714 Zotero.debug("Field '" + field + "' has changed from '" + this['_' + field] + "' to '" + value + "'", 4); 715 } 716 717 // Save a copy of the field before modifying 718 this._markFieldChange(field, this['_' + field]); 719 720 if (field == 'itemTypeID') { 721 this.setType(value, loadIn); 722 } 723 else { 724 725 this['_' + field] = value; 726 727 if (!this._changed.primaryData) { 728 this._changed.primaryData = {}; 729 } 730 this._changed.primaryData[field] = true; 731 } 732 return true; 733 } 734 735 if (!loadIn) { 736 this._requireData('itemData'); 737 } 738 739 let itemTypeID = this.itemTypeID; 740 if (!itemTypeID) { 741 throw new Error('Item type must be set before setting field data'); 742 } 743 744 var fieldID = Zotero.ItemFields.getID(field); 745 if (!fieldID) { 746 throw new Error('"' + field + '" is not a valid itemData field'); 747 } 748 749 if (loadIn && this.isNote() && field == 110) { // title 750 this._noteTitle = value ? value : ""; 751 return true; 752 } 753 754 // Make sure to use type-specific field ID if available 755 fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID) || fieldID; 756 757 if (value !== false && !Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) { 758 var msg = "'" + field + "' is not a valid field for type " + itemTypeID; 759 760 if (loadIn) { 761 Zotero.debug(msg + " -- ignoring value '" + value + "'", 2); 762 return false; 763 } 764 else { 765 throw new Error(msg); 766 } 767 } 768 769 // If not a multiline field, strip newlines 770 if (typeof value == 'string' && !Zotero.ItemFields.isMultiline(fieldID)) { 771 value = value.replace(/[\r\n]+/g, " ");; 772 } 773 774 if (fieldID == Zotero.ItemFields.getID('ISBN')) { 775 // Hyphenate ISBNs, but only if everything is in expected format and valid 776 let isbns = ('' + value).trim().split(/\s*[,;]\s*|\s+/), 777 newISBNs = '', 778 failed = false; 779 for (let i=0; i<isbns.length; i++) { 780 let isbn = Zotero.Utilities.Internal.hyphenateISBN(isbns[i]); 781 if (!isbn) { 782 failed = true; 783 break; 784 } 785 786 newISBNs += ' ' + isbn; 787 } 788 789 if (!failed) value = newISBNs.substr(1); 790 } 791 792 if (!loadIn) { 793 // Save date field as multipart date 794 // TEMP - filingDate 795 if (value !== false 796 && (Zotero.ItemFields.isFieldOfBase(fieldID, 'date') || field == 'filingDate') 797 && !Zotero.Date.isMultipart(value)) { 798 value = Zotero.Date.strToMultipart(value); 799 } 800 // Validate access date 801 else if (fieldID == Zotero.ItemFields.getID('accessDate')) { 802 if (value && value != 'CURRENT_TIMESTAMP') { 803 // Accept ISO dates 804 if (Zotero.Date.isISODate(value) && !Zotero.Date.isSQLDate(value)) { 805 let d = Zotero.Date.isoToDate(value); 806 value = Zotero.Date.dateToSQL(d, true); 807 } 808 809 if (!Zotero.Date.isSQLDate(value) && !Zotero.Date.isSQLDateTime(value)) { 810 Zotero.logError(`Discarding invalid ${Zotero.ItemFields.getName(field)} '${value}' ` 811 + `for item ${this.libraryKey} in setField()`); 812 return false; 813 } 814 } 815 } 816 817 // If existing value, make sure it's actually changing 818 if ((this._itemData[fieldID] === null && value === false) 819 || (this._itemData[fieldID] !== null && this._itemData[fieldID] === value)) { 820 return false; 821 } 822 823 // Save a copy of the field before modifying 824 this._markFieldChange( 825 Zotero.ItemFields.getName(field), this._itemData[fieldID] 826 ); 827 } 828 829 this._itemData[fieldID] = value; 830 831 if (!loadIn) { 832 if (!this._changed.itemData) { 833 this._changed.itemData = {}; 834 } 835 this._changed.itemData[fieldID] = true; 836 } 837 return true; 838 } 839 840 /* 841 * Get the title for an item for display in the interface 842 * 843 * This is the same as the standard title field (with includeBaseMapped on) 844 * except for letters and interviews, which get placeholder titles in 845 * square braces (e.g. "[Letter to Thoreau]"), and cases 846 */ 847 Zotero.Item.prototype.getDisplayTitle = function (includeAuthorAndDate) { 848 if (this._displayTitle !== null) { 849 return this._displayTitle; 850 } 851 return this._displayTitle = this.getField('title', false, true); 852 } 853 854 855 /** 856 * Update the generated display title from the loaded data 857 */ 858 Zotero.Item.prototype.updateDisplayTitle = function () { 859 var title = this.getField('title', false, true); 860 var itemTypeID = this.itemTypeID; 861 var itemTypeName = Zotero.ItemTypes.getName(itemTypeID); 862 863 if (title === "" && (itemTypeID == 8 || itemTypeID == 10)) { // 'letter' and 'interview' itemTypeIDs 864 var creatorsData = this.getCreators(); 865 var authors = []; 866 var participants = []; 867 for (let i=0; i<creatorsData.length; i++) { 868 let creatorData = creatorsData[i]; 869 let creatorTypeID = creatorsData[i].creatorTypeID; 870 if ((itemTypeID == 8 && creatorTypeID == 16) || // 'letter' 871 (itemTypeID == 10 && creatorTypeID == 7)) { // 'interview' 872 participants.push(creatorData); 873 } 874 else if ((itemTypeID == 8 && creatorTypeID == 1) || // 'letter'/'author' 875 (itemTypeID == 10 && creatorTypeID == 6)) { // 'interview'/'interviewee' 876 authors.push(creatorData); 877 } 878 } 879 880 var strParts = []; 881 if (participants.length > 0) { 882 let names = []; 883 let max = Math.min(4, participants.length); 884 for (let i=0; i<max; i++) { 885 names.push( 886 participants[i].name !== undefined 887 ? participants[i].name 888 : participants[i].lastName 889 ); 890 } 891 switch (names.length) { 892 case 1: 893 var str = 'oneParticipant'; 894 break; 895 896 case 2: 897 var str = 'twoParticipants'; 898 break; 899 900 case 3: 901 var str = 'threeParticipants'; 902 break; 903 904 default: 905 var str = 'manyParticipants'; 906 } 907 strParts.push(Zotero.getString('pane.items.' + itemTypeName + '.' + str, names)); 908 } 909 else { 910 strParts.push(Zotero.ItemTypes.getLocalizedString(itemTypeID)); 911 } 912 913 title = '[' + strParts.join('; ') + ']'; 914 } 915 else if (itemTypeID == 17) { // 'case' itemTypeID 916 if (title) { // common law cases always have case names 917 var reporter = this.getField('reporter'); 918 if (reporter) { 919 title = title + ' (' + reporter + ')'; 920 } else { 921 var court = this.getField('court'); 922 if (court) { 923 title = title + ' (' + court + ')'; 924 } 925 } 926 } 927 else { // civil law cases have only shortTitle as case name 928 var strParts = []; 929 var caseinfo = ""; 930 931 var part = this.getField('court'); 932 if (part) { 933 strParts.push(part); 934 } 935 936 part = Zotero.Date.multipartToSQL(this.getField('date', true, true)); 937 if (part) { 938 strParts.push(part); 939 } 940 941 var creatorData = this.getCreator(0); 942 if (creatorData && creatorData.creatorTypeID === 1) { // author 943 strParts.push(creatorData.lastName); 944 } 945 946 title = '[' + strParts.join(', ') + ']'; 947 } 948 } 949 950 this._displayTitle = title; 951 }; 952 953 954 /* 955 * Returns the number of creators for this item 956 */ 957 Zotero.Item.prototype.numCreators = function() { 958 this._requireData('creators'); 959 return this._creators.length; 960 } 961 962 963 Zotero.Item.prototype.hasCreatorAt = function(pos) { 964 this._requireData('creators'); 965 return !!this._creators[pos]; 966 } 967 968 969 /** 970 * @param {Integer} pos 971 * @return {Object|Boolean} The internal creator data object at the given position, or FALSE if none 972 */ 973 Zotero.Item.prototype.getCreator = function (pos) { 974 this._requireData('creators'); 975 if (!this._creators[pos]) { 976 return false; 977 } 978 var creator = {}; 979 for (let i in this._creators[pos]) { 980 creator[i] = this._creators[pos][i]; 981 } 982 return creator; 983 } 984 985 986 /** 987 * @param {Integer} pos 988 * @return {Object|Boolean} The API JSON creator data at the given position, or FALSE if none 989 */ 990 Zotero.Item.prototype.getCreatorJSON = function (pos) { 991 this._requireData('creators'); 992 return this._creators[pos] ? Zotero.Creators.internalToJSON(this._creators[pos]) : false; 993 } 994 995 996 /** 997 * Returns creator data in internal format 998 * 999 * @return {Array<Object>} An array of internal creator data objects 1000 * ('firstName', 'lastName', 'fieldMode', 'creatorTypeID') 1001 */ 1002 Zotero.Item.prototype.getCreators = function () { 1003 this._requireData('creators'); 1004 // Create copies of the creator data objects 1005 return this._creators.map(function (data) { 1006 var creator = {}; 1007 for (let i in data) { 1008 creator[i] = data[i]; 1009 } 1010 return creator; 1011 }); 1012 } 1013 1014 1015 /** 1016 * @return {Array<Object>} An array of creator data objects in API JSON format 1017 * ('firstName'/'lastName' or 'name', 'creatorType') 1018 */ 1019 Zotero.Item.prototype.getCreatorsJSON = function () { 1020 this._requireData('creators'); 1021 return this._creators.map(data => Zotero.Creators.internalToJSON(data)); 1022 } 1023 1024 1025 /** 1026 * Set or update the creator at the specified position 1027 * 1028 * @param {Integer} orderIndex 1029 * @param {Object} Creator data in internal or API JSON format: 1030 * <ul> 1031 * <li>'name' or 'firstName'/'lastName', or 'firstName'/'lastName'/'fieldMode'</li> 1032 * <li>'creatorType' (can be name or id) or 'creatorTypeID'</li> 1033 * </ul> 1034 */ 1035 Zotero.Item.prototype.setCreator = function (orderIndex, data) { 1036 var itemTypeID = this._itemTypeID; 1037 if (!itemTypeID) { 1038 throw new Error('Item type must be set before setting creators'); 1039 } 1040 1041 this._requireData('creators'); 1042 1043 data = Zotero.Creators.cleanData(data); 1044 1045 if (data.creatorTypeID === undefined) { 1046 throw new Error("Creator data must include a valid 'creatorType' or 'creatorTypeID' property"); 1047 } 1048 1049 // If creatorTypeID isn't valid for this type, use the primary type 1050 if (!data.creatorTypeID || !Zotero.CreatorTypes.isValidForItemType(data.creatorTypeID, itemTypeID)) { 1051 var msg = "Creator type '" + Zotero.CreatorTypes.getName(data.creatorTypeID) + "' " 1052 + "isn't valid for " + Zotero.ItemTypes.getName(itemTypeID) 1053 + " -- changing to primary creator"; 1054 Zotero.warn(msg); 1055 data.creatorTypeID = Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID); 1056 } 1057 1058 // If creator at this position hasn't changed, cancel 1059 let previousData = this._creators[orderIndex]; 1060 if (previousData 1061 && previousData.creatorTypeID === data.creatorTypeID 1062 && previousData.fieldMode === data.fieldMode 1063 && previousData.firstName === data.firstName 1064 && previousData.lastName === data.lastName) { 1065 Zotero.debug("Creator in position " + orderIndex + " hasn't changed", 4); 1066 return false; 1067 } 1068 1069 // Save copy of old creators for save() and notifier 1070 if (!this._changed.creators) { 1071 this._changed.creators = {}; 1072 this._markFieldChange('creators', this._getOldCreators()); 1073 } 1074 this._changed.creators[orderIndex] = true; 1075 this._creators[orderIndex] = data; 1076 return true; 1077 } 1078 1079 1080 /** 1081 * @param {Object[]} data - An array of creator data in internal or API JSON format 1082 */ 1083 Zotero.Item.prototype.setCreators = function (data) { 1084 // If empty array, clear all existing creators 1085 if (!data.length) { 1086 while (this.hasCreatorAt(0)) { 1087 this.removeCreator(0); 1088 } 1089 return; 1090 } 1091 1092 for (let i = 0; i < data.length; i++) { 1093 this.setCreator(i, data[i]); 1094 } 1095 } 1096 1097 1098 /* 1099 * Remove a creator and shift others down 1100 */ 1101 Zotero.Item.prototype.removeCreator = function(orderIndex, allowMissing) { 1102 var creatorData = this.getCreator(orderIndex); 1103 if (!creatorData && !allowMissing) { 1104 throw new Error('No creator exists at position ' + orderIndex); 1105 } 1106 1107 // Save copy of old creators for notifier 1108 if (!this._changed.creators) { 1109 this._changed.creators = {}; 1110 1111 var oldCreators = this._getOldCreators(); 1112 this._markFieldChange('creators', oldCreators); 1113 } 1114 1115 // Shift creator orderIndexes down, going to length+1 so we clear the last one 1116 for (var i=orderIndex, max=this._creators.length+1; i<max; i++) { 1117 var next = this._creators[i+1] ? this._creators[i+1] : false; 1118 if (next) { 1119 this._creators[i] = next; 1120 } 1121 else { 1122 this._creators.splice(i, 1); 1123 } 1124 1125 this._changed.creators[i] = true; 1126 } 1127 1128 return true; 1129 } 1130 1131 1132 // Define boolean properties 1133 for (let name of ['deleted', 'inPublications']) { 1134 let prop = '_' + name; 1135 Zotero.defineProperty(Zotero.Item.prototype, name, { 1136 get: function() { 1137 if (!this.id) { 1138 return false; 1139 } 1140 if (this[prop] !== null) { 1141 return this[prop]; 1142 } 1143 this._requireData('primaryData'); 1144 }, 1145 set: function(val) { 1146 val = !!val; 1147 1148 if (this[prop] == val) { 1149 Zotero.debug(Zotero.Utilities.capitalize(name) 1150 + " state hasn't changed for item " + this.id); 1151 return; 1152 } 1153 this._markFieldChange(name, !!this[prop]); 1154 this._changed[name] = true; 1155 this[prop] = val; 1156 } 1157 }); 1158 } 1159 1160 1161 /** 1162 * Relate this item to another. A separate save is required. 1163 * 1164 * @param {Zotero.Item} 1165 * @return {Boolean} 1166 */ 1167 Zotero.Item.prototype.addRelatedItem = function (item) { 1168 if (!(item instanceof Zotero.Item)) { 1169 throw new Error("'item' must be a Zotero.Item"); 1170 } 1171 1172 if (item == this) { 1173 Zotero.debug("Can't relate item to itself in Zotero.Item.addRelatedItem()", 2); 1174 return false; 1175 } 1176 1177 if (!this.libraryID) { 1178 this.libraryID = Zotero.Libraries.userLibraryID; 1179 } 1180 1181 if (item.libraryID != this.libraryID) { 1182 throw new Error("Cannot relate item to an item in a different library"); 1183 } 1184 1185 return this.addRelation(Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(item)); 1186 } 1187 1188 1189 /** 1190 * @param {Zotero.Item} 1191 */ 1192 Zotero.Item.prototype.removeRelatedItem = Zotero.Promise.coroutine(function* (item) { 1193 if (!(item instanceof Zotero.Item)) { 1194 throw new Error("'item' must be a Zotero.Item"); 1195 } 1196 1197 return this.removeRelation(Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(item)); 1198 }); 1199 1200 1201 Zotero.Item.prototype.isEditable = function() { 1202 var editable = Zotero.Item._super.prototype.isEditable.apply(this); 1203 if (!editable) return false; 1204 1205 // Check if we're allowed to save attachments 1206 if (this.isAttachment() 1207 && (this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL || 1208 this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) 1209 && !Zotero.Libraries.get(this.libraryID).filesEditable 1210 ) { 1211 return false; 1212 } 1213 1214 return true; 1215 } 1216 1217 Zotero.Item.prototype._initSave = Zotero.Promise.coroutine(function* (env) { 1218 if (!this.itemTypeID) { 1219 throw new Error("Item type must be set before saving"); 1220 } 1221 return Zotero.Item._super.prototype._initSave.apply(this, arguments); 1222 }) 1223 1224 Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) { 1225 Zotero.DB.requireTransaction(); 1226 1227 var isNew = env.isNew; 1228 var options = env.options; 1229 var libraryType = env.libraryType = Zotero.Libraries.get(env.libraryID).libraryType; 1230 1231 var itemTypeID = this.itemTypeID; 1232 1233 var reloadParentChildItems = {}; 1234 1235 // 1236 // Primary fields 1237 // 1238 // If available id value, use it -- otherwise we'll use autoincrement 1239 var itemID = this._id = this.id ? this.id : Zotero.ID.get('items'); 1240 1241 if (this._changed.primaryData && this._changed.primaryData.itemTypeID) { 1242 env.sqlColumns.push('itemTypeID'); 1243 env.sqlValues.push({ int: itemTypeID }); 1244 } 1245 1246 // If a new item and Date Modified hasn't been provided, or an existing item and 1247 // Date Modified hasn't changed from its previous value and skipDateModifiedUpdate wasn't 1248 // passed, use the current timestamp 1249 if (!this.dateModified 1250 || ((!this._changed.primaryData || !this._changed.primaryData.dateModified) 1251 && !options.skipDateModifiedUpdate)) { 1252 env.sqlColumns.push('dateModified'); 1253 env.sqlValues.push(Zotero.DB.transactionDateTime); 1254 } 1255 // Otherwise, if a new Date Modified was provided, use that. (This would also work when 1256 // skipDateModifiedUpdate was passed and there's an existing value, but in that case we 1257 // can just not change the field at all.) 1258 else if (this._changed.primaryData && this._changed.primaryData.dateModified) { 1259 env.sqlColumns.push('dateModified'); 1260 env.sqlValues.push(this.dateModified); 1261 } 1262 1263 if (env.sqlColumns.length) { 1264 if (isNew) { 1265 env.sqlColumns.push('dateAdded'); 1266 env.sqlValues.push(this.dateAdded ? this.dateAdded : Zotero.DB.transactionDateTime); 1267 1268 env.sqlColumns.unshift('itemID'); 1269 env.sqlValues.unshift(parseInt(itemID)); 1270 1271 let sql = "INSERT INTO items (" + env.sqlColumns.join(", ") + ") " 1272 + "VALUES (" + env.sqlValues.map(() => "?").join() + ")"; 1273 yield Zotero.DB.queryAsync(sql, env.sqlValues); 1274 1275 if (!env.options.skipNotifier) { 1276 Zotero.Notifier.queue('add', 'item', itemID, env.notifierData, env.options.notifierQueue); 1277 } 1278 } 1279 else { 1280 let sql = "UPDATE items SET " + env.sqlColumns.join("=?, ") + "=? WHERE itemID=?"; 1281 env.sqlValues.push(parseInt(itemID)); 1282 yield Zotero.DB.queryAsync(sql, env.sqlValues); 1283 1284 if (!env.options.skipNotifier) { 1285 Zotero.Notifier.queue('modify', 'item', itemID, env.notifierData, env.options.notifierQueue); 1286 } 1287 } 1288 } 1289 1290 // 1291 // ItemData 1292 // 1293 if (this._changed.itemData) { 1294 let del = []; 1295 1296 let valueSQL = "SELECT valueID FROM itemDataValues WHERE value=?"; 1297 let insertValueSQL = "INSERT INTO itemDataValues VALUES (?,?)"; 1298 let replaceSQL = "REPLACE INTO itemData VALUES (?,?,?)"; 1299 1300 for (let fieldID in this._changed.itemData) { 1301 fieldID = parseInt(fieldID); 1302 let value = this.getField(fieldID, true); 1303 1304 // If field changed and is empty, mark row for deletion 1305 if (value === '') { 1306 del.push(fieldID); 1307 continue; 1308 } 1309 1310 if (Zotero.ItemFields.getID('accessDate') == fieldID 1311 && (this.getField(fieldID)) == 'CURRENT_TIMESTAMP') { 1312 value = Zotero.DB.transactionDateTime; 1313 } 1314 1315 let valueID = yield Zotero.DB.valueQueryAsync(valueSQL, [value], { debug: true }) 1316 if (!valueID) { 1317 valueID = Zotero.ID.get('itemDataValues'); 1318 yield Zotero.DB.queryAsync(insertValueSQL, [valueID, value], { debug: false }); 1319 } 1320 1321 yield Zotero.DB.queryAsync(replaceSQL, [itemID, fieldID, valueID], { debug: false }); 1322 } 1323 1324 // Delete blank fields 1325 if (del.length) { 1326 sql = 'DELETE from itemData WHERE itemID=? AND ' 1327 + 'fieldID IN (' + del.map(() => '?').join() + ')'; 1328 yield Zotero.DB.queryAsync(sql, [itemID].concat(del)); 1329 } 1330 } 1331 1332 // 1333 // Creators 1334 // 1335 if (this._changed.creators) { 1336 for (let orderIndex in this._changed.creators) { 1337 orderIndex = parseInt(orderIndex); 1338 1339 if (isNew) { 1340 Zotero.debug('Adding creator in position ' + orderIndex, 4); 1341 } 1342 else { 1343 Zotero.debug('Creator ' + orderIndex + ' has changed', 4); 1344 } 1345 1346 let creatorData = this.getCreator(orderIndex); 1347 // If no creator in this position, just remove the item-creator association 1348 if (!creatorData) { 1349 let sql = "DELETE FROM itemCreators WHERE itemID=? AND orderIndex=?"; 1350 yield Zotero.DB.queryAsync(sql, [itemID, orderIndex]); 1351 Zotero.Prefs.set('purge.creators', true); 1352 continue; 1353 } 1354 1355 let previousCreatorID = !isNew && this._previousData.creators[orderIndex] 1356 ? this._previousData.creators[orderIndex].id 1357 : false; 1358 let newCreatorID = yield Zotero.Creators.getIDFromData(creatorData, true); 1359 1360 // If there was previously a creator at this position and it's different from 1361 // the new one, the old one might need to be purged. 1362 if (previousCreatorID && previousCreatorID != newCreatorID) { 1363 Zotero.Prefs.set('purge.creators', true); 1364 } 1365 1366 let sql = "INSERT OR REPLACE INTO itemCreators " 1367 + "(itemID, creatorID, creatorTypeID, orderIndex) VALUES (?, ?, ?, ?)"; 1368 yield Zotero.DB.queryAsync( 1369 sql, 1370 [ 1371 itemID, 1372 newCreatorID, 1373 creatorData.creatorTypeID, 1374 orderIndex 1375 ] 1376 ); 1377 } 1378 } 1379 1380 // Parent item (DB update is done below after collection removals) 1381 var parentItemKey = this.parentKey; 1382 var parentItemID = parentItemKey 1383 ? (this.ObjectsClass.getIDFromLibraryAndKey(this.libraryID, parentItemKey) || null) 1384 : null; 1385 if (this._changed.parentKey) { 1386 if (isNew) { 1387 if (!parentItemID) { 1388 // TODO: clear caches? 1389 let msg = "Parent item " + this.libraryID + "/" + parentItemKey + " not found"; 1390 let e = new Error(msg); 1391 e.name = "ZoteroMissingObjectError"; 1392 throw e; 1393 } 1394 1395 let newParentItemNotifierData = {}; 1396 //newParentItemNotifierData[newParentItem.id] = {}; 1397 if (!env.options.skipNotifier) { 1398 Zotero.Notifier.queue( 1399 'modify', 'item', parentItemID, newParentItemNotifierData, env.options.notifierQueue 1400 ); 1401 } 1402 1403 switch (Zotero.ItemTypes.getName(itemTypeID)) { 1404 case 'note': 1405 case 'attachment': 1406 reloadParentChildItems[parentItemID] = true; 1407 break; 1408 } 1409 } 1410 else { 1411 if (parentItemKey) { 1412 if (!parentItemID) { 1413 // TODO: clear caches 1414 let msg = "Parent item " + this.libraryID + "/" + parentItemKey + " not found"; 1415 let e = new Error(msg); 1416 e.name = "ZoteroMissingObjectError"; 1417 throw e; 1418 } 1419 1420 let newParentItemNotifierData = {}; 1421 //newParentItemNotifierData[newParentItem.id] = {}; 1422 if (!env.options.skipNotifier) { 1423 Zotero.Notifier.queue( 1424 'modify', 1425 'item', 1426 parentItemID, 1427 newParentItemNotifierData, 1428 env.options.notifierQueue 1429 ); 1430 } 1431 } 1432 1433 let oldParentKey = this._previousData.parentKey; 1434 let oldParentItemID; 1435 if (oldParentKey) { 1436 oldParentItemID = this.ObjectsClass.getIDFromLibraryAndKey(this.libraryID, oldParentKey); 1437 if (oldParentItemID) { 1438 let oldParentItemNotifierData = {}; 1439 //oldParentItemNotifierData[oldParentItemID] = {}; 1440 if (!env.options.skipNotifier) { 1441 Zotero.Notifier.queue( 1442 'modify', 1443 'item', 1444 oldParentItemID, 1445 oldParentItemNotifierData, 1446 env.options.notifierQueue 1447 ); 1448 } 1449 } 1450 else { 1451 Zotero.debug("Old source item " + oldParentKey 1452 + " didn't exist in Zotero.Item.save()", 2); 1453 } 1454 } 1455 1456 // If this was an independent item, remove from any collections 1457 // where it existed previously and add parent instead 1458 if (!oldParentKey) { 1459 let sql = "SELECT collectionID FROM collectionItems WHERE itemID=?"; 1460 let changedCollections = yield Zotero.DB.columnQueryAsync(sql, this.id); 1461 if (changedCollections.length) { 1462 let parentItem = yield this.ObjectsClass.getByLibraryAndKeyAsync( 1463 this.libraryID, parentItemKey 1464 ); 1465 for (let i=0; i<changedCollections.length; i++) { 1466 parentItem.addToCollection(changedCollections[i]); 1467 this.removeFromCollection(changedCollections[i]); 1468 1469 if (!env.options.skipNotifier) { 1470 Zotero.Notifier.queue( 1471 'remove', 1472 'collection-item', 1473 changedCollections[i] + '-' + this.id, 1474 {}, 1475 env.options.notifierQueue 1476 ); 1477 } 1478 } 1479 let parentOptions = { 1480 skipDateModifiedUpdate: true 1481 }; 1482 // Apply options (e.g., skipNotifier) from outer save 1483 for (let o in env.options) { 1484 if (!o.startsWith('skip')) continue; 1485 parentOptions[o] = env.options[o]; 1486 } 1487 yield parentItem.save(parentOptions); 1488 } 1489 } 1490 1491 // Update the counts of the previous and new sources 1492 if (oldParentItemID) { 1493 reloadParentChildItems[oldParentItemID] = true; 1494 } 1495 if (parentItemID) { 1496 reloadParentChildItems[parentItemID] = true; 1497 } 1498 } 1499 } 1500 1501 if (this._inPublications) { 1502 if (!this.isRegularItem() && !parentItemID) { 1503 throw new Error("Top-level attachments and notes cannot be added to My Publications"); 1504 } 1505 if (this.isAttachment() && this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { 1506 throw new Error("Linked-file attachments cannot be added to My Publications"); 1507 } 1508 if (Zotero.Libraries.get(this.libraryID).libraryType != 'user') { 1509 throw new Error("Only items in user libraries can be added to My Publications"); 1510 } 1511 } 1512 1513 // Trashed status 1514 if (this._changed.deleted) { 1515 if (this._deleted) { 1516 sql = "REPLACE INTO deletedItems (itemID) VALUES (?)"; 1517 } 1518 else { 1519 // If undeleting, remove any merge-tracking relations 1520 let predicate = Zotero.Relations.replacedItemPredicate; 1521 let thisURI = Zotero.URI.getItemURI(this); 1522 let mergeItems = Zotero.Relations.getByPredicateAndObject( 1523 'item', predicate, thisURI 1524 ); 1525 for (let mergeItem of mergeItems) { 1526 // An item shouldn't have itself as a dc:replaces relation, but if it does it causes an 1527 // infinite loop 1528 if (mergeItem.id == this.id) { 1529 Zotero.logError(`Item ${this.libraryKey} has itself as a ${predicate} relation`); 1530 this.removeRelation(predicate, thisURI); 1531 continue; 1532 } 1533 1534 mergeItem.removeRelation(predicate, thisURI); 1535 yield mergeItem.save({ 1536 skipDateModifiedUpdate: true 1537 }); 1538 } 1539 1540 sql = "DELETE FROM deletedItems WHERE itemID=?"; 1541 } 1542 yield Zotero.DB.queryAsync(sql, itemID); 1543 1544 // Refresh trash 1545 if (!env.options.skipNotifier) { 1546 Zotero.Notifier.queue('refresh', 'trash', this.libraryID, {}, env.options.notifierQueue); 1547 if (this._deleted) { 1548 Zotero.Notifier.queue('trash', 'item', this.id, {}, env.options.notifierQueue); 1549 } 1550 } 1551 1552 if (parentItemID) { 1553 reloadParentChildItems[parentItemID] = true; 1554 } 1555 } 1556 1557 if (this._changed.inPublications) { 1558 if (this._inPublications) { 1559 sql = "INSERT OR IGNORE INTO publicationsItems (itemID) VALUES (?)"; 1560 } 1561 else { 1562 sql = "DELETE FROM publicationsItems WHERE itemID=?"; 1563 } 1564 yield Zotero.DB.queryAsync(sql, itemID); 1565 } 1566 1567 // Collections 1568 // 1569 // Only diffing and removal are done here. Additions have to be done below after parentItemID has 1570 // been updated in itemAttachments/itemNotes, since a child item that was made a standalone item and 1571 // added to a collection can't be added to the collection while it still has a parent, and vice 1572 // versa, due to the trigger checks on collectionItems/itemAttachments/itemNotes. 1573 if (this._changed.collections) { 1574 if (libraryType == 'publications') { 1575 throw new Error("Items in My Publications cannot be added to collections"); 1576 } 1577 1578 let oldCollections = this._previousData.collections || []; 1579 let newCollections = this._collections; 1580 1581 let toAdd = Zotero.Utilities.arrayDiff(newCollections, oldCollections); 1582 let toRemove = Zotero.Utilities.arrayDiff(oldCollections, newCollections); 1583 1584 env.collectionsAdded = toAdd; 1585 env.collectionsRemoved = toRemove; 1586 1587 if (toRemove.length) { 1588 let sql = "DELETE FROM collectionItems WHERE itemID=? AND collectionID IN (" 1589 + toRemove.join(',') 1590 + ")"; 1591 yield Zotero.DB.queryAsync(sql, this.id); 1592 1593 for (let i=0; i<toRemove.length; i++) { 1594 let collectionID = toRemove[i]; 1595 1596 if (!env.options.skipNotifier) { 1597 Zotero.Notifier.queue( 1598 'remove', 1599 'collection-item', 1600 collectionID + '-' + this.id, 1601 {}, 1602 env.options.notifierQueue 1603 ); 1604 } 1605 } 1606 1607 // Remove this item from any loaded collections' cached item lists after commit 1608 Zotero.DB.addCurrentCallback("commit", function () { 1609 for (let i = 0; i < toRemove.length; i++) { 1610 this.ContainerObjectsClass.unregisterChildItem(toRemove[i], this.id); 1611 } 1612 }.bind(this)); 1613 } 1614 } 1615 1616 // Add parent item for existing item, if note or attachment data isn't going to be updated below 1617 // 1618 // Technically this doesn't have to go below collection removals, but only because the 1619 // 'collectionitem must be top level' trigger check applies only to INSERTs, not UPDATEs, which was 1620 // probably done in an earlier attempt to solve this problem. 1621 if (!isNew && this._changed.parentKey && !this._changed.note && !this._changed.attachmentData) { 1622 let type = Zotero.ItemTypes.getName(itemTypeID); 1623 let Type = type[0].toUpperCase() + type.substr(1); 1624 let sql = "UPDATE item" + Type + "s SET parentItemID=? WHERE itemID=?"; 1625 yield Zotero.DB.queryAsync(sql, [parentItemID, this.id]); 1626 } 1627 1628 // There's no reload for parentKey, so clear it here 1629 if (this._changed.parentKey) { 1630 this._clearChanged('parentKey'); 1631 } 1632 1633 // Note 1634 if ((isNew && this.isNote()) || this._changed.note) { 1635 if (!isNew) { 1636 if (this._noteText === null || this._noteTitle === null) { 1637 throw new Error("Cached note values not set with " 1638 + "this._changed.note set to true"); 1639 } 1640 } 1641 1642 let parent = this.isNote() ? this.parentID : null; 1643 let noteText = this._noteText ? this._noteText : ''; 1644 // Add <div> wrapper if not present 1645 if (!noteText.match(/^<div class="zotero-note znv[0-9]+">[\s\S]*<\/div>$/)) { 1646 noteText = Zotero.Notes.notePrefix + noteText + Zotero.Notes.noteSuffix; 1647 } 1648 1649 let params = [ 1650 parent ? parent : null, 1651 noteText, 1652 this._noteTitle ? this._noteTitle : '' 1653 ]; 1654 let sql = "SELECT COUNT(*) FROM itemNotes WHERE itemID=?"; 1655 if (yield Zotero.DB.valueQueryAsync(sql, itemID)) { 1656 sql = "UPDATE itemNotes SET parentItemID=?, note=?, title=? WHERE itemID=?"; 1657 params.push(itemID); 1658 } 1659 else { 1660 sql = "INSERT INTO itemNotes " 1661 + "(itemID, parentItemID, note, title) VALUES (?,?,?,?)"; 1662 params.unshift(itemID); 1663 } 1664 yield Zotero.DB.queryAsync(sql, params); 1665 1666 if (parentItemID) { 1667 reloadParentChildItems[parentItemID] = true; 1668 } 1669 } 1670 1671 // 1672 // Attachment 1673 // 1674 if (!isNew) { 1675 // If attachment title changes, update parent attachments 1676 if (this._changed.itemData && this._changed.itemData[110] && this.isAttachment() && parentItemID) { 1677 reloadParentChildItems[parentItemID] = true; 1678 } 1679 } 1680 if (this._changed.attachmentData) { 1681 let sql = "REPLACE INTO itemAttachments " 1682 + "(itemID, parentItemID, linkMode, contentType, charsetID, path, " 1683 + "syncState, storageModTime, storageHash) " 1684 + "VALUES (?,?,?,?,?,?,?,?,?)"; 1685 let linkMode = this.attachmentLinkMode; 1686 let contentType = this.attachmentContentType; 1687 let charsetID = this.attachmentCharset 1688 ? Zotero.CharacterSets.getID(this.attachmentCharset) 1689 : null; 1690 let path = this.attachmentPath; 1691 let syncState = this.attachmentSyncState; 1692 let storageModTime = this.attachmentSyncedModificationTime; 1693 let storageHash = this.attachmentSyncedHash; 1694 1695 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE && libraryType != 'user') { 1696 throw new Error("Linked files can only be added to user library"); 1697 } 1698 1699 let params = [ 1700 itemID, 1701 parentItemID, 1702 { int: linkMode }, 1703 contentType ? { string: contentType } : null, 1704 charsetID ? { int: charsetID } : null, 1705 path ? { string: path } : null, 1706 syncState !== undefined ? syncState : 0, 1707 storageModTime !== undefined ? storageModTime : null, 1708 storageHash || null 1709 ]; 1710 yield Zotero.DB.queryAsync(sql, params); 1711 1712 // Clear cached child attachments of the parent 1713 if (!isNew && parentItemID) { 1714 reloadParentChildItems[parentItemID] = true; 1715 } 1716 } 1717 1718 // Add to new collections 1719 if (env.collectionsAdded) { 1720 let toAdd = env.collectionsAdded; 1721 for (let i=0; i<toAdd.length; i++) { 1722 let collectionID = toAdd[i]; 1723 1724 let sql = "SELECT IFNULL(MAX(orderIndex)+1, 0) FROM collectionItems " 1725 + "WHERE collectionID=?"; 1726 let orderIndex = yield Zotero.DB.valueQueryAsync(sql, collectionID); 1727 1728 sql = "INSERT OR IGNORE INTO collectionItems " 1729 + "(collectionID, itemID, orderIndex) VALUES (?, ?, ?)"; 1730 yield Zotero.DB.queryAsync(sql, [collectionID, this.id, orderIndex]); 1731 1732 if (!env.options.skipNotifier) { 1733 Zotero.Notifier.queue( 1734 'add', 1735 'collection-item', 1736 collectionID + '-' + this.id, 1737 {}, 1738 env.options.notifierQueue 1739 ); 1740 } 1741 } 1742 1743 // Add this item to any loaded collections' cached item lists after commit 1744 Zotero.DB.addCurrentCallback("commit", function () { 1745 for (let i = 0; i < toAdd.length; i++) { 1746 this.ContainerObjectsClass.registerChildItem(toAdd[i], this.id); 1747 } 1748 }.bind(this)); 1749 } 1750 1751 // Tags 1752 if (this._changedData.tags) { 1753 let oldTags = this._tags; 1754 let newTags = this._changedData.tags; 1755 this._clearChanged('tags'); 1756 this._markForReload('tags'); 1757 1758 // Convert to individual JSON objects, diff, and convert back 1759 let oldTagsJSON = oldTags.map(x => JSON.stringify(x)); 1760 let newTagsJSON = newTags.map(x => JSON.stringify(x)); 1761 1762 let toAdd = Zotero.Utilities.arrayDiff(newTagsJSON, oldTagsJSON).map(x => JSON.parse(x)); 1763 let toRemove = Zotero.Utilities.arrayDiff(oldTagsJSON, newTagsJSON).map(x => JSON.parse(x)); 1764 1765 for (let i=0; i<toAdd.length; i++) { 1766 let tag = toAdd[i]; 1767 let tagID = yield Zotero.Tags.create(tag.tag); 1768 let tagType = tag.type ? tag.type : 0; 1769 // "OR REPLACE" allows changing type 1770 let sql = "INSERT OR REPLACE INTO itemTags (itemID, tagID, type) VALUES (?, ?, ?)"; 1771 yield Zotero.DB.queryAsync(sql, [this.id, tagID, tagType]); 1772 1773 let notifierData = {}; 1774 notifierData[this.id + '-' + tagID] = { 1775 libraryID: this.libraryID, 1776 tag: tag.tag, 1777 type: tagType 1778 }; 1779 if (!env.options.skipNotifier) { 1780 Zotero.Notifier.queue( 1781 'add', 'item-tag', this.id + '-' + tagID, notifierData, env.options.notifierQueue 1782 ); 1783 } 1784 } 1785 1786 if (toRemove.length) { 1787 for (let i=0; i<toRemove.length; i++) { 1788 let tag = toRemove[i]; 1789 let tagID = Zotero.Tags.getID(tag.tag); 1790 let sql = "DELETE FROM itemTags WHERE itemID=? AND tagID=? AND type=?"; 1791 yield Zotero.DB.queryAsync(sql, [this.id, tagID, tag.type ? tag.type : 0]); 1792 let notifierData = {}; 1793 notifierData[this.id + '-' + tagID] = { 1794 libraryID: this.libraryID, 1795 tag: tag.tag 1796 }; 1797 1798 if (!env.options.skipNotifier) { 1799 Zotero.Notifier.queue( 1800 'remove', 'item-tag', this.id + '-' + tagID, notifierData, env.options.notifierQueue 1801 ); 1802 } 1803 } 1804 Zotero.Prefs.set('purge.tags', true); 1805 } 1806 } 1807 1808 // Update child item counts and contents 1809 if (reloadParentChildItems) { 1810 for (let parentItemID in reloadParentChildItems) { 1811 // Keep in sync with Zotero.Items.trash() 1812 let parentItem = yield this.ObjectsClass.getAsync(parseInt(parentItemID)); 1813 yield parentItem.reload(['primaryData', 'childItems'], true); 1814 parentItem.clearBestAttachmentState(); 1815 } 1816 } 1817 1818 Zotero.DB.requireTransaction(); 1819 }); 1820 1821 Zotero.Item.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) { 1822 if (!env.skipCache) { 1823 // Always reload primary data. DataObject.reload() only reloads changed data types, so 1824 // it won't reload, say, dateModified and firstCreator if only creator data was changed 1825 // and not primaryData. 1826 yield this.loadPrimaryData(true); 1827 yield this.reload(); 1828 // If new, there's no other data we don't have, so we can mark everything as loaded 1829 if (env.isNew) { 1830 this._markAllDataTypeLoadStates(true); 1831 } 1832 } 1833 1834 return env.isNew ? this.id : true; 1835 }); 1836 1837 1838 Zotero.Item.prototype.isRegularItem = function() { 1839 return !(this.isNote() || this.isAttachment()); 1840 } 1841 1842 1843 Zotero.Item.prototype.isTopLevelItem = function () { 1844 return this.isRegularItem() || !this.parentKey; 1845 } 1846 1847 1848 Zotero.Item.prototype.numChildren = function(includeTrashed) { 1849 return this.numNotes(includeTrashed) + this.numAttachments(includeTrashed); 1850 } 1851 1852 1853 /** 1854 * @return {String|FALSE} Key of the parent item for an attachment or note, or FALSE if none 1855 */ 1856 Zotero.Item.prototype.getSourceKey = function() { 1857 Zotero.debug("Zotero.Item.prototype.getSource() is deprecated -- use .parentKey"); 1858 return this._parentKey; 1859 } 1860 1861 1862 Zotero.Item.prototype.setSourceKey = function(sourceItemKey) { 1863 Zotero.debug("Zotero.Item.prototype.setSourceKey() is deprecated -- use .parentKey"); 1864 return this.parentKey = sourceItemKey; 1865 } 1866 1867 1868 //////////////////////////////////////////////////////// 1869 // 1870 // Methods dealing with note items 1871 // 1872 //////////////////////////////////////////////////////// 1873 /** 1874 * Determine if an item is a note 1875 **/ 1876 Zotero.Item.prototype.isNote = function() { 1877 return Zotero.ItemTypes.getName(this.itemTypeID) == 'note'; 1878 } 1879 1880 1881 /** 1882 * Update an item note 1883 * 1884 * Note: This can only be called on saved notes and attachments 1885 **/ 1886 Zotero.Item.prototype.updateNote = function(text) { 1887 throw ('updateNote() removed -- use setNote() and save()'); 1888 } 1889 1890 1891 /** 1892 * Returns number of child notes of item 1893 * 1894 * @param {Boolean} includeTrashed Include trashed child items in count 1895 * @param {Boolean} includeEmbedded Include notes embedded in attachments 1896 * @return {Integer} 1897 */ 1898 Zotero.Item.prototype.numNotes = function(includeTrashed, includeEmbedded) { 1899 this._requireData('childItems'); 1900 var notes = Zotero.Items.get(this.getNotes(includeTrashed)); 1901 var num = notes.length; 1902 if (includeEmbedded) { 1903 // Include embedded attachment notes that aren't empty 1904 num += Zotero.Items.get(this.getAttachments(includeTrashed)) 1905 .filter(x => x.getNote() !== '').length; 1906 } 1907 return num; 1908 } 1909 1910 1911 /** 1912 * Get the first line of the note for display in the items list 1913 * 1914 * @return {String} 1915 */ 1916 Zotero.Item.prototype.getNoteTitle = function() { 1917 if (!this.isNote() && !this.isAttachment()) { 1918 throw ("getNoteTitle() can only be called on notes and attachments"); 1919 } 1920 if (this._noteTitle !== null) { 1921 return this._noteTitle; 1922 } 1923 this._requireData('itemData'); 1924 return ""; 1925 }; 1926 1927 1928 Zotero.Item.prototype.hasNote = Zotero.Promise.coroutine(function* () { 1929 if (!this.isNote() && !this.isAttachment()) { 1930 throw new Error("hasNote() can only be called on notes and attachments"); 1931 } 1932 1933 if (this._hasNote !== null) { 1934 return this._hasNote; 1935 } 1936 1937 if (!this._id) { 1938 return false; 1939 } 1940 1941 var sql = "SELECT COUNT(*) FROM itemNotes WHERE itemID=? " 1942 + "AND note!='' AND note!=?"; 1943 var hasNote = !!(yield Zotero.DB.valueQueryAsync(sql, [this._id, Zotero.Notes.defaultNote])); 1944 1945 this._hasNote = hasNote; 1946 return hasNote; 1947 }); 1948 1949 1950 /** 1951 * Get the text of an item note 1952 **/ 1953 Zotero.Item.prototype.getNote = function() { 1954 if (!this.isNote() && !this.isAttachment()) { 1955 throw new Error("getNote() can only be called on notes and attachments " 1956 + `(${this.libraryID}/${this.key} is a ${Zotero.ItemTypes.getName(this.itemTypeID)})`); 1957 } 1958 1959 // Store access time for later garbage collection 1960 this._noteAccessTime = new Date(); 1961 1962 if (this._noteText !== null) { 1963 return this._noteText; 1964 } 1965 1966 this._requireData('note'); 1967 return ""; 1968 } 1969 1970 1971 /** 1972 * Set an item note 1973 * 1974 * Note: This can only be called on notes and attachments 1975 **/ 1976 Zotero.Item.prototype.setNote = function(text) { 1977 if (!this.isNote() && !this.isAttachment()) { 1978 throw ("updateNote() can only be called on notes and attachments"); 1979 } 1980 1981 if (typeof text != 'string') { 1982 throw ("text must be a string in Zotero.Item.setNote() (was " + typeof text + ")"); 1983 } 1984 1985 text = text 1986 // Strip control characters 1987 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") 1988 .trim(); 1989 1990 var oldText = this.getNote(); 1991 if (text === oldText) { 1992 Zotero.debug("Note hasn't changed", 4); 1993 return false; 1994 } 1995 1996 this._hasNote = text !== ''; 1997 this._noteText = text; 1998 this._noteTitle = Zotero.Notes.noteToTitle(text); 1999 if (this.isNote()) { 2000 this._displayTitle = this._noteTitle; 2001 } 2002 2003 this._markFieldChange('note', oldText); 2004 this._changed.note = true; 2005 2006 return true; 2007 } 2008 2009 2010 /** 2011 * Returns child notes of this item 2012 * 2013 * @param {Boolean} includeTrashed Include trashed child items 2014 * @param {Boolean} includeEmbedded Include embedded attachment notes 2015 * @return {Integer[]} Array of itemIDs 2016 */ 2017 Zotero.Item.prototype.getNotes = function(includeTrashed) { 2018 if (this.isNote()) { 2019 throw new Error("getNotes() cannot be called on items of type 'note'"); 2020 } 2021 2022 this._requireData('childItems'); 2023 2024 if (!this._notes) { 2025 return []; 2026 } 2027 2028 var sortChronologically = Zotero.Prefs.get('sortNotesChronologically'); 2029 var cacheKey = (sortChronologically ? "chronological" : "alphabetical") 2030 + 'With' + (includeTrashed ? '' : 'out') + 'Trashed'; 2031 2032 if (this._notes[cacheKey]) { 2033 return this._notes[cacheKey]; 2034 } 2035 2036 var rows = this._notes.rows.concat(); 2037 // Remove trashed items if necessary 2038 if (!includeTrashed) { 2039 rows = rows.filter(row => !row.trashed); 2040 } 2041 // Sort by title if necessary 2042 if (!sortChronologically) { 2043 var collation = Zotero.getLocaleCollation(); 2044 rows.sort((a, b) => { 2045 var aTitle = this.ObjectsClass.getSortTitle(a.title); 2046 var bTitle = this.ObjectsClass.getSortTitle(b.title); 2047 return collation.compareString(1, aTitle, bTitle); 2048 }); 2049 } 2050 var ids = rows.map(row => row.itemID); 2051 this._notes[cacheKey] = ids; 2052 return ids; 2053 } 2054 2055 2056 //////////////////////////////////////////////////////// 2057 // 2058 // Methods dealing with attachments 2059 // 2060 // save() is not required for attachment functions 2061 // 2062 /////////////////////////////////////////////////////// 2063 /** 2064 * Determine if an item is an attachment 2065 **/ 2066 Zotero.Item.prototype.isAttachment = function() { 2067 return Zotero.ItemTypes.getName(this.itemTypeID) == 'attachment'; 2068 } 2069 2070 2071 /** 2072 * @return {Promise<Boolean>} 2073 */ 2074 Zotero.Item.prototype.isImportedAttachment = function() { 2075 if (!this.isAttachment()) { 2076 return false; 2077 } 2078 var linkMode = this.attachmentLinkMode; 2079 if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE || linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) { 2080 return true; 2081 } 2082 return false; 2083 } 2084 2085 2086 /** 2087 * @return {Promise<Boolean>} 2088 */ 2089 Zotero.Item.prototype.isWebAttachment = function() { 2090 if (!this.isAttachment()) { 2091 return false; 2092 } 2093 var linkMode = this.attachmentLinkMode; 2094 if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE || linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { 2095 return false; 2096 } 2097 return true; 2098 } 2099 2100 2101 /** 2102 * @return {Promise<Boolean>} 2103 */ 2104 Zotero.Item.prototype.isFileAttachment = function() { 2105 if (!this.isAttachment()) { 2106 return false; 2107 } 2108 return this.attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_URL; 2109 } 2110 2111 2112 /** 2113 * Returns number of child attachments of item 2114 * 2115 * @param {Boolean} includeTrashed Include trashed child items in count 2116 * @return <Integer> 2117 */ 2118 Zotero.Item.prototype.numAttachments = function (includeTrashed) { 2119 this._requireData('childItems'); 2120 return this.getAttachments(includeTrashed).length; 2121 } 2122 2123 2124 Zotero.Item.prototype.numNonHTMLFileAttachments = function () { 2125 this._requireData('childItems'); 2126 return this.getAttachments() 2127 .map(itemID => Zotero.Items.get(itemID)) 2128 .filter(item => item.isFileAttachment() && item.attachmentContentType != 'text/html') 2129 .length; 2130 }; 2131 2132 2133 Zotero.Item.prototype.getFile = function () { 2134 Zotero.debug("Zotero.Item.prototype.getFile() is deprecated -- use getFilePath[Async]()", 2); 2135 2136 var path = this.getFilePath(); 2137 if (path) { 2138 return Zotero.File.pathToFile(path); 2139 } 2140 return false; 2141 } 2142 2143 2144 /** 2145 * Get the absolute file path for the attachment 2146 * 2147 * @return {string|false} - The absolute file path of the attachment, or false for invalid paths 2148 */ 2149 Zotero.Item.prototype.getFilePath = function () { 2150 if (!this.isAttachment()) { 2151 throw new Error("getFilePath() can only be called on attachment items"); 2152 } 2153 2154 var linkMode = this.attachmentLinkMode; 2155 var path = this.attachmentPath; 2156 2157 // No associated files for linked URLs 2158 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { 2159 return false; 2160 } 2161 2162 if (!path) { 2163 Zotero.debug("Attachment path is empty", 2); 2164 this._updateAttachmentStates(false); 2165 return false; 2166 } 2167 2168 if (!this._identified) { 2169 Zotero.debug("Can't get file path for unsaved file"); 2170 return false; 2171 } 2172 2173 // Imported file with relative path 2174 if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL || 2175 linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) { 2176 if (!path.includes("storage:")) { 2177 Zotero.logError("Invalid attachment path '" + path + "'"); 2178 this._updateAttachmentStates(false); 2179 return false; 2180 } 2181 // Strip "storage:" 2182 path = path.substr(8); 2183 2184 // Ignore .zotero* files that were relinked before we started blocking them 2185 if (path.startsWith(".zotero")) { 2186 Zotero.debug("Ignoring attachment file " + path, 2); 2187 return false; 2188 } 2189 2190 return OS.Path.join( 2191 OS.Path.normalize(Zotero.Attachments.getStorageDirectory(this).path), path 2192 ); 2193 } 2194 2195 // Linked file with relative path 2196 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE && 2197 path.indexOf(Zotero.Attachments.BASE_PATH_PLACEHOLDER) == 0) { 2198 path = Zotero.Attachments.resolveRelativePath(path); 2199 if (!path) { 2200 this._updateAttachmentStates(false); 2201 } 2202 return path; 2203 } 2204 2205 // Old-style OS X persistent descriptor (Base64-encoded opaque alias record) 2206 // 2207 // These should only exist if they weren't converted in the 80 DB upgrade step because 2208 // the file couldn't be found. 2209 if (path.startsWith('AAAA')) { 2210 // These can only be resolved on Macs 2211 if (!Zotero.isMac) { 2212 Zotero.debug(`Can't resolve old-style attachment path '${path}' on non-Mac platform`); 2213 this._updateAttachmentStates(false); 2214 return false; 2215 } 2216 2217 let file = Components.classes["@mozilla.org/file/local;1"] 2218 .createInstance(Components.interfaces.nsILocalFile); 2219 try { 2220 file.persistentDescriptor = path; 2221 } 2222 catch (e) { 2223 Zotero.debug(`Can't resolve old-style attachment path '${path}'`); 2224 this._updateAttachmentStates(false); 2225 return false; 2226 } 2227 2228 // If valid, convert this to a regular string in the background 2229 Zotero.DB.queryAsync( 2230 "UPDATE itemAttachments SET path=? WHERE itemID=?", 2231 [file.path, this._id] 2232 ); 2233 2234 return file.path; 2235 } 2236 2237 return path; 2238 }; 2239 2240 2241 /** 2242 * Get the absolute path for the attachment, if the file exists 2243 * 2244 * @return {Promise<String|false>} - A promise for either the absolute path of the attachment 2245 * or false for invalid paths or if the file doesn't exist 2246 */ 2247 Zotero.Item.prototype.getFilePathAsync = Zotero.Promise.coroutine(function* () { 2248 if (!this.isAttachment()) { 2249 throw new Error("getFilePathAsync() can only be called on attachment items"); 2250 } 2251 2252 var linkMode = this.attachmentLinkMode; 2253 var path = this.attachmentPath; 2254 2255 // No associated files for linked URLs 2256 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { 2257 this._updateAttachmentStates(false); 2258 return false; 2259 } 2260 2261 if (!path) { 2262 Zotero.debug("Attachment path is empty", 2); 2263 this._updateAttachmentStates(false); 2264 return false; 2265 } 2266 2267 // Imported file with relative path 2268 if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL || 2269 linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) { 2270 if (!path.includes("storage:")) { 2271 Zotero.logError("Invalid attachment path '" + path + "'"); 2272 this._updateAttachmentStates(false); 2273 return false; 2274 } 2275 2276 // Strip "storage:" 2277 path = path.substr(8); 2278 2279 // Ignore .zotero* files that were relinked before we started blocking them 2280 if (path.startsWith(".zotero")) { 2281 Zotero.debug("Ignoring attachment file " + path, 2); 2282 this._updateAttachmentStates(false); 2283 return false; 2284 } 2285 2286 path = OS.Path.join( 2287 OS.Path.normalize(Zotero.Attachments.getStorageDirectory(this).path), path 2288 ); 2289 2290 if (!(yield OS.File.exists(path))) { 2291 Zotero.debug("Attachment file '" + path + "' not found", 2); 2292 this._updateAttachmentStates(false); 2293 return false; 2294 } 2295 2296 this._updateAttachmentStates(true); 2297 return path; 2298 } 2299 2300 // Linked file with relative path 2301 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE && 2302 path.indexOf(Zotero.Attachments.BASE_PATH_PLACEHOLDER) == 0) { 2303 path = Zotero.Attachments.resolveRelativePath(path); 2304 if (!path) { 2305 this._updateAttachmentStates(false); 2306 return false; 2307 } 2308 if (!(yield OS.File.exists(path))) { 2309 Zotero.debug("Attachment file '" + path + "' not found", 2); 2310 this._updateAttachmentStates(false); 2311 return false; 2312 } 2313 2314 this._updateAttachmentStates(true); 2315 return path; 2316 } 2317 2318 // Old-style OS X persistent descriptor (Base64-encoded opaque alias record) 2319 // 2320 // These should only exist if they weren't converted in the 80 DB upgrade step because 2321 // the file couldn't be found 2322 if (Zotero.isMac && path.startsWith('AAAA')) { 2323 let file = Components.classes["@mozilla.org/file/local;1"] 2324 .createInstance(Components.interfaces.nsILocalFile); 2325 try { 2326 file.persistentDescriptor = path; 2327 } 2328 catch (e) { 2329 this._updateAttachmentStates(false); 2330 return false; 2331 } 2332 2333 // If valid, convert this to a regular string 2334 yield Zotero.DB.queryAsync( 2335 "UPDATE itemAttachments SET path=? WHERE itemID=?", 2336 [file.leafName, this._id] 2337 ); 2338 2339 if (!(yield OS.File.exists(file.path))) { 2340 Zotero.debug("Attachment file '" + file.path + "' not found", 2); 2341 this._updateAttachmentStates(false); 2342 return false; 2343 } 2344 2345 this._updateAttachmentStates(true); 2346 2347 return file.path; 2348 } 2349 2350 if (!(yield OS.File.exists(path))) { 2351 Zotero.debug("Attachment file '" + path + "' not found", 2); 2352 this._updateAttachmentStates(false); 2353 return false; 2354 } 2355 2356 this._updateAttachmentStates(true); 2357 2358 return path; 2359 }); 2360 2361 2362 /** 2363 * Update file existence state of this item and best attachment state of parent item 2364 */ 2365 Zotero.Item.prototype._updateAttachmentStates = function (exists) { 2366 this._fileExists = exists; 2367 2368 if (this.isTopLevelItem()) { 2369 return; 2370 } 2371 2372 try { 2373 var parentKey = this.parentKey; 2374 } 2375 // This can happen during classic sync conflict resolution, if a 2376 // standalone attachment was modified locally and remotely was changed 2377 // into a child attachment 2378 catch (e) { 2379 Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} doesn't exist for ` 2380 + "source key in Zotero.Item.updateAttachmentStates()"); 2381 return; 2382 } 2383 2384 try { 2385 var item = this.ObjectsClass.getByLibraryAndKey(this.libraryID, parentKey); 2386 } 2387 catch (e) { 2388 if (e instanceof Zotero.Exception.UnloadedDataException) { 2389 Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} not yet loaded in ` 2390 + "Zotero.Item.updateAttachmentStates()"); 2391 return; 2392 } 2393 throw e; 2394 } 2395 if (!item) { 2396 Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} doesn't exist`); 2397 return; 2398 } 2399 item.clearBestAttachmentState(); 2400 }; 2401 2402 2403 Zotero.Item.prototype.getFilename = function () { 2404 Zotero.debug("getFilename() deprecated -- use .attachmentFilename"); 2405 return this.attachmentFilename; 2406 } 2407 2408 2409 /** 2410 * Asynchronous check for file existence 2411 */ 2412 Zotero.Item.prototype.fileExists = Zotero.Promise.coroutine(function* () { 2413 if (!this.isAttachment()) { 2414 throw new Error("Zotero.Item.fileExists() can only be called on attachment items"); 2415 } 2416 2417 if (this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { 2418 throw new Error("Zotero.Item.fileExists() cannot be called on link attachments"); 2419 } 2420 2421 return !!(yield this.getFilePathAsync()); 2422 }); 2423 2424 2425 /** 2426 * Synchronous cached check for file existence, used for items view 2427 */ 2428 Zotero.Item.prototype.fileExistsCached = function () { 2429 return this._fileExists; 2430 } 2431 2432 2433 2434 /** 2435 * Rename file associated with an attachment 2436 * 2437 * @param {String} newName 2438 * @param {Boolean} [overwrite=false] - Overwrite file if one exists 2439 * @param {Boolean} [unique=false] - Add suffix to create unique filename if necessary 2440 * @return {Number|false} -- true - Rename successful 2441 * -1 - Destination file exists; use _force_ to overwrite 2442 * -2 - Error renaming 2443 * false - Attachment file not found 2444 */ 2445 Zotero.Item.prototype.renameAttachmentFile = async function (newName, overwrite = false, unique = false) { 2446 var origPath = await this.getFilePathAsync(); 2447 if (!origPath) { 2448 Zotero.debug("Attachment file not found in renameAttachmentFile()", 2); 2449 return false; 2450 } 2451 2452 try { 2453 let origName = OS.Path.basename(origPath); 2454 if (this.isImportedAttachment()) { 2455 var origModDate = (await OS.File.stat(origPath)).lastModificationDate; 2456 } 2457 2458 // No change 2459 if (origName === newName) { 2460 Zotero.debug("Filename has not changed"); 2461 return true; 2462 } 2463 2464 // Update mod time and clear hash so the file syncs 2465 // TODO: use an integer counter instead of mod time for change detection 2466 // Update mod time first, because it may fail for read-only files on Windows 2467 if (this.isImportedAttachment()) { 2468 await OS.File.setDates(origPath, null, null); 2469 } 2470 2471 newName = await Zotero.File.rename( 2472 origPath, 2473 newName, 2474 { 2475 overwrite, 2476 unique 2477 } 2478 ); 2479 let destPath = OS.Path.join(OS.Path.dirname(origPath), newName); 2480 2481 await this.relinkAttachmentFile(destPath); 2482 2483 if (this.isImportedAttachment()) { 2484 this.attachmentSyncedHash = null; 2485 this.attachmentSyncState = "to_upload"; 2486 await this.saveTx({ skipAll: true }); 2487 } 2488 2489 return true; 2490 } 2491 catch (e) { 2492 Zotero.logError(e); 2493 2494 // Restore original modification date in case we managed to change it 2495 if (this.isImportedAttachment()) { 2496 try { 2497 OS.File.setDates(origPath, null, origModDate); 2498 } catch (e) { 2499 Zotero.debug(e, 2); 2500 } 2501 } 2502 2503 return -2; 2504 } 2505 }; 2506 2507 2508 /** 2509 * @param {string} path File path 2510 * @param {Boolean} [skipItemUpdate] Don't update attachment item mod time, so that item doesn't 2511 * sync. Used when a file needs to be renamed to be accessible but the user doesn't have 2512 * access to modify the attachment metadata. This also allows a save when the library is 2513 * read-only. 2514 */ 2515 Zotero.Item.prototype.relinkAttachmentFile = Zotero.Promise.coroutine(function* (path, skipItemUpdate) { 2516 if (path instanceof Components.interfaces.nsIFile) { 2517 Zotero.debug("WARNING: Zotero.Item.prototype.relinkAttachmentFile() now takes an absolute " 2518 + "file path instead of an nsIFile"); 2519 path = path.path; 2520 } 2521 2522 var linkMode = this.attachmentLinkMode; 2523 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { 2524 throw new Error('Cannot relink linked URL'); 2525 } 2526 2527 var fileName = OS.Path.basename(path); 2528 if (fileName.endsWith(".lnk")) { 2529 throw new Error("Cannot relink to Windows shortcut"); 2530 } 2531 var newPath; 2532 var newName = Zotero.File.getValidFileName(fileName); 2533 if (!newName) { 2534 throw new Error("No valid characters in filename after filtering"); 2535 } 2536 2537 // If selected file isn't in the attachment's storage directory, 2538 // copy it in and use that one instead 2539 var storageDir = Zotero.Attachments.getStorageDirectory(this).path; 2540 if (this.isImportedAttachment() && OS.Path.dirname(path) != storageDir) { 2541 newPath = OS.Path.join(storageDir, newName); 2542 2543 // If file with same name already exists in the storage directory, 2544 // move it out of the way 2545 let backupCreated = false; 2546 if (yield OS.File.exists(newPath)) { 2547 backupCreated = true; 2548 yield OS.File.move(newPath, newPath + ".bak"); 2549 } 2550 // Create storage directory if necessary 2551 else if (!(yield OS.File.exists(storageDir))) { 2552 yield Zotero.Attachments.createDirectoryForItem(this); 2553 } 2554 2555 let newFile; 2556 try { 2557 newFile = Zotero.File.copyToUnique(path, newPath); 2558 } 2559 catch (e) { 2560 // Restore backup file if copying failed 2561 if (backupCreated) { 2562 yield OS.File.move(newPath + ".bak", newPath); 2563 } 2564 throw e; 2565 } 2566 newPath = newFile.path; 2567 2568 // Delete backup file 2569 if (backupCreated) { 2570 yield OS.File.remove(newPath + ".bak"); 2571 } 2572 } 2573 else { 2574 newPath = OS.Path.join(OS.Path.dirname(path), newName); 2575 2576 // Rename file to filtered name if necessary 2577 if (fileName != newName) { 2578 Zotero.debug("Renaming file '" + fileName + "' to '" + newName + "'"); 2579 try { 2580 yield OS.File.move(path, newPath, { noOverwrite: true }); 2581 } 2582 catch (e) { 2583 if (e instanceof OS.File.Error && e.becauseExists && fileName.normalize() == newName) { 2584 // Ignore normalization differences that the filesystem ignores 2585 } 2586 else { 2587 throw e; 2588 } 2589 } 2590 } 2591 } 2592 2593 this.attachmentPath = newPath; 2594 2595 yield this.saveTx({ 2596 skipDateModifiedUpdate: true, 2597 skipClientDateModifiedUpdate: skipItemUpdate, 2598 skipEditCheck: skipItemUpdate 2599 }); 2600 2601 this._updateAttachmentStates(true); 2602 yield Zotero.Notifier.trigger('refresh', 'item', this.id); 2603 2604 return true; 2605 }); 2606 2607 2608 Zotero.Item.prototype.deleteAttachmentFile = Zotero.Promise.coroutine(function* () { 2609 if (!this.isImportedAttachment()) { 2610 throw new Error("deleteAttachmentFile() can only be called on imported attachment items"); 2611 } 2612 2613 var path = yield this.getFilePathAsync(); 2614 if (!path) { 2615 Zotero.debug(`File not found for item ${this.libraryKey} in deleteAttachmentFile()`, 2); 2616 return false; 2617 } 2618 2619 Zotero.debug("Deleting attachment file for item " + this.libraryKey); 2620 try { 2621 yield Zotero.File.removeIfExists(path); 2622 this.attachmentSyncState = "to_download"; 2623 yield this.saveTx({ skipAll: true }); 2624 return true; 2625 } 2626 catch (e) { 2627 Zotero.logError(e); 2628 return false; 2629 } 2630 }); 2631 2632 2633 2634 /* 2635 * Return a file:/// URL path to files and snapshots 2636 */ 2637 Zotero.Item.prototype.getLocalFileURL = function() { 2638 if (!this.isAttachment) { 2639 throw ("getLocalFileURL() can only be called on attachment items"); 2640 } 2641 2642 var file = this.getFile(); 2643 if (!file) { 2644 return false; 2645 } 2646 2647 var nsIFPH = Components.classes["@mozilla.org/network/protocol;1?name=file"] 2648 .getService(Components.interfaces.nsIFileProtocolHandler); 2649 return nsIFPH.getURLSpecFromFile(file); 2650 } 2651 2652 2653 Zotero.Item.prototype.getAttachmentLinkMode = function() { 2654 Zotero.debug("getAttachmentLinkMode() deprecated -- use .attachmentLinkMode"); 2655 return this.attachmentLinkMode; 2656 } 2657 2658 /** 2659 * Link mode of an attachment 2660 * 2661 * Possible values specified as constants in Zotero.Attachments 2662 * (e.g. Zotero.Attachments.LINK_MODE_LINKED_FILE) 2663 */ 2664 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentLinkMode', { 2665 get: function() { 2666 if (!this.isAttachment()) { 2667 return undefined; 2668 } 2669 return this._attachmentLinkMode; 2670 }, 2671 set: function(val) { 2672 if (!this.isAttachment()) { 2673 throw (".attachmentLinkMode can only be set for attachment items"); 2674 } 2675 2676 // Allow 'imported_url', etc. 2677 if (typeof val == 'string') { 2678 let code = Zotero.Attachments["LINK_MODE_" + val.toUpperCase()]; 2679 if (code !== undefined) { 2680 val = code; 2681 } 2682 } 2683 2684 switch (val) { 2685 case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: 2686 case Zotero.Attachments.LINK_MODE_IMPORTED_URL: 2687 case Zotero.Attachments.LINK_MODE_LINKED_FILE: 2688 case Zotero.Attachments.LINK_MODE_LINKED_URL: 2689 break; 2690 2691 default: 2692 throw ("Invalid attachment link mode '" + val 2693 + "' in Zotero.Item.attachmentLinkMode setter"); 2694 } 2695 2696 if (val === this.attachmentLinkMode) { 2697 return; 2698 } 2699 if (!this._changed.attachmentData) { 2700 this._changed.attachmentData = {}; 2701 } 2702 this._changed.attachmentData.linkMode = true; 2703 this._attachmentLinkMode = val; 2704 } 2705 }); 2706 2707 2708 Zotero.Item.prototype.getAttachmentMIMEType = function() { 2709 Zotero.debug("getAttachmentMIMEType() deprecated -- use .attachmentContentType"); 2710 return this.attachmentContentType; 2711 }; 2712 2713 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentMIMEType', { 2714 get: function() { 2715 Zotero.debug(".attachmentMIMEType deprecated -- use .attachmentContentType"); 2716 return this.attachmentContentType; 2717 }, 2718 enumerable: false 2719 }); 2720 2721 /** 2722 * Content type of an attachment (e.g. 'text/plain') 2723 */ 2724 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentContentType', { 2725 get: function() { 2726 if (!this.isAttachment()) { 2727 return undefined; 2728 } 2729 return this._attachmentContentType; 2730 }, 2731 set: function(val) { 2732 if (!this.isAttachment()) { 2733 throw (".attachmentContentType can only be set for attachment items"); 2734 } 2735 2736 if (!val) { 2737 val = ''; 2738 } 2739 2740 if (val == this.attachmentContentType) { 2741 return; 2742 } 2743 2744 if (!this._changed.attachmentData) { 2745 this._changed.attachmentData = {}; 2746 } 2747 this._changed.attachmentData.contentType = true; 2748 this._attachmentContentType = val; 2749 } 2750 }); 2751 2752 2753 Zotero.Item.prototype.getAttachmentCharset = function() { 2754 Zotero.debug("getAttachmentCharset() deprecated -- use .attachmentCharset"); 2755 return this.attachmentCharset; 2756 } 2757 2758 2759 /** 2760 * Character set of an attachment 2761 */ 2762 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentCharset', { 2763 get: function() { 2764 if (!this.isAttachment()) { 2765 return undefined; 2766 } 2767 return this._attachmentCharset 2768 }, 2769 set: function(val) { 2770 if (!this.isAttachment()) { 2771 throw (".attachmentCharset can only be set for attachment items"); 2772 } 2773 2774 if (typeof val == 'number') { 2775 throw new Error("Character set must be a string"); 2776 } 2777 oldVal = this.attachmentCharset; 2778 2779 if (val) { 2780 val = Zotero.CharacterSets.toCanonical(val); 2781 } 2782 if (!val) { 2783 val = ""; 2784 } 2785 2786 if (val === oldVal) { 2787 return; 2788 } 2789 2790 if (!this._changed.attachmentData) { 2791 this._changed.attachmentData= {}; 2792 } 2793 this._changed.attachmentData.charset = true; 2794 this._attachmentCharset = val; 2795 } 2796 }); 2797 2798 2799 /** 2800 * Get or set the filename of file attachments 2801 * 2802 * This will return the filename for all file attachments, but the filename can only be set 2803 * for stored file attachments. Linked file attachments should be set using .attachmentPath. 2804 */ 2805 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentFilename', { 2806 get: function () { 2807 if (!this.isAttachment()) { 2808 return undefined; 2809 } 2810 var path = this.attachmentPath; 2811 if (!path) { 2812 return ''; 2813 } 2814 var prefixedPath = path.match(/^(?:attachments|storage):(.*)$/); 2815 if (prefixedPath) { 2816 return prefixedPath[1]; 2817 } 2818 return OS.Path.basename(path); 2819 }, 2820 set: function (val) { 2821 if (!this.isAttachment()) { 2822 throw new Error("Attachment filename can only be set for attachment items"); 2823 } 2824 var linkMode = this.attachmentLinkMode; 2825 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE 2826 || linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { 2827 throw new Error("Attachment filename can only be set for stored files"); 2828 } 2829 2830 if (!val) { 2831 throw new Error("Attachment filename cannot be blank"); 2832 } 2833 2834 this.attachmentPath = 'storage:' + val; 2835 } 2836 }); 2837 2838 2839 /** 2840 * Returns raw attachment path string as stored in DB 2841 * (e.g., "storage:foo.pdf", "attachments:foo/bar.pdf", "/Users/foo/Desktop/bar.pdf") 2842 * 2843 * Can be set as absolute path or prefixed string ("storage:foo.pdf") 2844 */ 2845 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentPath', { 2846 get: function() { 2847 if (!this.isAttachment()) { 2848 return undefined; 2849 } 2850 return this._attachmentPath; 2851 }, 2852 set: function(val) { 2853 if (!this.isAttachment()) { 2854 throw new Error(".attachmentPath can only be set for attachment items"); 2855 } 2856 2857 if (typeof val != 'string') { 2858 throw new Error(".attachmentPath must be a string"); 2859 } 2860 2861 var linkMode = this.attachmentLinkMode; 2862 if (linkMode === null) { 2863 throw new Error("Link mode must be set before setting attachment path"); 2864 } 2865 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { 2866 throw new Error('attachmentPath cannot be set for link attachments'); 2867 } 2868 2869 if (!val) { 2870 val = ''; 2871 } 2872 2873 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { 2874 if (this._libraryID) { 2875 let libraryType = Zotero.Libraries.get(this._libraryID).libraryType; 2876 if (libraryType != 'user') { 2877 throw new Error("Linked files can only be added to user library"); 2878 } 2879 } 2880 2881 // If base directory is enabled, save attachment within as relative path 2882 if (Zotero.Prefs.get('saveRelativeAttachmentPath')) { 2883 val = Zotero.Attachments.getBaseDirectoryRelativePath(val); 2884 } 2885 // Otherwise, convert relative path to absolute if possible 2886 else { 2887 val = Zotero.Attachments.resolveRelativePath(val) || val; 2888 } 2889 } 2890 else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL || 2891 linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) { 2892 if (!val.startsWith('storage:')) { 2893 let storagePath = Zotero.Attachments.getStorageDirectory(this).path; 2894 if (!val.startsWith(storagePath)) { 2895 throw new Error("Imported file path must be within storage directory"); 2896 } 2897 val = 'storage:' + OS.Path.basename(val); 2898 } 2899 } 2900 2901 if (val == this.attachmentPath) { 2902 return; 2903 } 2904 2905 if (!this._changed.attachmentData) { 2906 this._changed.attachmentData = {}; 2907 } 2908 this._changed.attachmentData.path = true; 2909 this._attachmentPath = val; 2910 } 2911 }); 2912 2913 2914 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncState', { 2915 get: function() { 2916 if (!this.isAttachment()) { 2917 return undefined; 2918 } 2919 return this._attachmentSyncState; 2920 }, 2921 set: function(val) { 2922 if (!this.isAttachment()) { 2923 throw new Error("attachmentSyncState can only be set for attachment items"); 2924 } 2925 2926 if (typeof val == 'string') { 2927 val = Zotero.Sync.Storage.Local["SYNC_STATE_" + val.toUpperCase()]; 2928 } 2929 2930 switch (this.attachmentLinkMode) { 2931 case Zotero.Attachments.LINK_MODE_IMPORTED_URL: 2932 case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: 2933 break; 2934 2935 default: 2936 throw new Error("attachmentSyncState can only be set for stored files"); 2937 } 2938 2939 switch (val) { 2940 case Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD: 2941 case Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD: 2942 case Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC: 2943 case Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_UPLOAD: 2944 case Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_DOWNLOAD: 2945 case Zotero.Sync.Storage.Local.SYNC_STATE_IN_CONFLICT: 2946 break; 2947 2948 default: 2949 throw new Error("Invalid sync state '" + val + "'"); 2950 } 2951 2952 if (val == this.attachmentSyncState) { 2953 return; 2954 } 2955 2956 if (!this._changed.attachmentData) { 2957 this._changed.attachmentData = {}; 2958 } 2959 this._changed.attachmentData.syncState = true; 2960 this._attachmentSyncState = val; 2961 } 2962 }); 2963 2964 2965 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncedModificationTime', { 2966 get: function () { 2967 if (!this.isFileAttachment()) { 2968 return undefined; 2969 } 2970 return this._attachmentSyncedModificationTime; 2971 }, 2972 set: function (val) { 2973 if (!this.isAttachment()) { 2974 throw ("attachmentSyncedModificationTime can only be set for attachment items"); 2975 } 2976 2977 switch (this.attachmentLinkMode) { 2978 case Zotero.Attachments.LINK_MODE_IMPORTED_URL: 2979 case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: 2980 break; 2981 2982 default: 2983 throw new Error("attachmentSyncedModificationTime can only be set for stored files"); 2984 } 2985 2986 if (typeof val != 'number') { 2987 throw new Error("attachmentSyncedModificationTime must be a number"); 2988 } 2989 if (parseInt(val) != val || val < 0) { 2990 throw new Error("attachmentSyncedModificationTime must be a timestamp in milliseconds"); 2991 } 2992 if (val < 10000000000) { 2993 Zotero.logError("attachmentSyncedModificationTime should be a timestamp in milliseconds " 2994 + "-- " + val + " given"); 2995 } 2996 2997 if (val == this._attachmentSyncedModificationTime) { 2998 return; 2999 } 3000 3001 if (!this._changed.attachmentData) { 3002 this._changed.attachmentData = {}; 3003 } 3004 this._changed.attachmentData.syncedModificationTime = true; 3005 this._attachmentSyncedModificationTime = val; 3006 } 3007 }); 3008 3009 3010 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncedHash', { 3011 get: function () { 3012 if (!this.isFileAttachment()) { 3013 return undefined; 3014 } 3015 return this._attachmentSyncedHash; 3016 }, 3017 set: function (val) { 3018 if (!this.isAttachment()) { 3019 throw ("attachmentSyncedHash can only be set for attachment items"); 3020 } 3021 3022 switch (this.attachmentLinkMode) { 3023 case Zotero.Attachments.LINK_MODE_IMPORTED_URL: 3024 case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: 3025 break; 3026 3027 default: 3028 throw new Error("attachmentSyncedHash can only be set for stored files"); 3029 } 3030 3031 if (val !== null && val.length != 32) { 3032 throw new Error("Invalid attachment hash '" + val + "'"); 3033 } 3034 3035 if (val == this._attachmentSyncedHash) { 3036 return; 3037 } 3038 3039 if (!this._changed.attachmentData) { 3040 this._changed.attachmentData = {}; 3041 } 3042 this._changed.attachmentData.syncedHash = true; 3043 this._attachmentSyncedHash = val; 3044 } 3045 }); 3046 3047 3048 /** 3049 * Modification time of an attachment file 3050 * 3051 * Note: This is the mod time of the file itself, not the last-known mod time 3052 * of the file on the storage server as stored in the database 3053 * 3054 * @return {Promise<Number|undefined>} File modification time as timestamp in milliseconds, 3055 * or undefined if no file 3056 */ 3057 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentModificationTime', { 3058 get: Zotero.Promise.coroutine(function* () { 3059 if (!this.isFileAttachment()) { 3060 return undefined; 3061 } 3062 3063 if (!this.id) { 3064 return undefined; 3065 } 3066 3067 var path = yield this.getFilePathAsync(); 3068 if (!path) { 3069 return undefined; 3070 } 3071 3072 var fmtime = ((yield OS.File.stat(path)).lastModificationDate).getTime(); 3073 3074 if (fmtime < 1) { 3075 Zotero.debug("File mod time " + fmtime + " is less than 1 -- interpreting as 1", 2); 3076 fmtime = 1; 3077 } 3078 3079 return fmtime; 3080 }) 3081 }); 3082 3083 3084 /** 3085 * MD5 hash of an attachment file 3086 * 3087 * Note: This is the hash of the file itself, not the last-known hash 3088 * of the file on the storage server as stored in the database 3089 * 3090 * @return {Promise<String>} - MD5 hash of file as hex string 3091 */ 3092 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentHash', { 3093 get: Zotero.Promise.coroutine(function* () { 3094 if (!this.isAttachment()) { 3095 return undefined; 3096 } 3097 3098 if (!this.id) { 3099 return undefined; 3100 } 3101 3102 var path = yield this.getFilePathAsync(); 3103 if (!path) { 3104 return undefined; 3105 } 3106 3107 return Zotero.Utilities.Internal.md5Async(path); 3108 }) 3109 }); 3110 3111 3112 3113 /** 3114 * Return plain text of attachment content 3115 * 3116 * - Currently works on HTML, PDF and plaintext attachments 3117 * - Paragraph breaks will be lost in PDF content 3118 * 3119 * @return {Promise<String>} - A promise for attachment text or empty string if unavailable 3120 */ 3121 Zotero.defineProperty(Zotero.Item.prototype, 'attachmentText', { 3122 get: Zotero.Promise.coroutine(function* () { 3123 if (!this.isAttachment()) { 3124 return undefined; 3125 } 3126 3127 if (!this.id) { 3128 return null; 3129 } 3130 3131 var file = this.getFile(); 3132 3133 if (!(yield OS.File.exists(file.path))) { 3134 file = false; 3135 } 3136 3137 var cacheFile = Zotero.Fulltext.getItemCacheFile(this); 3138 if (!file) { 3139 if (cacheFile.exists()) { 3140 var str = yield Zotero.File.getContentsAsync(cacheFile); 3141 3142 return str.trim(); 3143 } 3144 return ''; 3145 } 3146 3147 var contentType = this.attachmentContentType; 3148 if (!contentType) { 3149 contentType = yield Zotero.MIME.getMIMETypeFromFile(file); 3150 if (contentType) { 3151 this.attachmentContentType = contentType; 3152 yield this.save(); 3153 } 3154 } 3155 3156 var str; 3157 if (Zotero.Fulltext.isCachedMIMEType(contentType)) { 3158 var reindex = false; 3159 3160 if (!cacheFile.exists()) { 3161 Zotero.debug("Regenerating item " + this.id + " full-text cache file"); 3162 reindex = true; 3163 } 3164 // Fully index item if it's not yet 3165 else if (!(yield Zotero.Fulltext.isFullyIndexed(this))) { 3166 Zotero.debug("Item " + this.id + " is not fully indexed -- caching now"); 3167 reindex = true; 3168 } 3169 3170 if (reindex) { 3171 yield Zotero.Fulltext.indexItems(this.id, false); 3172 } 3173 3174 if (!cacheFile.exists()) { 3175 Zotero.debug("Cache file doesn't exist after indexing -- returning empty .attachmentText"); 3176 return ''; 3177 } 3178 str = yield Zotero.File.getContentsAsync(cacheFile); 3179 } 3180 3181 else if (contentType == 'text/html') { 3182 str = yield Zotero.File.getContentsAsync(file); 3183 str = Zotero.Utilities.unescapeHTML(str); 3184 } 3185 3186 else if (contentType == 'text/plain') { 3187 str = yield Zotero.File.getContentsAsync(file); 3188 } 3189 3190 else { 3191 return ''; 3192 } 3193 3194 return str.trim(); 3195 }) 3196 }); 3197 3198 3199 3200 /** 3201 * Returns child attachments of this item 3202 * 3203 * @param {Boolean} includeTrashed Include trashed child items 3204 * @return {Integer[]} Array of itemIDs 3205 */ 3206 Zotero.Item.prototype.getAttachments = function(includeTrashed) { 3207 if (this.isAttachment()) { 3208 throw new Error("getAttachments() cannot be called on attachment items"); 3209 } 3210 3211 this._requireData('childItems'); 3212 3213 if (!this._attachments) { 3214 return []; 3215 } 3216 3217 var cacheKey = (Zotero.Prefs.get('sortAttachmentsChronologically') ? 'chronological' : 'alphabetical') 3218 + 'With' + (includeTrashed ? '' : 'out') + 'Trashed'; 3219 3220 if (this._attachments[cacheKey]) { 3221 return this._attachments[cacheKey]; 3222 } 3223 3224 var rows = this._attachments.rows.concat(); 3225 // Remove trashed items if necessary 3226 if (!includeTrashed) { 3227 rows = rows.filter(row => !row.trashed); 3228 } 3229 // Sort by title if necessary 3230 if (!Zotero.Prefs.get('sortAttachmentsChronologically')) { 3231 var collation = Zotero.getLocaleCollation(); 3232 rows.sort((a, b) => collation.compareString(1, a.title, b.title)); 3233 } 3234 var ids = rows.map(row => row.itemID); 3235 this._attachments[cacheKey] = ids; 3236 return ids; 3237 } 3238 3239 3240 /** 3241 * Looks for attachment in the following order: oldest PDF attachment matching parent URL, 3242 * oldest non-PDF attachment matching parent URL, oldest PDF attachment not matching URL, 3243 * old non-PDF attachment not matching URL 3244 * 3245 * @return {Promise<Zotero.Item|FALSE>} - A promise for attachment item or FALSE if none 3246 */ 3247 Zotero.Item.prototype.getBestAttachment = Zotero.Promise.coroutine(function* () { 3248 if (!this.isRegularItem()) { 3249 throw ("getBestAttachment() can only be called on regular items"); 3250 } 3251 var attachments = yield this.getBestAttachments(); 3252 return attachments ? attachments[0] : false; 3253 }); 3254 3255 3256 /** 3257 * Looks for attachment in the following order: oldest PDF attachment matching parent URL, 3258 * oldest PDF attachment not matching parent URL, oldest non-PDF attachment matching parent URL, 3259 * old non-PDF attachment not matching parent URL 3260 * 3261 * @return {Promise<Zotero.Item[]>} - A promise for an array of Zotero items 3262 */ 3263 Zotero.Item.prototype.getBestAttachments = Zotero.Promise.coroutine(function* () { 3264 if (!this.isRegularItem()) { 3265 throw new Error("getBestAttachments() can only be called on regular items"); 3266 } 3267 3268 var url = this.getField('url'); 3269 3270 var sql = "SELECT IA.itemID FROM itemAttachments IA NATURAL JOIN items I " 3271 + "LEFT JOIN itemData ID ON (IA.itemID=ID.itemID AND fieldID=1) " 3272 + "LEFT JOIN itemDataValues IDV ON (ID.valueID=IDV.valueID) " 3273 + "WHERE parentItemID=? AND linkMode NOT IN (?) " 3274 + "AND IA.itemID NOT IN (SELECT itemID FROM deletedItems) " 3275 + "ORDER BY contentType='application/pdf' DESC, value=? DESC, dateAdded ASC"; 3276 var itemIDs = yield Zotero.DB.columnQueryAsync(sql, [this.id, Zotero.Attachments.LINK_MODE_LINKED_URL, url]); 3277 return this.ObjectsClass.get(itemIDs); 3278 }); 3279 3280 3281 3282 /** 3283 * Return state of best attachment 3284 * 3285 * @return {Promise<Integer>} Promise for 0 (none), 1 (present), -1 (missing) 3286 */ 3287 Zotero.Item.prototype.getBestAttachmentState = Zotero.Promise.coroutine(function* () { 3288 if (this._bestAttachmentState !== null) { 3289 return this._bestAttachmentState; 3290 } 3291 var item = yield this.getBestAttachment(); 3292 if (item) { 3293 let exists = yield item.fileExists(); 3294 return this._bestAttachmentState = exists ? 1 : -1; 3295 } 3296 return this._bestAttachmentState = 0; 3297 }); 3298 3299 3300 /** 3301 * Return cached state of best attachment for use in items view 3302 * 3303 * @return {Integer|null} 0 (none), 1 (present), -1 (missing), null (unavailable) 3304 */ 3305 Zotero.Item.prototype.getBestAttachmentStateCached = function () { 3306 return this._bestAttachmentState; 3307 } 3308 3309 3310 Zotero.Item.prototype.clearBestAttachmentState = function () { 3311 this._bestAttachmentState = null; 3312 } 3313 3314 3315 // 3316 // Methods dealing with item tags 3317 // 3318 // 3319 /** 3320 * Returns all tags assigned to an item 3321 * 3322 * @return {Array} Array of tag data in API JSON format 3323 */ 3324 Zotero.Item.prototype.getTags = function () { 3325 this._requireData('tags'); 3326 // BETTER DEEP COPY? 3327 return JSON.parse(JSON.stringify(this._changedData.tags || this._tags)); 3328 }; 3329 3330 3331 /** 3332 * Check if the item has a given tag 3333 * 3334 * @param {String} 3335 * @return {Boolean} 3336 */ 3337 Zotero.Item.prototype.hasTag = function (tagName) { 3338 this._requireData('tags'); 3339 var tags = this._changedData.tags || this._tags; 3340 return tags.some(tagData => tagData.tag == tagName); 3341 } 3342 3343 3344 /** 3345 * Get the assigned type for a given tag of the item 3346 */ 3347 Zotero.Item.prototype.getTagType = function (tagName) { 3348 this._requireData('tags'); 3349 var tags = this._changedData.tags || this._tags; 3350 for (let tag of tags) { 3351 if (tag.tag === tagName) { 3352 return tag.type ? tag.type : 0; 3353 } 3354 } 3355 return null; 3356 } 3357 3358 3359 /** 3360 * Set the item's tags 3361 * 3362 * A separate save() is required to update the database. 3363 * 3364 * @param {String[]|Object[]} tags - Array of strings or object in API JSON format 3365 * (e.g., [{tag: 'tag', type: 1}]) 3366 */ 3367 Zotero.Item.prototype.setTags = function (tags) { 3368 this._requireData('tags'); 3369 var oldTags = this._changedData.tags || this._tags; 3370 var newTags = tags.concat() 3371 // Allow array of strings 3372 .map(tag => typeof tag == 'string' ? { tag } : tag); 3373 for (let i=0; i<oldTags.length; i++) { 3374 oldTags[i] = Zotero.Tags.cleanData(oldTags[i]); 3375 } 3376 for (let i=0; i<newTags.length; i++) { 3377 newTags[i] = Zotero.Tags.cleanData(newTags[i]); 3378 } 3379 3380 // Sort to allow comparison with JSON, which maybe we'll stop doing if it's too slow 3381 var sorter = function (a, b) { 3382 if (a.type < b.type) return -1; 3383 if (a.type > b.type) return 1; 3384 return a.tag.localeCompare(b.tag); 3385 }; 3386 oldTags.sort(sorter); 3387 newTags.sort(sorter); 3388 3389 if (JSON.stringify(oldTags) == JSON.stringify(newTags)) { 3390 Zotero.debug("Tags haven't changed", 4); 3391 return; 3392 } 3393 3394 this._markFieldChange('tags', newTags); 3395 } 3396 3397 3398 /** 3399 * Add a single tag to the item. If type is 1 and an automatic tag with the same name already 3400 * exists, replace it with a manual one. 3401 * 3402 * A separate save() is required to update the database. 3403 * 3404 * @param {String} name 3405 * @param {Number} [type=0] 3406 */ 3407 Zotero.Item.prototype.addTag = function (name, type) { 3408 type = type ? parseInt(type) : 0; 3409 3410 var changed = false; 3411 var tags = this.getTags(); 3412 for (let i=0; i<tags.length; i++) { 3413 let tag = tags[i]; 3414 if (tag.tag === name) { 3415 if (tag.type == type) { 3416 Zotero.debug("Tag '" + name + "' already exists on item " + this.libraryKey); 3417 return false; 3418 } 3419 tag.type = type; 3420 changed = true; 3421 break; 3422 } 3423 } 3424 if (!changed) { 3425 tags.push({ 3426 tag: name, 3427 type: type 3428 }); 3429 } 3430 this.setTags(tags); 3431 return true; 3432 } 3433 3434 3435 /** 3436 * Replace an existing tag with a new manual tag 3437 * 3438 * A separate save() is required to update the database. 3439 * 3440 * @param {String} oldTag 3441 * @param {String} newTag 3442 */ 3443 Zotero.Item.prototype.replaceTag = function (oldTag, newTag) { 3444 var tags = this.getTags(); 3445 newTag = newTag.trim(); 3446 3447 if (newTag === "") { 3448 Zotero.debug('Not replacing with empty tag', 2); 3449 return false; 3450 } 3451 3452 var changed = false; 3453 for (let i=0; i<tags.length; i++) { 3454 let tag = tags[i]; 3455 if (tag.tag === oldTag) { 3456 tag.tag = newTag; 3457 tag.type = 0; 3458 changed = true; 3459 } 3460 } 3461 if (!changed) { 3462 Zotero.debug("Tag '" + oldTag + "' not found on item -- not replacing", 2); 3463 return false; 3464 } 3465 this.setTags(tags); 3466 return true; 3467 } 3468 3469 3470 /** 3471 * Remove a tag from the item 3472 * 3473 * A separate save() is required to update the database. 3474 */ 3475 Zotero.Item.prototype.removeTag = function(tagName) { 3476 this._requireData('tags'); 3477 var oldTags = this._changedData.tags || this._tags; 3478 var newTags = oldTags.filter(tagData => tagData.tag !== tagName); 3479 if (newTags.length == oldTags.length) { 3480 Zotero.debug('Cannot remove missing tag ' + tagName + ' from item ' + this.libraryKey); 3481 return; 3482 } 3483 this.setTags(newTags); 3484 } 3485 3486 3487 /** 3488 * Remove all tags from the item 3489 * 3490 * A separate save() is required to update the database. 3491 */ 3492 Zotero.Item.prototype.removeAllTags = function() { 3493 this._requireData('tags'); 3494 this.setTags([]); 3495 } 3496 3497 3498 // 3499 // Methods dealing with collections 3500 // 3501 /** 3502 * Gets the collections the item is in 3503 * 3504 * @return {Array<Integer>} An array of collectionIDs for all collections the item belongs to 3505 */ 3506 Zotero.Item.prototype.getCollections = function () { 3507 this._requireData('collections'); 3508 return this._collections.concat(); 3509 }; 3510 3511 3512 /** 3513 * Sets the collections the item is in 3514 * 3515 * A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes. 3516 * 3517 * @param {Array<String|Integer>} collectionIDsOrKeys Collection ids or keys 3518 */ 3519 Zotero.Item.prototype.setCollections = function (collectionIDsOrKeys) { 3520 if (!this.libraryID) { 3521 this.libraryID = Zotero.Libraries.userLibraryID; 3522 } 3523 3524 this._requireData('collections'); 3525 3526 if (!collectionIDsOrKeys) { 3527 collectionIDsOrKeys = []; 3528 } 3529 3530 // Convert any keys to ids 3531 var collectionIDs = collectionIDsOrKeys.map(function (val) { 3532 if (parseInt(val) == val) { 3533 return parseInt(val); 3534 } 3535 var id = this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, val); 3536 if (!id) { 3537 let e = new Error("Collection " + val + " not found for item " + this.libraryKey); 3538 e.name = "ZoteroMissingObjectError"; 3539 throw e; 3540 } 3541 return id; 3542 }.bind(this)); 3543 collectionIDs = Zotero.Utilities.arrayUnique(collectionIDs); 3544 3545 if (Zotero.Utilities.arrayEquals(this._collections, collectionIDs)) { 3546 Zotero.debug("Collections have not changed for item " + this.id); 3547 return; 3548 } 3549 3550 this._markFieldChange("collections", this._collections); 3551 this._collections = collectionIDs; 3552 this._changed.collections = true; 3553 }; 3554 3555 3556 /** 3557 * Add this item to a collection 3558 * 3559 * A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes. 3560 * 3561 * @param {Number} collectionID 3562 */ 3563 Zotero.Item.prototype.addToCollection = function (collectionIDOrKey) { 3564 if (!this.libraryID) { 3565 this.libraryID = Zotero.Libraries.userLibraryID; 3566 } 3567 3568 var collectionID = parseInt(collectionIDOrKey) == collectionIDOrKey 3569 ? parseInt(collectionIDOrKey) 3570 : this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, collectionIDOrKey) 3571 3572 if (!collectionID) { 3573 throw new Error("Invalid collection '" + collectionIDOrKey + "'"); 3574 } 3575 3576 this._requireData('collections'); 3577 if (this._collections.indexOf(collectionID) != -1) { 3578 Zotero.debug("Item is already in collection " + collectionID); 3579 return; 3580 } 3581 this.setCollections(this._collections.concat(collectionID)); 3582 }; 3583 3584 3585 /** 3586 * Remove this item from a collection 3587 * 3588 * A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes. 3589 * 3590 * @param {Number} collectionID 3591 */ 3592 Zotero.Item.prototype.removeFromCollection = function (collectionIDOrKey) { 3593 if (!this.libraryID) { 3594 this.libraryID = Zotero.Libraries.userLibraryID; 3595 } 3596 3597 var collectionID = parseInt(collectionIDOrKey) == collectionIDOrKey 3598 ? parseInt(collectionIDOrKey) 3599 : this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, collectionIDOrKey) 3600 3601 if (!collectionID) { 3602 throw new Error("Invalid collection '" + collectionIDOrKey + "'"); 3603 } 3604 3605 this._requireData('collections'); 3606 var pos = this._collections.indexOf(collectionID); 3607 if (pos == -1) { 3608 Zotero.debug("Item is not in collection " + collectionID); 3609 return; 3610 } 3611 this.setCollections(this._collections.slice(0, pos).concat(this._collections.slice(pos + 1))); 3612 }; 3613 3614 3615 /** 3616 * Determine whether the item belongs to a given collectionID 3617 **/ 3618 Zotero.Item.prototype.inCollection = function (collectionID) { 3619 this._requireData('collections'); 3620 return this._collections.indexOf(collectionID) != -1; 3621 }; 3622 3623 3624 /** 3625 * Update item deleted (i.e., trash) state without marking as changed or modifying DB 3626 * 3627 * This is used by Zotero.Items.trash(). 3628 * 3629 * Database state must be set separately! 3630 * 3631 * @param {Boolean} deleted 3632 */ 3633 Zotero.DataObject.prototype.setDeleted = Zotero.Promise.coroutine(function* (deleted) { 3634 if (!this.id) { 3635 throw new Error("Cannot update deleted state of unsaved item"); 3636 } 3637 3638 this._deleted = !!deleted; 3639 3640 if (this._changed.deleted) { 3641 delete this._changed.deleted; 3642 } 3643 }); 3644 3645 3646 /** 3647 * Update item publications state without marking as changed or modifying DB 3648 * 3649 * This is used by Zotero.Items.addToPublications()/removeFromPublications() 3650 * 3651 * Database state must be set separately! 3652 * 3653 * @param {Boolean} inPublications 3654 */ 3655 Zotero.DataObject.prototype.setPublications = Zotero.Promise.coroutine(function* (inPublications) { 3656 if (!this.id) { 3657 throw new Error("Cannot update publications state of unsaved item"); 3658 } 3659 3660 this._inPublications = !!inPublications; 3661 3662 if (this._changed.inPublications) { 3663 delete this._changed.inPublications; 3664 } 3665 }); 3666 3667 3668 Zotero.Item.prototype.getImageSrc = function() { 3669 var itemType = Zotero.ItemTypes.getName(this.itemTypeID); 3670 if (itemType == 'attachment') { 3671 var linkMode = this.attachmentLinkMode; 3672 3673 // Quick hack to use PDF icon for imported files and URLs -- 3674 // extend to support other document types later 3675 if ((linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE || 3676 linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) && 3677 this.attachmentContentType == 'application/pdf') { 3678 itemType += '-pdf'; 3679 } 3680 else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) { 3681 itemType += "-file"; 3682 } 3683 else if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { 3684 itemType += "-link"; 3685 } 3686 else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) { 3687 itemType += "-snapshot"; 3688 } 3689 else if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { 3690 itemType += "-web-link"; 3691 } 3692 } 3693 3694 return Zotero.ItemTypes.getImageSrc(itemType); 3695 } 3696 3697 3698 Zotero.Item.prototype.getImageSrcWithTags = Zotero.Promise.coroutine(function* () { 3699 //Zotero.debug("Generating tree image for item " + this.id); 3700 3701 var uri = this.getImageSrc(); 3702 3703 var tags = this.getTags(); 3704 if (!tags.length) { 3705 return uri; 3706 } 3707 3708 var tagColors = Zotero.Tags.getColors(this.libraryID); 3709 var colorData = []; 3710 for (let i=0; i<tags.length; i++) { 3711 let tag = tags[i]; 3712 let data = tagColors.get(tag.tag); 3713 if (data) { 3714 colorData.push(data); 3715 } 3716 } 3717 if (!colorData.length) { 3718 return uri; 3719 } 3720 colorData.sort(function (a, b) { 3721 return a.position - b.position; 3722 }); 3723 var colors = colorData.map(val => val.color); 3724 return Zotero.Tags.generateItemsListImage(colors, uri); 3725 }); 3726 3727 3728 3729 /** 3730 * Compares this item to another 3731 * 3732 * Returns a two-element array containing two objects with the differing values, 3733 * or FALSE if no differences 3734 * 3735 * @param {Zotero.Item} item Zotero.Item to compare this item to 3736 * @param {Boolean} includeMatches Include all fields, even those that aren't different 3737 * @param {Boolean} ignoreFields If no fields other than those specified 3738 * are different, just return false -- 3739 * only works for primary fields 3740 */ 3741 Zotero.Item.prototype.diff = function (item, includeMatches, ignoreFields) { 3742 var diff = []; 3743 3744 if (!ignoreFields) { 3745 ignoreFields = []; 3746 } 3747 3748 var thisData = this.serialize(); 3749 var otherData = item.serialize(); 3750 3751 var numDiffs = this.ObjectsClass.diff(thisData, otherData, diff, includeMatches); 3752 3753 diff[0].creators = []; 3754 diff[1].creators = []; 3755 // TODO: creators? 3756 // TODO: tags? 3757 // TODO: related? 3758 // TODO: annotations 3759 3760 var changed = false; 3761 3762 changed = thisData.parentKey != otherData.parentKey; 3763 if (includeMatches || changed) { 3764 diff[0].parentKey = thisData.parentKey; 3765 diff[1].parentKey = otherData.parentKey; 3766 3767 if (changed) { 3768 numDiffs++; 3769 } 3770 } 3771 3772 if (thisData.attachment) { 3773 for (var field in thisData.attachment) { 3774 changed = thisData.attachment[field] != otherData.attachment[field]; 3775 if (includeMatches || changed) { 3776 if (!diff[0].attachment) { 3777 diff[0].attachment = {}; 3778 diff[1].attachment = {}; 3779 } 3780 diff[0].attachment[field] = thisData.attachment[field]; 3781 diff[1].attachment[field] = otherData.attachment[field]; 3782 } 3783 3784 if (changed) { 3785 numDiffs++; 3786 } 3787 } 3788 } 3789 3790 if (thisData.note != undefined) { 3791 // Whitespace and entity normalization 3792 // 3793 // Ideally this would all be fixed elsewhere so we didn't have to 3794 // convert on every sync diff 3795 // 3796 // TEMP: Using a try/catch to avoid unexpected errors in 2.1 releases 3797 try { 3798 var thisNote = thisData.note; 3799 var otherNote = otherData.note; 3800 3801 // Stop non-Unix newlines from triggering erroneous conflicts 3802 thisNote = thisNote.replace(/\r\n?/g, "\n"); 3803 otherNote = otherNote.replace(/\r\n?/g, "\n"); 3804 3805 // Normalize multiple spaces (due to differences TinyMCE, Z.U.text2html(), 3806 // and the server) 3807 var re = /( | |\u00a0 |\u00a0\u00a0)/g; 3808 thisNote = thisNote.replace(re, " "); 3809 otherNote = otherNote.replace(re, " "); 3810 3811 // Normalize new paragraphs 3812 var re = /<p>( |\u00a0)<\/p>/g; 3813 thisNote = thisNote.replace(re, "<p> </p>"); 3814 otherNote = otherNote.replace(re, "<p> </p>"); 3815 3816 // Unencode XML entities 3817 thisNote = thisNote.replace(/&/g, "&"); 3818 otherNote = otherNote.replace(/&/g, "&"); 3819 thisNote = thisNote.replace(/'/g, "'"); 3820 otherNote = otherNote.replace(/'/g, "'"); 3821 thisNote = thisNote.replace(/"/g, '"'); 3822 otherNote = otherNote.replace(/"/g, '"'); 3823 thisNote = thisNote.replace(/</g, "<"); 3824 otherNote = otherNote.replace(/</g, "<"); 3825 thisNote = thisNote.replace(/>/g, ">"); 3826 otherNote = otherNote.replace(/>/g, ">"); 3827 3828 changed = thisNote != otherNote; 3829 } 3830 catch (e) { 3831 Zotero.debug(e); 3832 Components.utils.reportError(e); 3833 changed = thisNote != otherNote; 3834 } 3835 3836 if (includeMatches || changed) { 3837 diff[0].note = thisNote; 3838 diff[1].note = otherNote; 3839 } 3840 3841 if (changed) { 3842 numDiffs++; 3843 } 3844 } 3845 3846 //Zotero.debug(thisData); 3847 //Zotero.debug(otherData); 3848 //Zotero.debug(diff); 3849 3850 if (numDiffs == 0) { 3851 return false; 3852 } 3853 if (ignoreFields.length && diff[0].primary) { 3854 if (includeMatches) { 3855 throw ("ignoreFields cannot be used if includeMatches is set"); 3856 } 3857 var realDiffs = numDiffs; 3858 for (let field of ignoreFields) { 3859 if (diff[0].primary[field] != undefined) { 3860 realDiffs--; 3861 if (realDiffs == 0) { 3862 return false; 3863 } 3864 } 3865 } 3866 } 3867 3868 return diff; 3869 } 3870 3871 3872 /** 3873 * Compare multiple items against this item and return fields that differ 3874 * 3875 * Currently compares only item data, not primary fields 3876 */ 3877 Zotero.Item.prototype.multiDiff = function (otherItems, ignoreFields) { 3878 var thisData = this.toJSON(); 3879 3880 var alternatives = {}; 3881 var hasDiffs = false; 3882 3883 for (let i = 0; i < otherItems.length; i++) { 3884 let otherData = otherItems[i].toJSON(); 3885 let changeset = Zotero.DataObjectUtilities.diff(thisData, otherData, ignoreFields); 3886 3887 for (let i = 0; i < changeset.length; i++) { 3888 let change = changeset[i]; 3889 3890 if (change.op == 'delete') { 3891 continue; 3892 } 3893 3894 if (!alternatives[change.field]) { 3895 hasDiffs = true; 3896 alternatives[change.field] = [change.value]; 3897 } 3898 else if (alternatives[change.field].indexOf(change.value) == -1) { 3899 hasDiffs = true; 3900 alternatives[change.field].push(change.value); 3901 } 3902 } 3903 } 3904 3905 if (!hasDiffs) { 3906 return false; 3907 } 3908 3909 return alternatives; 3910 }; 3911 3912 3913 /** 3914 * Returns an unsaved copy of the item without itemID and key 3915 * 3916 * This is used to duplicate items and copy them between libraries. 3917 * 3918 * @param {Number} [libraryID] - libraryID of the new item, or the same as original if omitted 3919 * @param {Boolean} [options.skipTags=false] - Skip tags 3920 * @param {Boolean} [options.includeCollections=false] - Add new item to all collections 3921 * @return {Promise<Zotero.Item>} 3922 */ 3923 Zotero.Item.prototype.clone = function (libraryID, options = {}) { 3924 Zotero.debug('Cloning item ' + this.id); 3925 3926 if (libraryID !== undefined && libraryID !== null && typeof libraryID !== 'number') { 3927 throw new Error("libraryID must be null or an integer"); 3928 } 3929 3930 if (libraryID === undefined || libraryID === null) { 3931 libraryID = this.libraryID; 3932 } 3933 var sameLibrary = libraryID == this.libraryID; 3934 3935 var newItem = new Zotero.Item; 3936 newItem.libraryID = libraryID; 3937 newItem.setType(this.itemTypeID); 3938 3939 var fieldIDs = this.getUsedFields(); 3940 for (let i = 0; i < fieldIDs.length; i++) { 3941 let fieldID = fieldIDs[i]; 3942 newItem.setField(fieldID, this.getField(fieldID)); 3943 } 3944 3945 // Regular item 3946 if (this.isRegularItem()) { 3947 newItem.setCreators(this.getCreators()); 3948 } 3949 else { 3950 newItem.setNote(this.getNote()); 3951 if (sameLibrary) { 3952 var parent = this.parentKey; 3953 if (parent) { 3954 newItem.parentKey = parent; 3955 } 3956 } 3957 3958 if (this.isAttachment()) { 3959 newItem.attachmentLinkMode = this.attachmentLinkMode; 3960 newItem.attachmentContentType = this.attachmentContentType; 3961 newItem.attachmentCharset = this.attachmentCharset; 3962 if (sameLibrary) { 3963 if (this.attachmentPath) { 3964 newItem.attachmentPath = this.attachmentPath; 3965 } 3966 } 3967 } 3968 } 3969 3970 if (!options.skipTags) { 3971 newItem.setTags(this.getTags()); 3972 } 3973 3974 if (options.includeCollections) { 3975 if (!sameLibrary) { 3976 throw new Error("Can't include collections when cloning to different library"); 3977 } 3978 newItem.setCollections(this.getCollections()); 3979 } 3980 3981 if (sameLibrary) { 3982 // DEBUG: this will add reverse-only relateds too 3983 newItem.setRelations(this.getRelations()); 3984 } 3985 3986 return newItem; 3987 } 3988 3989 3990 /** 3991 * @param {Zotero.Item} item 3992 * @param {Integer} libraryID 3993 * @return {Zotero.Item} - New item 3994 */ 3995 Zotero.Item.prototype.moveToLibrary = async function (libraryID, onSkippedAttachment) { 3996 if (!this.isEditable) { 3997 throw new Error("Can't move item in read-only library"); 3998 } 3999 var library = Zotero.Libraries.get(libraryID); 4000 Zotero.debug("Moving item to " + library.name); 4001 if (!library.editable) { 4002 throw new Error("Can't move item to read-only library"); 4003 } 4004 var filesEditable = library.filesEditable; 4005 var allowsLinkedFiles = library.allowsLinkedFiles; 4006 4007 var newItem = await Zotero.DB.executeTransaction(async function () { 4008 // Create new clone item in target library 4009 var newItem = this.clone(libraryID); 4010 var newItemID = await newItem.save({ 4011 skipSelect: true 4012 }); 4013 4014 if (this.isNote()) { 4015 // Delete old item 4016 await this.erase(); 4017 return newItem; 4018 } 4019 4020 // For regular items, add child items 4021 4022 // Child notes 4023 var noteIDs = this.getNotes(); 4024 var notes = Zotero.Items.get(noteIDs); 4025 for (let note of notes) { 4026 let newNote = note.clone(libraryID); 4027 newNote.parentID = newItemID; 4028 await newNote.save({ 4029 skipSelect: true 4030 }); 4031 } 4032 4033 // Child attachments 4034 var attachmentIDs = this.getAttachments(); 4035 var attachments = Zotero.Items.get(attachmentIDs); 4036 for (let attachment of attachments) { 4037 let linkMode = attachment.attachmentLinkMode; 4038 4039 // Skip linked files if not allowed in destination 4040 if (!allowsLinkedFiles && linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { 4041 Zotero.debug("Target library doesn't support linked files -- skipping attachment"); 4042 if (onSkippedAttachment) { 4043 await onSkippedAttachment(attachment); 4044 } 4045 continue; 4046 } 4047 4048 // Skip files if not allowed in destination 4049 if (!filesEditable && linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) { 4050 Zotero.debug("Target library doesn't allow file editing -- skipping attachment"); 4051 if (onSkippedAttachment) { 4052 await onSkippedAttachment(attachment); 4053 } 4054 continue; 4055 } 4056 4057 await Zotero.Attachments.moveAttachmentToLibrary( 4058 attachment, libraryID, newItemID 4059 ); 4060 } 4061 4062 return newItem; 4063 }.bind(this)); 4064 4065 // Delete old item. Do this outside of a transaction so we don't leave stranded files 4066 // in the target library if deleting fails. 4067 await this.eraseTx(); 4068 4069 return newItem; 4070 }; 4071 4072 4073 Zotero.Item.prototype._eraseData = Zotero.Promise.coroutine(function* (env) { 4074 Zotero.DB.requireTransaction(); 4075 4076 // Remove item from parent collections 4077 var parentCollectionIDs = this._collections; 4078 for (let parentCollectionID of parentCollectionIDs) { 4079 let parentCollection = yield Zotero.Collections.getAsync(parentCollectionID); 4080 yield parentCollection.removeItem(this.id); 4081 } 4082 4083 var parentItem = this.parentKey; 4084 parentItem = parentItem 4085 ? (yield this.ObjectsClass.getByLibraryAndKeyAsync(this.libraryID, parentItem)) 4086 : null; 4087 4088 if (parentItem && !env.options.skipParentRefresh) { 4089 Zotero.Notifier.queue('refresh', 'item', parentItem.id); 4090 } 4091 4092 // // Delete associated attachment files 4093 if (this.isAttachment()) { 4094 let linkMode = this.attachmentLinkMode; 4095 // If link only, nothing to delete 4096 if (linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) { 4097 try { 4098 let file = Zotero.Attachments.getStorageDirectory(this); 4099 yield OS.File.removeDir(file.path, { 4100 ignoreAbsent: true, 4101 ignorePermissions: true 4102 }); 4103 } 4104 catch (e) { 4105 Zotero.debug(e, 2); 4106 Components.utils.reportError(e); 4107 } 4108 } 4109 4110 // Zotero.Sync.EventListeners.ChangeListener needs to know if this was a storage file 4111 env.notifierData[this.id].storageDeleteLog = this.isImportedAttachment(); 4112 } 4113 // Regular item 4114 else { 4115 let sql = "SELECT itemID FROM itemNotes WHERE parentItemID=?1 UNION " 4116 + "SELECT itemID FROM itemAttachments WHERE parentItemID=?1"; 4117 let toDelete = yield Zotero.DB.columnQueryAsync(sql, [this.id]); 4118 for (let i=0; i<toDelete.length; i++) { 4119 let obj = yield this.ObjectsClass.getAsync(toDelete[i]); 4120 // Copy all options other than 'tx', which would cause a deadlock 4121 let options = { 4122 skipParentRefresh: true 4123 }; 4124 Object.assign(options, env.options); 4125 delete options.tx; 4126 yield obj.erase(options); 4127 } 4128 } 4129 4130 // Remove related-item relations pointing to this item 4131 var relatedItems = Zotero.Relations.getByPredicateAndObject( 4132 'item', Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(this) 4133 ); 4134 for (let relatedItem of relatedItems) { 4135 relatedItem.removeRelatedItem(this); 4136 relatedItem.save(); 4137 } 4138 4139 // Clear fulltext cache 4140 if (this.isAttachment()) { 4141 yield Zotero.Fulltext.clearItemWords(this.id); 4142 //Zotero.Fulltext.clearItemContent(this.id); 4143 } 4144 4145 yield Zotero.DB.queryAsync('DELETE FROM items WHERE itemID=?', this.id); 4146 4147 if (parentItem && !env.options.skipParentRefresh) { 4148 yield parentItem.reload(['primaryData', 'childItems'], true); 4149 parentItem.clearBestAttachmentState(); 4150 } 4151 4152 Zotero.Prefs.set('purge.items', true); 4153 Zotero.Prefs.set('purge.creators', true); 4154 Zotero.Prefs.set('purge.tags', true); 4155 }); 4156 4157 4158 Zotero.Item.prototype.isCollection = function() { 4159 return false; 4160 } 4161 4162 4163 /** 4164 * Populate the object's data from an API JSON data object 4165 */ 4166 Zotero.Item.prototype.fromJSON = function (json) { 4167 if (!json.itemType && !this._itemTypeID) { 4168 throw new Error("itemType property not provided"); 4169 } 4170 4171 let itemTypeID = Zotero.ItemTypes.getID(json.itemType); 4172 if (!itemTypeID) { 4173 let e = new Error(`Invalid item type '${json.itemType}'`); 4174 e.name = "ZoteroUnknownTypeError"; 4175 throw e; 4176 } 4177 this.setType(itemTypeID); 4178 4179 var isValidForType = {}; 4180 var setFields = {}; 4181 4182 // Primary data 4183 for (let field in json) { 4184 let val = json[field]; 4185 4186 switch (field) { 4187 case 'key': 4188 case 'version': 4189 case 'synced': 4190 case 'itemType': 4191 case 'note': 4192 // Use? 4193 case 'md5': 4194 case 'mtime': 4195 // Handled below 4196 case 'collections': 4197 case 'parentItem': 4198 case 'deleted': 4199 case 'inPublications': 4200 break; 4201 4202 case 'accessDate': 4203 if (val && !Zotero.Date.isSQLDate(val)) { 4204 let d = Zotero.Date.isoToDate(val); 4205 if (!d) { 4206 Zotero.logError(`Discarding invalid ${field} '${val}' for item ${this.libraryKey}`); 4207 continue; 4208 } 4209 val = Zotero.Date.dateToSQL(d, true); 4210 } 4211 this.setField(field, val); 4212 setFields[field] = true; 4213 break; 4214 4215 case 'dateAdded': 4216 case 'dateModified': 4217 if (val) { 4218 let d = Zotero.Date.isoToDate(val); 4219 if (!d) { 4220 Zotero.logError(`Discarding invalid ${field} '${val}' for item ${this.libraryKey}`); 4221 continue; 4222 } 4223 val = Zotero.Date.dateToSQL(d, true); 4224 } 4225 this[field] = val; 4226 break; 4227 4228 case 'creators': 4229 this.setCreators(json.creators); 4230 break; 4231 4232 case 'tags': 4233 this.setTags(json.tags); 4234 break; 4235 4236 case 'relations': 4237 this.setRelations(json.relations); 4238 break; 4239 4240 // 4241 // Attachment metadata 4242 // 4243 case 'linkMode': 4244 this.attachmentLinkMode = Zotero.Attachments["LINK_MODE_" + val.toUpperCase()]; 4245 break; 4246 4247 case 'contentType': 4248 this.attachmentContentType = val; 4249 break; 4250 4251 case 'charset': 4252 this.attachmentCharset = val; 4253 break; 4254 4255 case 'filename': 4256 if (val === "") { 4257 Zotero.logError("Ignoring empty attachment filename in JSON for item " + this.libraryKey); 4258 } 4259 else { 4260 this.attachmentFilename = val; 4261 } 4262 break; 4263 4264 case 'path': 4265 this.attachmentPath = val; 4266 break; 4267 4268 // Item fields 4269 default: 4270 let fieldID = Zotero.ItemFields.getID(field); 4271 if (!fieldID) { 4272 Zotero.logError("Discarding unknown JSON field '" + field + "' for item " 4273 + this.libraryKey); 4274 continue; 4275 } 4276 // Convert to base-mapped field if necessary, so that setFields has the base-mapped field 4277 // when it's checked for values from getUsedFields() below 4278 let origFieldID = fieldID; 4279 let origField = field; 4280 fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID) || fieldID; 4281 if (origFieldID != fieldID) { 4282 field = Zotero.ItemFields.getName(fieldID); 4283 } 4284 isValidForType[field] = Zotero.ItemFields.isValidForType(fieldID, this.itemTypeID); 4285 if (!isValidForType[field]) { 4286 Zotero.logError("Discarding invalid field '" + origField + "' for type " + itemTypeID 4287 + " for item " + this.libraryKey); 4288 continue; 4289 } 4290 this.setField(field, json[origField]); 4291 setFields[field] = true; 4292 } 4293 } 4294 4295 if (json.collections || this._collections.length) { 4296 this.setCollections(json.collections); 4297 } 4298 4299 // Clear existing fields not specified 4300 var previousFields = this.getUsedFields(true); 4301 for (let field of previousFields) { 4302 if (!setFields[field] && isValidForType[field] !== false) { 4303 this.setField(field, false); 4304 } 4305 } 4306 4307 // Both notes and attachments might have parents and notes 4308 if (this.isNote() || this.isAttachment()) { 4309 let parentKey = json.parentItem; 4310 this.parentKey = parentKey ? parentKey : false; 4311 4312 let note = json.note; 4313 this.setNote(note !== undefined ? note : ""); 4314 } 4315 4316 // Update boolean fields that might not be present in JSON 4317 ['deleted', 'inPublications'].forEach(field => { 4318 if (json[field] || this[field]) { 4319 this[field] = !!json[field]; 4320 } 4321 }); 4322 } 4323 4324 4325 /** 4326 * @param {Object} options 4327 */ 4328 Zotero.Item.prototype.toJSON = function (options = {}) { 4329 var env = this._preToJSON(options); 4330 var mode = env.mode; 4331 4332 var obj = env.obj = {}; 4333 obj.key = this.key; 4334 obj.version = this.version; 4335 obj.itemType = Zotero.ItemTypes.getName(this.itemTypeID); 4336 4337 // Fields 4338 for (let i in this._itemData) { 4339 let val = this.getField(i) + ''; 4340 if (val !== '' || mode == 'full') { 4341 obj[Zotero.ItemFields.getName(i)] = val; 4342 } 4343 } 4344 4345 // Creators 4346 if (this.isRegularItem()) { 4347 obj.creators = this.getCreatorsJSON(); 4348 } 4349 else { 4350 var parent = this.parentKey; 4351 if (parent || mode == 'full') { 4352 obj.parentItem = parent ? parent : false; 4353 } 4354 4355 // Attachment fields 4356 if (this.isAttachment()) { 4357 let linkMode = this.attachmentLinkMode; 4358 obj.linkMode = Zotero.Attachments.linkModeToName(linkMode); 4359 4360 obj.contentType = this.attachmentContentType; 4361 obj.charset = this.attachmentCharset; 4362 4363 if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { 4364 obj.path = this.attachmentPath; 4365 } 4366 else if (linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) { 4367 obj.filename = this.attachmentFilename; 4368 } 4369 4370 if (this.isImportedAttachment() && !options.skipStorageProperties) { 4371 if (options.syncedStorageProperties) { 4372 obj.mtime = this.attachmentSyncedModificationTime; 4373 obj.md5 = this.attachmentSyncedHash; 4374 } 4375 else { 4376 // TEMP 4377 //obj.mtime = (yield this.attachmentModificationTime) || null; 4378 //obj.md5 = (yield this.attachmentHash) || null; 4379 } 4380 } 4381 } 4382 4383 // Notes and embedded attachment notes 4384 let note = this.getNote(); 4385 if (note !== "" || mode == 'full' || (mode == 'new' && this.isNote())) { 4386 obj.note = note; 4387 } 4388 } 4389 4390 // Tags 4391 obj.tags = []; 4392 var tags = this.getTags(); 4393 for (let i=0; i<tags.length; i++) { 4394 obj.tags.push(tags[i]); 4395 } 4396 4397 // Collections 4398 if (this.isTopLevelItem()) { 4399 obj.collections = this.getCollections().map(function (id) { 4400 var { libraryID, key } = this.ContainerObjectsClass.getLibraryAndKeyFromID(id); 4401 if (!key) { 4402 throw new Error("Item collection " + id + " not found"); 4403 } 4404 return key; 4405 }.bind(this)); 4406 } 4407 4408 // My Publications 4409 if (this._inPublications 4410 // Include in 'full' mode, but only in My Library 4411 || (mode == 'full' && this.library && this.library.libraryType == 'user')) { 4412 obj.inPublications = this._inPublications; 4413 } 4414 4415 // Deleted 4416 let deleted = this.deleted; 4417 if (deleted || mode == 'full') { 4418 // Match what APIv3 returns, though it would be good to change this 4419 obj.deleted = deleted ? 1 : 0; 4420 } 4421 4422 // Relations 4423 obj.relations = this.getRelations() 4424 4425 if (obj.accessDate) obj.accessDate = Zotero.Date.sqlToISO8601(obj.accessDate); 4426 4427 if (this.dateAdded) { 4428 obj.dateAdded = Zotero.Date.sqlToISO8601(this.dateAdded); 4429 } 4430 if (this.dateModified) { 4431 obj.dateModified = Zotero.Date.sqlToISO8601(this.dateModified); 4432 } 4433 4434 var json = this._postToJSON(env); 4435 if (options.skipStorageProperties) { 4436 delete json.md5; 4437 delete json.mtime; 4438 } 4439 return json; 4440 } 4441 4442 4443 Zotero.Item.prototype.toResponseJSON = function (options = {}) { 4444 // Default to showing synced storage properties, since that's what the API does, and this function 4445 // is generally used to emulate the API 4446 if (options.syncedStorageProperties === undefined) { 4447 options.syncedStorageProperties = true; 4448 } 4449 4450 var json = this.constructor._super.prototype.toResponseJSON.call(this, options); 4451 4452 // creatorSummary 4453 var firstCreator = this.getField('firstCreator'); 4454 if (firstCreator) { 4455 json.meta.creatorSummary = firstCreator; 4456 } 4457 // parsedDate 4458 var parsedDate = Zotero.Date.multipartToSQL(this.getField('date', true, true)); 4459 if (parsedDate) { 4460 // 0000? 4461 json.meta.parsedDate = parsedDate; 4462 } 4463 // numChildren 4464 if (this.isRegularItem()) { 4465 json.meta.numChildren = this.numChildren(); 4466 } 4467 return json; 4468 }; 4469 4470 4471 ////////////////////////////////////////////////////////////////////////////// 4472 // 4473 // Asynchronous load methods 4474 // 4475 ////////////////////////////////////////////////////////////////////////////// 4476 4477 4478 /** 4479 * Return an item in the specified library equivalent to this item 4480 * 4481 * @return {Promise<Zotero.Item>} 4482 */ 4483 Zotero.Item.prototype.getLinkedItem = function (libraryID, bidirectional) { 4484 return this._getLinkedObject(libraryID, bidirectional); 4485 }; 4486 4487 4488 /** 4489 * Add a linked-object relation pointing to the given item 4490 * 4491 * Does not require a separate save() 4492 * 4493 * @return {Promise} 4494 */ 4495 Zotero.Item.prototype.addLinkedItem = Zotero.Promise.coroutine(function* (item) { 4496 return this._addLinkedObject(item); 4497 }); 4498 4499 4500 ////////////////////////////////////////////////////////////////////////////// 4501 // 4502 // Private methods 4503 // 4504 ////////////////////////////////////////////////////////////////////////////// 4505 /** 4506 * Returns related items this item points to 4507 * 4508 * @return {String[]} - Keys of related items 4509 */ 4510 Zotero.Item.prototype._getRelatedItems = function () { 4511 this._requireData('relations'); 4512 4513 var predicate = Zotero.Relations.relatedItemPredicate; 4514 4515 var relatedItemURIs = this.getRelationsByPredicate(predicate); 4516 4517 // Pull out object values from related-item relations, turn into items, and pull out keys 4518 var keys = []; 4519 for (let i=0; i<relatedItemURIs.length; i++) { 4520 let {libraryID, key} = Zotero.URI.getURIItemLibraryKey(relatedItemURIs[i]); 4521 if (key) { 4522 keys.push(key); 4523 } 4524 } 4525 return keys; 4526 } 4527 4528 4529 /** 4530 * @return {Object} Return a copy of the creators, with additional 'id' properties 4531 */ 4532 Zotero.Item.prototype._getOldCreators = function () { 4533 var oldCreators = {}; 4534 for (i=0; i<this._creators.length; i++) { 4535 let old = {}; 4536 for (let field in this._creators[i]) { 4537 old[field] = this._creators[i][field]; 4538 } 4539 // Add 'id' property for efficient DB updates 4540 old.id = this._creatorIDs[i]; 4541 oldCreators[i] = old; 4542 } 4543 return oldCreators; 4544 }