zotero/chrome/content/zotero/xpcom/data/item.js
Dan Stillman 08d3880d88 Don't remove relations in annotation item JSON
This was causing UUIDs for annotations from Mendeley imports to be
removed after a sync, resulting in annotations being duplicated on a
reimport.
2022-04-14 06:37:30 -04:00

5564 lines
152 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2009 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
/*
* Constructor for Item object
*/
Zotero.Item = function(itemTypeOrID) {
if (arguments[1] || arguments[2]) {
throw ("Zotero.Item constructor only takes one parameter");
}
Zotero.Item._super.apply(this);
this._disabled = false;
// loadPrimaryData (additional properties in dataObject.js)
this._itemTypeID = null;
this._createdByUserID = null;
this._lastModifiedByUserID = null;
this._firstCreator = null;
this._sortCreator = null;
this._attachmentCharset = null;
this._attachmentLinkMode = null;
this._attachmentContentType = null;
this._attachmentPath = null;
this._attachmentSyncState = 0;
this._attachmentSyncedModificationTime = null;
this._attachmentSyncedHash = null;
this._attachmentLastProcessedModificationTime = null;
// loadCreators
this._creators = [];
this._creatorIDs = [];
// loadItemData
this._itemData = null;
this._noteTitle = null;
this._noteText = null;
this._displayTitle = null;
// loadChildItems
this._attachments = null;
this._notes = null;
// loadAnnotation
this._annotationType = null;
this._annotationAuthorName = null;
this._annotationText = null;
this._annotationImage = null;
this._annotationComment = null;
this._annotationColor = null;
this._annotationPageLabel = null;
this._annotationSortIndex = null;
this._annotationPosition = null;
this._annotationIsExternal = null;
this._tags = [];
this._collections = [];
this._bestAttachmentState = null;
this._fileExists = null;
this._hasNote = null;
this._noteAccessTime = null;
if (itemTypeOrID) {
// setType initializes type-specific properties in this._itemData
this.setType(Zotero.ItemTypes.getID(itemTypeOrID));
}
}
Zotero.extendClass(Zotero.DataObject, Zotero.Item);
Zotero.Item.prototype._objectType = 'item';
Zotero.defineProperty(Zotero.Item.prototype, 'ContainerObjectsClass', {
get: function() { return Zotero.Collections; }
});
Zotero.Item.prototype._dataTypes = Zotero.Item._super.prototype._dataTypes.concat([
'creators',
'itemData',
'note',
'annotation',
'annotationDeferred',
'childItems',
// 'relatedItems', // TODO: remove
'tags',
'collections',
'relations'
]);
Zotero.defineProperty(Zotero.Item.prototype, 'id', {
get: function() { return this._id; },
set: function(val) { return this.setField('id', val); }
});
Zotero.defineProperty(Zotero.Item.prototype, 'itemID', {
get: function() {
Zotero.debug("Item.itemID is deprecated -- use Item.id");
return this._id;
},
enumerable: false
});
for (let name of ['libraryID', 'key', 'dateAdded', 'dateModified', 'version', 'synced',
'createdByUserID', 'lastModifiedByUserID']) {
let prop = '_' + name;
Zotero.defineProperty(Zotero.Item.prototype, name, {
get: function () { return this[prop]; },
set: function (val) { return this.setField(name, val); }
});
}
Zotero.defineProperty(Zotero.Item.prototype, 'itemTypeID', {
get: function() { return this._itemTypeID; }
});
Zotero.defineProperty(Zotero.Item.prototype, 'itemType', {
get: function() { return Zotero.ItemTypes.getName(this._itemTypeID); }
});
// .parentKey and .parentID defined in dataObject.js, but create aliases
Zotero.defineProperty(Zotero.Item.prototype, 'parentItemID', {
get: function() { return this.parentID; },
set: function(val) { return this.parentID = val; }
});
Zotero.defineProperty(Zotero.Item.prototype, 'parentItemKey', {
get: function() { return this.parentKey; },
set: function(val) { return this.parentKey = val; }
});
Zotero.defineProperty(Zotero.Item.prototype, 'parentItem', {
get: function() { return Zotero.Items.get(this.parentID) || undefined; },
});
Zotero.defineProperty(Zotero.Item.prototype, 'firstCreator', {
get: function() { return this._firstCreator; }
});
Zotero.defineProperty(Zotero.Item.prototype, 'sortCreator', {
get: function() { return this._sortCreator; }
});
Zotero.defineProperty(Zotero.Item.prototype, 'relatedItems', {
get: function() { return this._getRelatedItems(); }
});
Zotero.defineProperty(Zotero.Item.prototype, 'treeViewID', {
get: function () {
return this.id
}
});
Zotero.Item.prototype.getID = function() {
Zotero.debug('Item.getID() is deprecated -- use Item.id');
return this._id;
}
Zotero.Item.prototype.getType = function() {
Zotero.debug('Item.getType() is deprecated -- use Item.itemTypeID');
return this._itemTypeID;
}
Zotero.Item.prototype.isPrimaryField = function (fieldName) {
Zotero.debug("Zotero.Item.isPrimaryField() is deprecated -- use Zotero.Items.isPrimaryField()");
return this.ObjectsClass.isPrimaryField(fieldName);
}
Zotero.Item.prototype._get = function () {
throw new Error("_get is not valid for items");
}
Zotero.Item.prototype._set = function () {
throw new Error("_set is not valid for items");
}
Zotero.Item.prototype._setParentKey = function() {
if (!this.isNote() && !this.isAttachment() && !this.isAnnotation()) {
throw new Error("_setParentKey() can only be called on items of type 'note', 'attachment', or 'annotation'");
}
Zotero.Item._super.prototype._setParentKey.apply(this, arguments);
}
//////////////////////////////////////////////////////////////////////////////
//
// Public Zotero.Item methods
//
//////////////////////////////////////////////////////////////////////////////
/*
* Retrieves an itemData field value
*
* @param {String|Integer} field fieldID or fieldName
* @param {Boolean} [unformatted] Skip any special processing of DB value
* (e.g. multipart date field)
* @param {Boolean} includeBaseMapped If true and field is a base field, returns
* value of type-specific field instead
* (e.g. 'label' for 'publisher' in 'audioRecording')
* @return {String} Value as string or empty string if value is not present
*/
Zotero.Item.prototype.getField = function(field, unformatted, includeBaseMapped) {
if (field != 'id') this._disabledCheck();
//Zotero.debug('Requesting field ' + field + ' for item ' + this._id, 4);
this._requireData('primaryData');
// TODO: Add sortCreator
if (field === 'firstCreator' && !this._id) {
// Hack to get a firstCreator for an unsaved item
let creatorsData = this.getCreators(true);
return Zotero.Items.getFirstCreatorFromData(this.itemTypeID, creatorsData);
} else if (field === 'id' || this.ObjectsClass.isPrimaryField(field)) {
var privField = '_' + field;
//Zotero.debug('Returning ' + (this[privField] ? this[privField] : '') + ' (typeof ' + typeof this[privField] + ')');
return this[privField];
} else if (field == 'year') {
return this.getField('date', true, true).substr(0,4);
}
if (this.isNote()) {
switch (Zotero.ItemFields.getName(field)) {
case 'title':
return this.getNoteTitle();
default:
return '';
}
}
if (includeBaseMapped) {
var fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(
this._itemTypeID, field
);
}
if (!fieldID) {
var fieldID = Zotero.ItemFields.getID(field);
}
let value = this._itemData[fieldID];
if (value === undefined) {
//Zotero.debug("Field '" + field + "' doesn't exist for item type " + this._itemTypeID + " in Item.getField()");
return '';
}
// If the item is identified (has an id or key), this field has to be populated
if (this._identified && value === null && !this._loaded.itemData) {
throw new Zotero.Exception.UnloadedDataException(
"Item data not loaded and field '" + field + "' not set for item " + this.libraryKey,
"itemData"
);
}
value = (value !== null && value !== false) ? value : '';
if (!unformatted) {
// Multipart date fields
if (Zotero.ItemFields.isDate(fieldID)) {
value = Zotero.Date.multipartToStr(value);
}
}
//Zotero.debug('Returning ' + value);
return value;
}
Zotero.Item.prototype.getExtraField = function (fieldName) {
var { fields } = Zotero.Utilities.Internal.extractExtraFields(this.getField('extra'));
return fields.get(fieldName) || '';
};
/**
* @param {Boolean} asNames
* @return {Integer[]|String[]}
*/
Zotero.Item.prototype.getUsedFields = function(asNames) {
this._requireData('itemData');
return Object.keys(this._itemData)
.filter(id => this._itemData[id] !== false && this._itemData[id] !== null)
.map(id => asNames ? Zotero.ItemFields.getName(id) : parseInt(id));
};
/*
* Populate basic item data from a database row
*/
Zotero.Item.prototype.loadFromRow = function(row, reload) {
// If necessary or reloading, set the type and reinitialize this._itemData
if (reload || (!this._itemTypeID && row.itemTypeID)) {
this.setType(row.itemTypeID, true);
}
this._parseRowData(row);
this._finalizeLoadFromRow(row);
}
Zotero.Item.prototype._parseRowData = function(row) {
var primaryFields = this.ObjectsClass.primaryFields;
for (let i=0; i<primaryFields.length; i++) {
let col = primaryFields[i];
try {
var val = row[col];
}
catch (e) {
Zotero.debug('Skipping missing field ' + col);
continue;
}
//Zotero.debug("Setting field '" + col + "' to '" + val + "' for item " + this.id);
switch (col) {
// Unchanged
case 'libraryID':
case 'itemTypeID':
case 'attachmentSyncState':
case 'attachmentSyncedModificationTime':
case 'attachmentSyncedHash':
case 'attachmentLastProcessedModificationTime':
case 'createdByUserID':
case 'lastModifiedByUserID':
break;
case 'itemID':
col = 'id';
break;
// Integer or 0
case 'version':
val = val ? parseInt(val) : 0;
break;
// Value or false
case 'parentKey':
val = val || false;
break;
// Integer or false if falsy
case 'parentID':
val = val ? parseInt(val) : false;
break;
case 'attachmentLinkMode':
val = val !== null
? parseInt(val)
// Shouldn't happen
: Zotero.Attachments.LINK_MODE_IMPORTED_URL;
break;
case 'attachmentPath':
// Ignore .zotero* files that were relinked before we started blocking them
if (!val || val.startsWith('.zotero')) {
val = '';
}
break;
// Boolean
case 'synced':
case 'deleted':
case 'inPublications':
val = !!val;
break;
default:
val = val ? val : '';
}
this['_' + col] = val;
}
}
Zotero.Item.prototype._finalizeLoadFromRow = function(row) {
this._loaded.primaryData = true;
this._clearChanged('primaryData');
this._clearChanged('attachmentData');
this._identified = true;
}
/*
* Set or change the item's type
*/
Zotero.Item.prototype.setType = function(itemTypeID, loadIn) {
if (itemTypeID == this._itemTypeID) {
return true;
}
// Adjust 'note' data type based on whether the item is an attachment or note
var isAttachment = Zotero.ItemTypes.getID('attachment') == itemTypeID;
var isNote = Zotero.ItemTypes.getID('note') == itemTypeID;
this._skipDataTypeLoad.note = !(isAttachment || isNote);
var oldItemTypeID = this._itemTypeID;
if (oldItemTypeID) {
if (loadIn) {
throw new Error('Cannot change type in loadIn mode');
}
// Changing the item type can affect fields and creators, so they need to be loaded
this._requireData('itemData');
this._requireData('creators');
var copiedFields = [];
var newNotifierFields = [];
// Special cases handled below
var bookTypeID = Zotero.ItemTypes.getID('book');
var bookSectionTypeID = Zotero.ItemTypes.getID('bookSection');
var obsoleteFields = this.getFieldsNotInType(itemTypeID);
if (obsoleteFields) {
// Move bookTitle to title and clear short title when going from
// bookSection to book if there's not also a title
if (oldItemTypeID == bookSectionTypeID && itemTypeID == bookTypeID) {
var titleFieldID = Zotero.ItemFields.getID('title');
var bookTitleFieldID = Zotero.ItemFields.getID('bookTitle');
var shortTitleFieldID = Zotero.ItemFields.getID('shortTitle');
if (this._itemData[bookTitleFieldID] && !this._itemData[titleFieldID]) {
copiedFields.push([titleFieldID, this._itemData[bookTitleFieldID]]);
newNotifierFields.push(titleFieldID);
if (this._itemData[shortTitleFieldID]) {
this.setField(shortTitleFieldID, false);
}
}
}
for (let oldFieldID of obsoleteFields) {
// Try to get a base type for this field
var baseFieldID =
Zotero.ItemFields.getBaseIDFromTypeAndField(oldItemTypeID, oldFieldID);
if (baseFieldID) {
var newFieldID =
Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, baseFieldID);
// If so, save value to copy to new field
if (newFieldID) {
copiedFields.push([newFieldID, this.getField(oldFieldID)]);
}
}
// Clear old field
/*
delete this._itemData[oldFieldID];
if (!this._changed.itemData) {
this._changed.itemData = {};
}
this._changed.itemData[oldFieldID] = true;
*/
this.setField(oldFieldID, false);
}
}
// Move title to bookTitle and clear shortTitle when going from book to bookSection
if (oldItemTypeID == bookTypeID && itemTypeID == bookSectionTypeID) {
var titleFieldID = Zotero.ItemFields.getID('title');
var bookTitleFieldID = Zotero.ItemFields.getID('bookTitle');
var shortTitleFieldID = Zotero.ItemFields.getID('shortTitle');
if (this._itemData[titleFieldID]) {
copiedFields.push([bookTitleFieldID, this._itemData[titleFieldID]]);
newNotifierFields.push(bookTitleFieldID);
this.setField(titleFieldID, false);
}
if (this._itemData[shortTitleFieldID]) {
this.setField(shortTitleFieldID, false);
}
}
for (var fieldID in this._itemData) {
if (this._itemData[fieldID] &&
(!obsoleteFields || obsoleteFields.indexOf(fieldID) == -1)) {
copiedFields.push([fieldID, this.getField(fieldID)]);
}
}
}
this._itemTypeID = itemTypeID;
// If there's an existing type
if (oldItemTypeID) {
// Reset custom creator types to the default
let creators = this.getCreators();
if (creators.length) {
let removeAll = !Zotero.CreatorTypes.itemTypeHasCreators(itemTypeID);
for (let i = 0; i < this.getCreators().length; i++) {
// Remove all creators if new item type doesn't have any
if (removeAll) {
throw new Error("Disabled");
this.removeCreator(i);
i--;
continue;
}
if (!Zotero.CreatorTypes.isValidForItemType(creators[i].creatorTypeID, itemTypeID)) {
// Convert existing primary creator type to new item type's
// primary creator type, or contributor (creatorTypeID 2)
// if none or not currently primary
let oldPrimary = Zotero.CreatorTypes.getPrimaryIDForType(oldItemTypeID);
let newPrimary = false;
if (oldPrimary == creators[i].creatorTypeID) {
newPrimary = Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID);
}
creators[i].creatorTypeID = newPrimary ? newPrimary : 2;
this.setCreator(i, creators[i]);
}
}
}
}
// Initialize this._itemData with type-specific fields
this._itemData = {};
var fields = Zotero.ItemFields.getItemTypeFields(itemTypeID);
for (let fieldID of fields) {
this._itemData[fieldID] = null;
}
// DEBUG: clear change item data?
if (copiedFields) {
for (let f of copiedFields) {
// For fields that we moved to different fields in the new type
// (e.g., book -> bookTitle), mark the old value as explicitly
// false in previousData (since otherwise it would be null)
if (newNotifierFields.indexOf(f[0]) != -1) {
this._markFieldChange(Zotero.ItemFields.getName(f[0]), false);
this.setField(f[0], f[1]);
}
// For fields that haven't changed, clear from previousData
// after setting
else {
this.setField(f[0], f[1]);
this._clearFieldChange(Zotero.ItemFields.getName(f[0]));
}
}
}
if (loadIn) {
this._loaded['itemData'] = false;
}
else {
if (oldItemTypeID) {
this._markFieldChange('itemType', Zotero.ItemTypes.getName(oldItemTypeID));
}
if (!this._changed.primaryData) {
this._changed.primaryData = {};
}
this._changed.primaryData.itemTypeID = true;
}
return true;
}
/*
* Find existing fields from current type that aren't in another
*
* If _allowBaseConversion_, don't return fields that can be converted
* via base fields (e.g. label => publisher => studio)
*/
Zotero.Item.prototype.getFieldsNotInType = function (itemTypeID, allowBaseConversion) {
var fieldIDs = [];
for (var field in this._itemData) {
if (this._itemData[field]) {
var fieldID = Zotero.ItemFields.getID(field);
if (Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) {
continue;
}
if (allowBaseConversion) {
var baseID = Zotero.ItemFields.getBaseIDFromTypeAndField(this.itemTypeID, field);
if (baseID) {
var newFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, baseID);
if (newFieldID) {
continue;
}
}
}
fieldIDs.push(fieldID);
}
}
if (!fieldIDs.length) {
return false;
}
return fieldIDs;
}
/*
* Set a field value, loading existing itemData first if necessary
*
* Field can be passed as fieldID or fieldName
*/
Zotero.Item.prototype.setField = function(field, value, loadIn) {
this._disabledCheck();
if (value === undefined) {
throw new Error(`'${field}' value cannot be undefined`);
}
//Zotero.debug("Setting field '" + field + "' to '" + value + "' (loadIn: " + (loadIn ? 'true' : 'false') + ") for item " + this.id + " ");
if (!field) {
throw new Error("Field not specified");
}
if (field == 'id' || field == 'libraryID' || field == 'key') {
return this._setIdentifier(field, value);
}
// Primary field
if (this.ObjectsClass.isPrimaryField(field)) {
this._requireData('primaryData');
if (loadIn) {
throw new Error('Cannot set primary field ' + field + ' in loadIn mode in Zotero.Item.setField()');
}
switch (field) {
case 'itemTypeID':
if (typeof value != 'number' || value != parseInt(value)) {
throw new Error(`${field} must be a number`);
}
break;
case 'dateAdded':
case 'dateModified':
// Accept ISO dates
if (Zotero.Date.isISODate(value)) {
let d = Zotero.Date.isoToDate(value);
value = Zotero.Date.dateToSQL(d, true);
}
// Make sure it's valid
let date = Zotero.Date.sqlToDate(value, true);
if (!date) throw new Error("Invalid SQL date: " + value);
value = Zotero.Date.dateToSQL(date, true);
break;
case 'version':
if (typeof value != 'number' || value != parseInt(value)) {
throw new Error(`${field} must be a number`);
}
break;
case 'synced':
if (typeof value != 'boolean') {
throw new Error(`${field} must be a boolean`);
}
value = !!value;
break;
case 'createdByUserID':
case 'lastModifiedByUserID':
if (typeof value != 'number' || value != parseInt(value)) {
throw new Error(`${field} must be a number`);
}
if (!this._libraryID) {
throw new Error(`libraryID must be set before setting ${field}`);
}
if (Zotero.Libraries.get(this._libraryID).libraryType != 'group') {
throw new Error(`${field} is only valid for group library items`);
}
break;
default:
throw new Error('Primary field ' + field + ' cannot be changed in Zotero.Item.setField()');
}
/*
if (!Zotero.ItemFields.validate(field, value)) {
throw("Value '" + value + "' of type " + typeof value + " does not validate for field '" + field + "' in Zotero.Item.setField()");
}
*/
// If field value has changed
if (this['_' + field] === value) {
if (field == 'synced') {
Zotero.debug("Setting synced to " + value);
}
else {
Zotero.debug("Field '" + field + "' has not changed", 4);
return false;
}
}
else {
Zotero.debug("Field '" + field + "' has changed from '" + this['_' + field] + "' to '" + value + "'", 4);
}
// Save a copy of the field before modifying
this._markFieldChange(field, this['_' + field]);
if (field == 'itemTypeID') {
this.setType(value, loadIn);
}
else {
this['_' + field] = value;
if (!this._changed.primaryData) {
this._changed.primaryData = {};
}
this._changed.primaryData[field] = true;
}
return true;
}
// Normalize values
if (typeof value == 'number') {
value = "" + value;
}
else if (typeof value == 'string') {
value = value.trim().normalize();
}
if (value === "" || value === null || value === false) {
value = false;
}
if (!loadIn) {
this._requireData('itemData');
}
let itemTypeID = this.itemTypeID;
if (!itemTypeID) {
throw new Error('Item type must be set before setting field data');
}
var fieldID = Zotero.ItemFields.getID(field);
if (!fieldID) {
throw new Error('"' + field + '" is not a valid itemData field');
}
if (loadIn && this.isNote() && field == Zotero.ItemFields.getID('title')) {
this._noteTitle = value ? value : "";
return true;
}
// Make sure to use type-specific field ID if available
fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID) || fieldID;
if (value !== false && !Zotero.ItemFields.isValidForType(fieldID, itemTypeID)) {
let msg = "'" + field + "' is not a valid field for type '"
+ Zotero.ItemFields.getName(itemTypeID) + "'";
if (loadIn) {
Zotero.debug(msg + " -- ignoring value '" + value + "'", 2);
return false;
}
else {
throw new Error(msg);
}
}
// If not a multiline field, strip newlines
if (typeof value == 'string' && !Zotero.ItemFields.isMultiline(fieldID)) {
value = value.replace(/[\r\n]+/g, " ");;
}
if (fieldID == Zotero.ItemFields.getID('ISBN')) {
// Hyphenate ISBNs, but only if everything is in expected format and valid
let isbns = ('' + value).trim().split(/\s*[,;]\s*|\s+/),
newISBNs = '',
failed = false;
for (let i=0; i<isbns.length; i++) {
let isbn = Zotero.Utilities.Internal.hyphenateISBN(isbns[i]);
if (!isbn) {
failed = true;
break;
}
newISBNs += ' ' + isbn;
}
if (!failed) value = newISBNs.substr(1);
}
if (!loadIn) {
// Save date field as multipart date
if (value !== false
&& (Zotero.ItemFields.isDate(fieldID))
&& !Zotero.Date.isMultipart(value)) {
value = Zotero.Date.strToMultipart(value);
}
// Validate access date
else if (fieldID == Zotero.ItemFields.getID('accessDate')) {
if (value && value != 'CURRENT_TIMESTAMP') {
// Accept ISO dates
if (Zotero.Date.isISODate(value) && !Zotero.Date.isSQLDate(value)) {
let d = Zotero.Date.isoToDate(value);
value = Zotero.Date.dateToSQL(d, true);
}
if (!Zotero.Date.isSQLDate(value) && !Zotero.Date.isSQLDateTime(value)) {
Zotero.logError(`Discarding invalid ${Zotero.ItemFields.getName(field)} '${value}' `
+ `for item ${this.libraryKey} in setField()`);
return false;
}
}
}
// If existing value, make sure it's actually changing
if ((this._itemData[fieldID] === null && value === false)
|| (this._itemData[fieldID] !== null && this._itemData[fieldID] === value)) {
return false;
}
// Save a copy of the field before modifying
this._markFieldChange(
Zotero.ItemFields.getName(field), this._itemData[fieldID]
);
}
this._itemData[fieldID] = value;
if (!loadIn) {
if (!this._changed.itemData) {
this._changed.itemData = {};
}
this._changed.itemData[fieldID] = true;
}
return true;
}
/*
* Get the title for an item for display in the interface
*
* This is the same as the standard title field (with includeBaseMapped on)
* except for letters and interviews, which get placeholder titles in
* square braces (e.g. "[Letter to Thoreau]"), and cases
*/
Zotero.Item.prototype.getDisplayTitle = function (includeAuthorAndDate) {
if (this._displayTitle !== null) {
return this._displayTitle;
}
return this._displayTitle = this.getField('title', false, true);
}
/**
* Update the generated display title from the loaded data
*/
Zotero.Item.prototype.updateDisplayTitle = function () {
var title = this.getField('title', false, true);
var itemTypeID = this.itemTypeID;
var itemTypeName = Zotero.ItemTypes.getName(itemTypeID);
var itemTypeLetter = Zotero.ItemTypes.getID('letter');
var itemTypeInterview = Zotero.ItemTypes.getID('interview');
var itemTypeCase = Zotero.ItemTypes.getID('case');
var creatorTypeAuthor = Zotero.CreatorTypes.getID('author');
var creatorTypeRecipient = Zotero.CreatorTypes.getID('recipient');
var creatorTypeInterviewer = Zotero.CreatorTypes.getID('interviewer');
var creatorTypeInterviewee = Zotero.CreatorTypes.getID('interviewee');
// 'letter' and 'interview'
if (title === "" && (itemTypeID == itemTypeLetter || itemTypeID == itemTypeInterview)) {
var creatorsData = this.getCreators();
var authors = [];
var participants = [];
for (let i=0; i<creatorsData.length; i++) {
let creatorData = creatorsData[i];
let creatorTypeID = creatorsData[i].creatorTypeID;
if ((itemTypeID == itemTypeLetter && creatorTypeID == creatorTypeRecipient) ||
(itemTypeID == itemTypeInterview && creatorTypeID == creatorTypeInterviewer)) {
participants.push(creatorData);
}
else if ((itemTypeID == itemTypeLetter && creatorTypeID == creatorTypeAuthor) ||
(itemTypeID == itemTypeInterview && creatorTypeID == creatorTypeInterviewee)) {
authors.push(creatorData);
}
}
var strParts = [];
if (participants.length > 0) {
let names = [];
let max = Math.min(4, participants.length);
for (let i=0; i<max; i++) {
names.push(
participants[i].name !== undefined
? participants[i].name
: participants[i].lastName
);
}
switch (names.length) {
case 1:
var str = 'oneParticipant';
break;
case 2:
var str = 'twoParticipants';
break;
case 3:
var str = 'threeParticipants';
break;
default:
var str = 'manyParticipants';
}
strParts.push(Zotero.getString('pane.items.' + itemTypeName + '.' + str, names));
}
else {
strParts.push(Zotero.ItemTypes.getLocalizedString(itemTypeID));
}
title = '[' + strParts.join('; ') + ']';
}
// 'case'
else if (itemTypeID == itemTypeCase) {
if (title) { // common law cases always have case names
var reporter = this.getField('reporter');
if (reporter) {
title = title + ' (' + reporter + ')';
} else {
var court = this.getField('court');
if (court) {
title = title + ' (' + court + ')';
}
}
}
else { // civil law cases have only shortTitle as case name
var strParts = [];
var caseinfo = "";
var part = this.getField('court');
if (part) {
strParts.push(part);
}
part = Zotero.Date.multipartToSQL(this.getField('date', true, true));
if (part) {
strParts.push(part);
}
var creatorData = this.getCreator(0);
if (creatorData && creatorData.creatorTypeID === creatorTypeAuthor) {
strParts.push(creatorData.lastName);
}
title = '[' + strParts.join(', ') + ']';
}
}
this._displayTitle = title;
};
/*
* Returns the number of creators for this item
*/
Zotero.Item.prototype.numCreators = function() {
this._requireData('creators');
return this._creators.length;
}
Zotero.Item.prototype.hasCreatorAt = function(pos) {
this._requireData('creators');
return !!this._creators[pos];
}
/**
* @param {Integer} pos
* @return {Object|Boolean} The internal creator data object at the given position, or FALSE if none
*/
Zotero.Item.prototype.getCreator = function (pos) {
this._requireData('creators');
if (!this._creators[pos]) {
return false;
}
var creator = {};
for (let i in this._creators[pos]) {
creator[i] = this._creators[pos][i];
}
return creator;
}
/**
* @param {Integer} pos
* @return {Object|Boolean} The API JSON creator data at the given position, or FALSE if none
*/
Zotero.Item.prototype.getCreatorJSON = function (pos) {
this._requireData('creators');
return this._creators[pos] ? Zotero.Creators.internalToJSON(this._creators[pos]) : false;
}
/**
* Returns creator data in internal format
*
* @return {Array<Object>} An array of internal creator data objects
* ('firstName', 'lastName', 'fieldMode', 'creatorTypeID')
*/
Zotero.Item.prototype.getCreators = function () {
this._requireData('creators');
// Create copies of the creator data objects
return this._creators.map(function (data) {
var creator = {};
for (let i in data) {
creator[i] = data[i];
}
return creator;
});
}
/**
* @return {Array<Object>} An array of creator data objects in API JSON format
* ('firstName'/'lastName' or 'name', 'creatorType')
*/
Zotero.Item.prototype.getCreatorsJSON = function () {
this._requireData('creators');
return this._creators.map(data => Zotero.Creators.internalToJSON(data));
}
/**
* Set or update the creator at the specified position
*
* @param {Integer} orderIndex
* @param {Object} Creator data in internal or API JSON format:
* <ul>
* <li>'name' or 'firstName'/'lastName', or 'firstName'/'lastName'/'fieldMode'</li>
* <li>'creatorType' (can be name or id) or 'creatorTypeID'</li>
* </ul>
* @param {Object} [options]
* @param {Boolean} [options.strict] - Throw on invalid creator type
*/
Zotero.Item.prototype.setCreator = function (orderIndex, data, options = {}) {
var itemTypeID = this._itemTypeID;
if (!itemTypeID) {
throw new Error('Item type must be set before setting creators');
}
this._requireData('creators');
var origCreatorType = data.creatorType;
data = Zotero.Creators.cleanData(data, options);
if (data.creatorTypeID === undefined) {
throw new Error("Creator data must include a valid 'creatorType' or 'creatorTypeID' property");
}
// If creatorTypeID isn't valid for this type, use the primary type
if (!data.creatorTypeID || !Zotero.CreatorTypes.isValidForItemType(data.creatorTypeID, itemTypeID)) {
let itemType = Zotero.ItemTypes.getName(itemTypeID);
if (options.strict) {
let e = new Error(`Invalid creator type '${origCreatorType}' for type ${itemType}`);
e.name = "ZoteroInvalidDataError";
throw e;
}
let msg = `Creator type '${origCreatorType}' isn't valid for ${itemType} -- `
+ "changing to primary creator";
Zotero.warn(msg);
data.creatorTypeID = Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID);
}
// If creator at this position hasn't changed, cancel
let previousData = this._creators[orderIndex];
if (previousData
&& previousData.creatorTypeID === data.creatorTypeID
&& previousData.fieldMode === data.fieldMode
&& previousData.firstName === data.firstName
&& previousData.lastName === data.lastName) {
Zotero.debug("Creator in position " + orderIndex + " hasn't changed", 4);
return false;
}
// Save copy of old creators for save() and notifier
if (!this._changed.creators) {
this._changed.creators = {};
this._markFieldChange('creators', this._getOldCreators());
}
this._changed.creators[orderIndex] = true;
this._creators[orderIndex] = data;
return true;
}
/**
* @param {Object[]} data - An array of creator data in internal or API JSON format
*/
Zotero.Item.prototype.setCreators = function (data, options = {}) {
// Clear existing creators beyond the number of provided ones
var numCreators = this.numCreators();
if (data.length < numCreators) {
while (this.hasCreatorAt(data.length)) {
this.removeCreator(data.length);
}
}
for (let i = 0; i < data.length; i++) {
this.setCreator(i, data[i], options);
}
}
/*
* Remove a creator and shift others down
*/
Zotero.Item.prototype.removeCreator = function(orderIndex, allowMissing) {
var creatorData = this.getCreator(orderIndex);
if (!creatorData && !allowMissing) {
throw new Error('No creator exists at position ' + orderIndex);
}
// Save copy of old creators for notifier
if (!this._changed.creators) {
this._changed.creators = {};
var oldCreators = this._getOldCreators();
this._markFieldChange('creators', oldCreators);
}
// Shift creator orderIndexes down, going to length+1 so we clear the last one
for (var i=orderIndex, max=this._creators.length+1; i<max; i++) {
var next = this._creators[i+1] ? this._creators[i+1] : false;
if (next) {
this._creators[i] = next;
}
else {
this._creators.splice(i, 1);
}
this._changed.creators[i] = true;
}
return true;
}
// Define boolean properties
for (let name of ['inPublications']) {
let prop = '_' + name;
Zotero.defineProperty(Zotero.Item.prototype, name, {
get: function() {
if (!this.id) {
return false;
}
if (this[prop] !== null) {
return this[prop];
}
this._requireData('primaryData');
},
set: function(val) {
val = !!val;
if (this[prop] == val) {
Zotero.debug(Zotero.Utilities.capitalize(name)
+ " state hasn't changed for item " + this.id);
return;
}
this._markFieldChange(name, !!this[prop]);
this._changed[name] = true;
this[prop] = val;
}
});
}
/**
* Relate this item to another. A separate save is required.
*
* @param {Zotero.Item}
* @return {Boolean}
*/
Zotero.Item.prototype.addRelatedItem = function (item) {
if (!(item instanceof Zotero.Item)) {
throw new Error("'item' must be a Zotero.Item");
}
if (item == this) {
Zotero.debug("Can't relate item to itself in Zotero.Item.addRelatedItem()", 2);
return false;
}
if (!this.libraryID) {
this.libraryID = Zotero.Libraries.userLibraryID;
}
if (item.libraryID != this.libraryID) {
throw new Error("Cannot relate item to an item in a different library");
}
return this.addRelation(Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(item));
}
/**
* @param {Zotero.Item}
*/
Zotero.Item.prototype.removeRelatedItem = Zotero.Promise.coroutine(function* (item) {
if (!(item instanceof Zotero.Item)) {
throw new Error("'item' must be a Zotero.Item");
}
return this.removeRelation(Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(item));
});
/**
* @param {String} [op='edit'] - Operation to check; if not provided, check edit privileges for
* library
*/
Zotero.Item.prototype.isEditable = function (op = 'edit') {
// DataObject::isEditable() checks if library is editable
var editable = Zotero.Item._super.prototype.isEditable.call(this, op);
if (!editable) return false;
// Check if we're allowed to edit file attachments
if (this.isAttachment()
&& (this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL
|| this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE)
&& !Zotero.Libraries.get(this.libraryID).filesEditable) {
return false;
}
switch (op) {
case 'edit':
// Group library annotations created by other users aren't editable
if (this.isAnnotation()) {
let library = this.library;
if (library.isGroup
&& this.createdByUserID
&& this.createdByUserID != Zotero.Users.getCurrentUserID()) {
return false;
}
}
break;
case 'erase':
break;
default:
throw new Error(`Unknown operation ${op}`);
}
return true;
};
Zotero.Item.prototype._initSave = Zotero.Promise.coroutine(function* (env) {
if (!this.itemTypeID) {
throw new Error("Item type must be set before saving");
}
return Zotero.Item._super.prototype._initSave.apply(this, arguments);
})
Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) {
Zotero.DB.requireTransaction();
var isNew = env.isNew;
var options = env.options;
var libraryType = env.libraryType = Zotero.Libraries.get(env.libraryID).libraryType;
var itemTypeID = this.itemTypeID;
var reloadParentChildItems = {};
//
// Primary fields
//
// If available id value, use it -- otherwise we'll use autoincrement
var itemID = this._id = this.id ? this.id : Zotero.ID.get('items');
if (this._changed.primaryData && this._changed.primaryData.itemTypeID) {
env.sqlColumns.push('itemTypeID');
env.sqlValues.push({ int: itemTypeID });
}
if (isNew || (this._changed.primaryData && this._changed.primaryData.dateAdded)) {
env.sqlColumns.push('dateAdded');
env.sqlValues.push(this.dateAdded ? this.dateAdded : Zotero.DB.transactionDateTime);
}
// If a new item and Date Modified hasn't been provided, or an existing item and
// Date Modified hasn't changed from its previous value and skipDateModifiedUpdate wasn't
// passed, use the current timestamp
if (!this.dateModified
|| ((!this._changed.primaryData || !this._changed.primaryData.dateModified)
&& !options.skipDateModifiedUpdate)) {
env.sqlColumns.push('dateModified');
env.sqlValues.push(Zotero.DB.transactionDateTime);
}
// Otherwise, if a new Date Modified was provided, use that. (This would also work when
// skipDateModifiedUpdate was passed and there's an existing value, but in that case we
// can just not change the field at all.)
else if (this._changed.primaryData && this._changed.primaryData.dateModified) {
env.sqlColumns.push('dateModified');
env.sqlValues.push(this.dateModified);
}
if (env.sqlColumns.length) {
if (isNew) {
env.sqlColumns.unshift('itemID');
env.sqlValues.unshift(parseInt(itemID));
let sql = "INSERT INTO items (" + env.sqlColumns.join(", ") + ") "
+ "VALUES (" + env.sqlValues.map(() => "?").join() + ")";
yield Zotero.DB.queryAsync(sql, env.sqlValues);
if (!env.options.skipNotifier) {
Zotero.Notifier.queue('add', 'item', itemID, env.notifierData, env.options.notifierQueue);
}
}
else {
let sql = "UPDATE items SET " + env.sqlColumns.join("=?, ") + "=? WHERE itemID=?";
env.sqlValues.push(parseInt(itemID));
yield Zotero.DB.queryAsync(sql, env.sqlValues);
if (!env.options.skipNotifier) {
Zotero.Notifier.queue('modify', 'item', itemID, env.notifierData, env.options.notifierQueue);
}
}
}
// In group libraries:
//
// - If createdByUserID or lastModifiedByUserID are explicitly set, use those values
// - Otherwise, set current user as createdByUserID if new or lastModifiedByUserID if not
if (libraryType == 'group') {
let createdByUserID;
let lastModifiedByUserID;
if (this._changed.primaryData) {
if (this._changed.primaryData.createdByUserID) {
createdByUserID = this._createdByUserID;
}
if (this._changed.primaryData.lastModifiedByUserID) {
lastModifiedByUserID = this._lastModifiedByUserID;
}
}
if (!options.skipGroupItemsUserUpdate) {
if (!createdByUserID && isNew) {
createdByUserID = Zotero.Users.getCurrentUserID();
}
// TEMP: For now, don't update lastModifiedByUserID -- we may want to start doing this
// before we start showing a last-modified-by name in the UI so that it updates
// immediately rather than waiting until a sync happens, but we should figure out if we
// want all changes to count and make sure the dataserver follows the same behavior.
//
//if (!lastModifiedByUserID && !isNew) {
// lastModifiedByUserID = Zotero.Users.getCurrentUserID();
//}
}
if (createdByUserID || lastModifiedByUserID) {
try {
let sql = "REPLACE INTO groupItems VALUES (?, ?, ?)";
yield Zotero.DB.queryAsync(
sql,
[
itemID,
createdByUserID || null,
lastModifiedByUserID || null
]
);
}
// TODO: Use schema update step to add username to users table if group library
// and no current name
catch (e) {
let username = yield Zotero.DB.valueQueryAsync(
"SELECT value FROM settings WHERE setting='account' AND key='username'"
);
if (username) {
yield Zotero.Users.setCurrentName(username);
let sql = "REPLACE INTO groupItems VALUES (?, ?, ?)";
yield Zotero.DB.queryAsync(
sql,
[
itemID,
createdByUserID || null,
lastModifiedByUserID || null
]
);
}
}
}
}
//
// ItemData
//
if (this._changed.itemData) {
let del = [];
let valueSQL = "SELECT valueID FROM itemDataValues WHERE value=?";
let insertValueSQL = "INSERT INTO itemDataValues VALUES (?,?)";
let replaceSQL = "REPLACE INTO itemData VALUES (?,?,?)";
for (let fieldID in this._changed.itemData) {
fieldID = parseInt(fieldID);
let value = this.getField(fieldID, true);
// If field changed and is empty, mark row for deletion
if (value === '') {
del.push(fieldID);
continue;
}
if (Zotero.ItemFields.getID('accessDate') == fieldID
&& (this.getField(fieldID)) == 'CURRENT_TIMESTAMP') {
value = Zotero.DB.transactionDateTime;
}
let valueID = yield Zotero.DB.valueQueryAsync(valueSQL, [value], { debug: true })
if (!valueID) {
valueID = Zotero.ID.get('itemDataValues');
yield Zotero.DB.queryAsync(insertValueSQL, [valueID, value], { debug: false });
}
yield Zotero.DB.queryAsync(replaceSQL, [itemID, fieldID, valueID], { debug: false });
}
// Delete blank fields
if (del.length) {
sql = 'DELETE from itemData WHERE itemID=? AND '
+ 'fieldID IN (' + del.map(() => '?').join() + ')';
yield Zotero.DB.queryAsync(sql, [itemID].concat(del));
}
}
//
// Creators
//
if (this._changed.creators) {
for (let orderIndex in this._changed.creators) {
orderIndex = parseInt(orderIndex);
if (isNew) {
Zotero.debug('Adding creator in position ' + orderIndex, 4);
}
else {
Zotero.debug('Creator ' + orderIndex + ' has changed', 4);
}
let creatorData = this.getCreator(orderIndex);
// If no creator in this position, just remove the item-creator association
if (!creatorData) {
let sql = "DELETE FROM itemCreators WHERE itemID=? AND orderIndex=?";
yield Zotero.DB.queryAsync(sql, [itemID, orderIndex]);
Zotero.Prefs.set('purge.creators', true);
continue;
}
let previousCreatorID = !isNew && this._previousData.creators[orderIndex]
? this._previousData.creators[orderIndex].id
: false;
let newCreatorID = yield Zotero.Creators.getIDFromData(creatorData, true);
// If there was previously a creator at this position and it's different from
// the new one, the old one might need to be purged.
if (previousCreatorID && previousCreatorID != newCreatorID) {
Zotero.Prefs.set('purge.creators', true);
}
let sql = "INSERT OR REPLACE INTO itemCreators "
+ "(itemID, creatorID, creatorTypeID, orderIndex) VALUES (?, ?, ?, ?)";
yield Zotero.DB.queryAsync(
sql,
[
itemID,
newCreatorID,
creatorData.creatorTypeID,
orderIndex
]
);
}
}
// Parent item (DB update is done below after collection removals)
var parentItemKey = this.parentKey;
var parentItemID = parentItemKey
? (this.ObjectsClass.getIDFromLibraryAndKey(this.libraryID, parentItemKey) || null)
: null;
if (this._changed.parentKey) {
if (parentItemKey && parentItemKey == this.key) {
throw new Error("Item cannot be set as parent of itself");
}
// Make sure parent is a regular item
if (parentItemID) {
let parentItem = yield Zotero.Items.getAsync(parentItemID);
if (!parentItem.isRegularItem()) {
// Allow embedded-image attachments under notes
if (this.isEmbeddedImageAttachment()) {
if (!parentItem.isNote()) {
throw new Error(`Parent item ${parentItem.libraryKey} must a note`);
}
}
// Allow annotations under attachments
else if (this.isAnnotation()) {
if (!parentItem.isFileAttachment()) {
throw new Error(`Parent item ${parentItem.libraryKey} must be a file attachment`);
}
}
else {
throw new Error(`Parent item ${parentItem.libraryKey} must be a regular item`);
}
}
}
if (isNew) {
if (!parentItemID) {
// TODO: clear caches?
let msg = "Parent item " + this.libraryID + "/" + parentItemKey + " not found";
let e = new Error(msg);
e.name = "ZoteroMissingObjectError";
throw e;
}
let newParentItemNotifierData = {};
//newParentItemNotifierData[newParentItem.id] = {};
if (!env.options.skipNotifier) {
Zotero.Notifier.queue(
'modify', 'item', parentItemID, newParentItemNotifierData, env.options.notifierQueue
);
}
switch (Zotero.ItemTypes.getName(itemTypeID)) {
case 'note':
case 'attachment':
case 'annotation':
reloadParentChildItems[parentItemID] = true;
break;
}
}
else {
if (parentItemKey) {
if (!parentItemID) {
// TODO: clear caches
let msg = "Parent item " + this.libraryID + "/" + parentItemKey + " not found";
let e = new Error(msg);
e.name = "ZoteroMissingObjectError";
throw e;
}
let newParentItemNotifierData = {};
//newParentItemNotifierData[newParentItem.id] = {};
if (!env.options.skipNotifier) {
Zotero.Notifier.queue(
'modify',
'item',
parentItemID,
newParentItemNotifierData,
env.options.notifierQueue
);
}
}
let oldParentKey = this._previousData.parentKey;
let oldParentItemID;
if (oldParentKey) {
oldParentItemID = this.ObjectsClass.getIDFromLibraryAndKey(this.libraryID, oldParentKey);
if (oldParentItemID) {
let oldParentItemNotifierData = {};
//oldParentItemNotifierData[oldParentItemID] = {};
if (!env.options.skipNotifier) {
Zotero.Notifier.queue(
'modify',
'item',
oldParentItemID,
oldParentItemNotifierData,
env.options.notifierQueue
);
}
}
else {
Zotero.debug("Old source item " + oldParentKey
+ " didn't exist in Zotero.Item.save()", 2);
}
}
// If this was an independent item, remove from any collections
// where it existed previously and add parent instead
if (!oldParentKey) {
let sql = "SELECT collectionID FROM collectionItems WHERE itemID=?";
let changedCollections = yield Zotero.DB.columnQueryAsync(sql, this.id);
if (changedCollections.length) {
let parentItem = yield this.ObjectsClass.getByLibraryAndKeyAsync(
this.libraryID, parentItemKey
);
for (let i=0; i<changedCollections.length; i++) {
parentItem.addToCollection(changedCollections[i]);
this.removeFromCollection(changedCollections[i]);
if (!env.options.skipNotifier) {
Zotero.Notifier.queue(
'remove',
'collection-item',
changedCollections[i] + '-' + this.id,
{},
env.options.notifierQueue
);
}
}
yield parentItem.save({
skipDateModifiedUpdate: true,
skipEditCheck: env.options.skipEditCheck
});
}
}
// Update the counts of the previous and new sources
if (oldParentItemID) {
reloadParentChildItems[oldParentItemID] = true;
}
if (parentItemID) {
reloadParentChildItems[parentItemID] = true;
}
}
}
if (this._inPublications) {
if (!this.isRegularItem() && !parentItemID) {
throw new Error("Top-level attachments and notes cannot be added to My Publications");
}
if (this.isAttachment() && this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
throw new Error("Linked-file attachments cannot be added to My Publications");
}
if (Zotero.Libraries.get(this.libraryID).libraryType != 'user') {
throw new Error("Only items in user libraries can be added to My Publications");
}
}
// Trashed status
if (this._changedData.deleted !== undefined) {
if (this._changedData.deleted) {
sql = "REPLACE INTO deletedItems (itemID) VALUES (?)";
}
else {
// If undeleting, remove any merge-tracking relations
let predicate = Zotero.Relations.replacedItemPredicate;
let thisURI = Zotero.URI.getItemURI(this);
let mergeItems = yield Zotero.Relations.getByPredicateAndObject(
'item', predicate, thisURI
);
for (let mergeItem of mergeItems) {
// An item shouldn't have itself as a dc:replaces relation, but if it does it causes an
// infinite loop
if (mergeItem.id == this.id) {
Zotero.logError(`Item ${this.libraryKey} has itself as a ${predicate} relation`);
this.removeRelation(predicate, thisURI);
continue;
}
mergeItem.removeRelation(predicate, thisURI);
yield mergeItem.save({
skipDateModifiedUpdate: true,
skipEditCheck: env.options.skipEditCheck
});
}
sql = "DELETE FROM deletedItems WHERE itemID=?";
}
yield Zotero.DB.queryAsync(sql, itemID);
// Refresh trash
if (!env.options.skipNotifier) {
Zotero.Notifier.queue('refresh', 'trash', this.libraryID, {}, env.options.notifierQueue);
if (this._changedData.deleted) {
Zotero.Notifier.queue('trash', 'item', this.id, {}, env.options.notifierQueue);
}
}
if (parentItemID) {
reloadParentChildItems[parentItemID] = true;
}
this._clearChanged('deleted');
this._markForReload('primaryData');
}
if (this._changed.inPublications) {
if (this._inPublications) {
sql = "INSERT OR IGNORE INTO publicationsItems (itemID) VALUES (?)";
}
else {
sql = "DELETE FROM publicationsItems WHERE itemID=?";
}
yield Zotero.DB.queryAsync(sql, itemID);
}
// Collections
//
// Only diffing and removal are done here. Additions have to be done below after parentItemID has
// been updated in itemAttachments/itemNotes, since a child item that was made a standalone item and
// added to a collection can't be added to the collection while it still has a parent, and vice
// versa, due to the trigger checks on collectionItems/itemAttachments/itemNotes.
if (this._changed.collections) {
if (libraryType == 'publications') {
throw new Error("Items in My Publications cannot be added to collections");
}
let oldCollections = this._previousData.collections || [];
let newCollections = this._collections;
let toAdd = Zotero.Utilities.arrayDiff(newCollections, oldCollections);
let toRemove = Zotero.Utilities.arrayDiff(oldCollections, newCollections);
env.collectionsAdded = toAdd;
env.collectionsRemoved = toRemove;
if (toRemove.length) {
let sql = "DELETE FROM collectionItems WHERE itemID=? AND collectionID IN ("
+ toRemove.join(',')
+ ")";
yield Zotero.DB.queryAsync(sql, this.id);
for (let i=0; i<toRemove.length; i++) {
let collectionID = toRemove[i];
if (!env.options.skipNotifier) {
Zotero.Notifier.queue(
'remove',
'collection-item',
collectionID + '-' + this.id,
{},
env.options.notifierQueue
);
}
}
// Remove this item from any loaded collections' cached item lists after commit
Zotero.DB.addCurrentCallback("commit", function () {
for (let i = 0; i < toRemove.length; i++) {
this.ContainerObjectsClass.unregisterChildItem(toRemove[i], this.id);
}
}.bind(this));
}
}
// Add parent item for existing item, if note or attachment data isn't going to be updated below
//
// Technically this doesn't have to go below collection removals, but only because the
// 'collectionitem must be top level' trigger check applies only to INSERTs, not UPDATEs, which was
// probably done in an earlier attempt to solve this problem.
if (!isNew && this._changed.parentKey && !this._changed.note && !this._changed.attachmentData) {
let type = Zotero.ItemTypes.getName(itemTypeID);
let Type = type[0].toUpperCase() + type.substr(1);
let sql = "UPDATE item" + Type + "s SET parentItemID=? WHERE itemID=?";
yield Zotero.DB.queryAsync(sql, [parentItemID, this.id]);
}
// There's no reload for parentKey, so clear it here
if (this._changed.parentKey) {
this._clearChanged('parentKey');
}
// Note
if ((isNew && this.isNote()) || this._changed.note) {
if (!isNew) {
if (this._noteText === null || this._noteTitle === null) {
throw new Error("Cached note values not set with "
+ "this._changed.note set to true");
}
}
let parent = this.isNote() ? this.parentID : null;
let noteText = this._noteText ? this._noteText : '';
// Add <div> wrapper if not present
if (!noteText.match(/^<div class="zotero-note znv[0-9]+">[\s\S]*<\/div>$/)) {
noteText = Zotero.Notes.notePrefix + noteText + Zotero.Notes.noteSuffix;
}
let params = [
parent ? parent : null,
noteText,
this._noteTitle ? this._noteTitle : ''
];
let sql = "SELECT COUNT(*) FROM itemNotes WHERE itemID=?";
if (yield Zotero.DB.valueQueryAsync(sql, itemID)) {
sql = "UPDATE itemNotes SET parentItemID=?, note=?, title=? WHERE itemID=?";
params.push(itemID);
}
else {
sql = "INSERT INTO itemNotes "
+ "(itemID, parentItemID, note, title) VALUES (?,?,?,?)";
params.unshift(itemID);
}
yield Zotero.DB.queryAsync(sql, params);
if (parentItemID) {
reloadParentChildItems[parentItemID] = true;
}
}
//
// Attachment
//
if (!isNew) {
// If attachment title changes, update parent attachments
let titleFieldID = Zotero.ItemFields.getID('title');
if (this._changed.itemData
&& this._changed.itemData[titleFieldID]
&& this.isAttachment() && parentItemID) {
reloadParentChildItems[parentItemID] = true;
}
}
if (this._changed.attachmentData) {
let sql = "";
let cols = [
'parentItemID', 'linkMode', 'contentType', 'charsetID', 'path', 'syncState',
'storageModTime', 'storageHash', 'lastProcessedModificationTime'
];
// TODO: Replace with UPSERT after SQLite 3.24.0
if (isNew) {
sql = "INSERT INTO itemAttachments "
+ "(itemID, " + cols.join(", ") + ") "
+ "VALUES (?,?,?,?,?,?,?,?,?,?)";
}
else {
sql = "UPDATE itemAttachments SET " + cols.join("=?, ") + "=? WHERE itemID=?";
}
let linkMode = this.attachmentLinkMode;
let contentType = this.attachmentContentType;
let charsetID = this.attachmentCharset
? Zotero.CharacterSets.getID(this.attachmentCharset)
: null;
let path = this.attachmentPath;
let syncState = this.attachmentSyncState;
let storageModTime = this.attachmentSyncedModificationTime;
let storageHash = this.attachmentSyncedHash;
let lastProcessedModificationTime = this.attachmentLastProcessedModificationTime;
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE && libraryType != 'user') {
throw new Error("Linked files can only be added to user library");
}
let params = [
parentItemID,
{ int: linkMode },
contentType ? { string: contentType } : null,
charsetID ? { int: charsetID } : null,
path ? { string: path } : null,
syncState !== undefined ? syncState : 0,
storageModTime !== undefined ? storageModTime : null,
storageHash || null,
lastProcessedModificationTime || null,
];
if (isNew) {
params.unshift(itemID);
}
else {
params.push(itemID);
}
yield Zotero.DB.queryAsync(sql, params);
// Clear cached child attachments of the parent
if (!isNew && parentItemID) {
reloadParentChildItems[parentItemID] = true;
}
}
//
// Annotation
//
if (this._changed.annotation || this._changed.annotationDeferred) {
if (!parentItemID) {
throw new Error("Annotation item must have a parent item");
}
let parentItem = Zotero.Items.get(parentItemID);
if (!parentItem.isAttachment()) {
throw new Error("Annotation parent must be an attachment item");
}
if (!parentItem.isFileAttachment()) {
throw new Error("Annotation parent must be a file attachment");
}
if (parentItem.attachmentContentType != 'application/pdf') {
throw new Error("Annotation parent must be a PDF");
}
let type = this._getLatestField('annotationType');
let typeID = Zotero.Annotations[`ANNOTATION_TYPE_${type.toUpperCase()}`];
if (!typeID) {
throw new Error(`Invalid annotation type '${type}'`);
}
let authorName = this._getLatestField('annotationAuthorName');
let text = this._getLatestField('annotationText');
let comment = this._getLatestField('annotationComment');
let color = this._getLatestField('annotationColor');
let pageLabel = this._getLatestField('annotationPageLabel');
let sortIndex = this._getLatestField('annotationSortIndex');
let position = this._getLatestField('annotationPosition');
let isExternal = this._getLatestField('annotationIsExternal');
let sql = "REPLACE INTO itemAnnotations "
+ "(itemID, parentItemID, type, authorName, text, comment, color, pageLabel, sortIndex, position, isExternal) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
yield Zotero.DB.queryAsync(
sql,
[
itemID,
parentItemID,
typeID,
authorName || null,
text || null,
comment || null,
color || null,
pageLabel || null,
sortIndex,
position,
isExternal ? 1 : 0
]
);
// Clear cached child items of the parent attachment
reloadParentChildItems[parentItemID] = true;
// Mark cache image for deletion when image or ink annotation position (or ink color) changes
if (!isNew && (
['image', 'ink'].includes(type) && this._hasFieldChanged('annotationPosition')
|| type == 'ink' && this._hasFieldChanged('annotationColor')
)) {
let libraryID = this.libraryID;
let key = this.key;
Zotero.DB.addCurrentCallback("commit", function () {
Zotero.Annotations.removeCacheImage({ libraryID, key });
}.bind(this));
}
let fields = ['Type', 'Text', 'Comment', 'Color', 'PageLabel', 'SortIndex', 'Position', 'IsExternal'];
for (let field of fields) {
this._clearChanged('annotation' + field);
}
this._markForReload('annotation');
this._markForReload('annotationDeferred');
}
// Add to new collections
if (env.collectionsAdded) {
let toAdd = env.collectionsAdded;
for (let i=0; i<toAdd.length; i++) {
let collectionID = toAdd[i];
let sql = "SELECT IFNULL(MAX(orderIndex)+1, 0) FROM collectionItems "
+ "WHERE collectionID=?";
let orderIndex = yield Zotero.DB.valueQueryAsync(sql, collectionID);
sql = "INSERT OR IGNORE INTO collectionItems "
+ "(collectionID, itemID, orderIndex) VALUES (?, ?, ?)";
yield Zotero.DB.queryAsync(sql, [collectionID, this.id, orderIndex]);
if (!env.options.skipNotifier) {
Zotero.Notifier.queue(
'add',
'collection-item',
collectionID + '-' + this.id,
{},
env.options.notifierQueue
);
}
}
// Add this item to any loaded collections' cached item lists after commit
Zotero.DB.addCurrentCallback("commit", function () {
for (let i = 0; i < toAdd.length; i++) {
this.ContainerObjectsClass.registerChildItem(toAdd[i], this.id);
}
}.bind(this));
}
// Tags
if (this._hasFieldChanged('tags')) {
let oldTags = this._tags;
let newTags = this._getChangedField('tags');
this._clearChanged('tags');
this._markForReload('tags');
// Convert to individual JSON objects, diff, and convert back
let oldTagsJSON = oldTags.map(x => JSON.stringify(x));
let newTagsJSON = newTags.map(x => JSON.stringify(x));
let toAdd = Zotero.Utilities.arrayDiff(newTagsJSON, oldTagsJSON).map(x => JSON.parse(x));
let toRemove = Zotero.Utilities.arrayDiff(oldTagsJSON, newTagsJSON).map(x => JSON.parse(x));
for (let i=0; i<toAdd.length; i++) {
let tag = toAdd[i];
let tagID = yield Zotero.Tags.create(tag.tag);
let tagType = tag.type ? tag.type : 0;
// "OR REPLACE" allows changing type
let sql = "INSERT OR REPLACE INTO itemTags (itemID, tagID, type) VALUES (?, ?, ?)";
yield Zotero.DB.queryAsync(sql, [this.id, tagID, tagType]);
let notifierData = {};
notifierData[this.id + '-' + tagID] = {
libraryID: this.libraryID,
tag: tag.tag,
type: tagType
};
if (!env.options.skipNotifier) {
Zotero.Notifier.queue(
'add', 'item-tag', this.id + '-' + tagID, notifierData, env.options.notifierQueue
);
}
}
if (toRemove.length) {
for (let i=0; i<toRemove.length; i++) {
let tag = toRemove[i];
let tagID = Zotero.Tags.getID(tag.tag);
let tagType = tag.type ? tag.type : 0;
let sql = "DELETE FROM itemTags WHERE itemID=? AND tagID=? AND type=?";
yield Zotero.DB.queryAsync(sql, [this.id, tagID, tagType]);
let notifierData = {};
notifierData[this.id + '-' + tagID] = {
libraryID: this.libraryID,
tag: tag.tag,
type: tagType
};
if (!env.options.skipNotifier) {
Zotero.Notifier.queue(
'remove', 'item-tag', this.id + '-' + tagID, notifierData, env.options.notifierQueue
);
}
}
Zotero.Prefs.set('purge.tags', true);
}
}
// Update child item counts and contents
if (reloadParentChildItems) {
for (let parentItemID in reloadParentChildItems) {
// Keep in sync with Zotero.Items.trash()
let parentItem = yield this.ObjectsClass.getAsync(parseInt(parentItemID));
yield parentItem.reload(['primaryData', 'childItems'], true);
parentItem.clearBestAttachmentState();
}
}
Zotero.DB.requireTransaction();
});
Zotero.Item.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) {
if (!env.skipCache) {
// Always reload primary data. DataObject.reload() only reloads changed data types, so
// it won't reload, say, dateModified and firstCreator if only creator data was changed
// and not primaryData.
yield this.loadPrimaryData(true);
yield this.reload();
// If new, there's no other data we don't have, so we can mark everything as loaded
if (env.isNew) {
this._markAllDataTypeLoadStates(true);
}
}
return env.isNew ? this.id : true;
});
Zotero.Item.prototype.isRegularItem = function() {
return !(this.isNote() || this.isAttachment() || this.isAnnotation());
}
Zotero.Item.prototype.isTopLevelItem = function () {
return this.isRegularItem() || !this.parentKey;
}
Zotero.Item.prototype.numChildren = function(includeTrashed) {
return this.numNotes(includeTrashed) + this.numAttachments(includeTrashed);
}
/**
* @return {String|FALSE} Key of the parent item for an attachment or note, or FALSE if none
*/
Zotero.Item.prototype.getSourceKey = function() {
Zotero.debug("Zotero.Item.prototype.getSource() is deprecated -- use .parentKey");
return this._parentKey;
}
Zotero.Item.prototype.setSourceKey = function(sourceItemKey) {
Zotero.debug("Zotero.Item.prototype.setSourceKey() is deprecated -- use .parentKey");
return this.parentKey = sourceItemKey;
}
////////////////////////////////////////////////////////
//
//
// Note methods
//
//
////////////////////////////////////////////////////////
/**
* Determine if an item is a note
**/
Zotero.Item.prototype.isNote = function() {
return Zotero.ItemTypes.getName(this.itemTypeID) == 'note';
}
/**
* Update an item note
*
* Note: This can only be called on saved notes and attachments
**/
Zotero.Item.prototype.updateNote = function(text) {
throw ('updateNote() removed -- use setNote() and save()');
}
/**
* Returns number of child notes of item
*
* @param {Boolean} includeTrashed Include trashed child items in count
* @param {Boolean} includeEmbedded Include notes embedded in attachments
* @return {Integer}
*/
Zotero.Item.prototype.numNotes = function(includeTrashed, includeEmbedded) {
this._requireData('childItems');
var notes = Zotero.Items.get(this.getNotes(includeTrashed));
var num = notes.length;
if (includeEmbedded) {
// Include embedded attachment notes that aren't empty
num += Zotero.Items.get(this.getAttachments(includeTrashed))
.filter(x => x.note !== '').length;
}
return num;
}
/**
* Get the first line of the note for display in the items list
*
* @return {String}
*/
Zotero.Item.prototype.getNoteTitle = function() {
if (!this.isNote() && !this.isAttachment()) {
throw ("getNoteTitle() can only be called on notes and attachments");
}
if (this._noteTitle !== null) {
return this._noteTitle;
}
this._requireData('itemData');
return "";
};
Zotero.Item.prototype.hasNote = Zotero.Promise.coroutine(function* () {
if (!this.isNote() && !this.isAttachment()) {
throw new Error("hasNote() can only be called on notes and attachments");
}
if (this._hasNote !== null) {
return this._hasNote;
}
if (!this._id) {
return false;
}
var sql = "SELECT COUNT(*) FROM itemNotes WHERE itemID=? "
+ "AND note!='' AND note!=?";
var hasNote = !!(yield Zotero.DB.valueQueryAsync(sql, [this._id, Zotero.Notes.defaultNote]));
this._hasNote = hasNote;
return hasNote;
});
Zotero.defineProperty(Zotero.Item.prototype, 'note', {
get: function () {
return this.getNote();
}
});
/**
* Get the text of an item note
**/
Zotero.Item.prototype.getNote = function() {
if (!this.isNote() && !this.isAttachment()) {
throw new Error("getNote() can only be called on notes and attachments "
+ `(${this.libraryID}/${this.key} is a ${Zotero.ItemTypes.getName(this.itemTypeID)})`);
}
// Store access time for later garbage collection
this._noteAccessTime = new Date();
if (this._noteText !== null) {
return this._noteText;
}
this._requireData('note');
return "";
}
/**
* Set an item note
*
* Note: This can only be called on notes and attachments
**/
Zotero.Item.prototype.setNote = function(text) {
if (!this.isNote() && !this.isAttachment()) {
throw ("updateNote() can only be called on notes and attachments");
}
if (typeof text != 'string') {
throw ("text must be a string in Zotero.Item.setNote() (was " + typeof text + ")");
}
text = text
// Strip control characters
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
.trim();
var oldText = this.getNote();
if (text === oldText) {
Zotero.debug("Note hasn't changed", 4);
return false;
}
this._hasNote = text !== '';
this._noteText = text;
this._noteTitle = Zotero.Utilities.Item.noteToTitle(text);
if (this.isNote()) {
this._displayTitle = this._noteTitle;
}
this._markFieldChange('note', oldText);
this._changed.note = true;
return true;
}
/**
* Returns child notes of this item
*
* @param {Boolean} includeTrashed Include trashed child items
* @param {Boolean} includeEmbedded Include embedded attachment notes
* @return {Integer[]} Array of itemIDs
*/
Zotero.Item.prototype.getNotes = function(includeTrashed) {
if (this.isNote()) {
throw new Error("getNotes() cannot be called on items of type 'note'");
}
this._requireData('childItems');
if (!this._notes) {
return [];
}
var sortChronologically = Zotero.Prefs.get('sortNotesChronologically');
var cacheKey = (sortChronologically ? "chronological" : "alphabetical")
+ 'With' + (includeTrashed ? '' : 'out') + 'Trashed';
if (this._notes[cacheKey]) {
return [...this._notes[cacheKey]];
}
var rows = this._notes.rows.concat();
// Remove trashed items if necessary
if (!includeTrashed) {
rows = rows.filter(row => !row.trashed);
}
// Sort by title if necessary
if (!sortChronologically) {
var collation = Zotero.getLocaleCollation();
rows.sort((a, b) => {
var aTitle = this.ObjectsClass.getSortTitle(a.title);
var bTitle = this.ObjectsClass.getSortTitle(b.title);
return collation.compareString(1, aTitle, bTitle);
});
}
var ids = rows.map(row => row.itemID);
this._notes[cacheKey] = ids;
return ids;
}
////////////////////////////////////////////////////////
//
//
// Attachment methods
//
// save() is not required for attachment functions
//
//
///////////////////////////////////////////////////////
/**
* Determine if an item is an attachment
**/
Zotero.Item.prototype.isAttachment = function() {
return Zotero.ItemTypes.getName(this.itemTypeID) == 'attachment';
}
/**
* @return {Promise<Boolean>}
*/
Zotero.Item.prototype.isImportedAttachment = function() {
if (!this.isAttachment()) {
return false;
}
var linkMode = this.attachmentLinkMode;
switch (linkMode) {
case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
return true;
}
return false;
}
/**
* @return {Promise<Boolean>}
*/
Zotero.Item.prototype.isStoredFileAttachment = function() {
if (!this.isAttachment()) {
return false;
}
return this.isImportedAttachment() || this.isEmbeddedImageAttachment();
}
/**
* @return {Promise<Boolean>}
*/
Zotero.Item.prototype.isWebAttachment = function() {
if (!this.isAttachment()) {
return false;
}
var linkMode = this.attachmentLinkMode;
if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE || linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
return false;
}
return true;
}
/**
* @return {Boolean}
*/
Zotero.Item.prototype.isFileAttachment = function() {
if (!this.isAttachment()) {
return false;
}
return this.attachmentLinkMode != Zotero.Attachments.LINK_MODE_LINKED_URL;
}
/**
* @return {Boolean}
*/
Zotero.Item.prototype.isLinkedFileAttachment = function() {
return this.isAttachment() && this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE;
}
/**
* @return {Boolean}
*/
Zotero.Item.prototype.isEmbeddedImageAttachment = function() {
return this.isAttachment() && this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_EMBEDDED_IMAGE;
}
/**
* @return {Boolean} - Returns true if item is a snapshot
*/
Zotero.Item.prototype.isSnapshotAttachment = function () {
return this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL
&& this.attachmentContentType == 'text/html';
};
/**
* @return {Boolean} - Returns true if item is a stored or linked PDF attachment
*/
Zotero.Item.prototype.isPDFAttachment = function () {
return this.isFileAttachment() && this.attachmentContentType == 'application/pdf';
};
/**
* Returns number of child attachments of item
*
* @param {Boolean} includeTrashed Include trashed child items in count
* @return <Integer>
*/
Zotero.Item.prototype.numAttachments = function (includeTrashed) {
this._requireData('childItems');
return this.getAttachments(includeTrashed).length;
}
Zotero.Item.prototype.numNonHTMLFileAttachments = function () {
this._requireData('childItems');
return this.getAttachments()
.map(itemID => Zotero.Items.get(itemID))
.filter(item => item.isFileAttachment() && item.attachmentContentType != 'text/html')
.length;
};
Zotero.Item.prototype.numPDFAttachments = function () {
this._requireData('childItems');
return this.getAttachments()
.map(itemID => Zotero.Items.get(itemID))
.filter(item => item.isFileAttachment() && item.attachmentContentType == 'application/pdf')
.length;
};
Zotero.Item.prototype.getFile = function () {
Zotero.debug("Zotero.Item.prototype.getFile() is deprecated -- use getFilePath[Async]()", 2);
var path = this.getFilePath();
if (path) {
return Zotero.File.pathToFile(path);
}
return false;
}
/**
* Get the absolute file path for the attachment
*
* @return {string|false} - The absolute file path of the attachment, or false for invalid paths
*/
Zotero.Item.prototype.getFilePath = function () {
if (!this.isAttachment()) {
throw new Error("getFilePath() can only be called on attachment items");
}
var linkMode = this.attachmentLinkMode;
var path = this.attachmentPath;
// No associated files for linked URLs
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
return false;
}
if (!path) {
Zotero.debug("Attachment path is empty", 2);
this._updateAttachmentStates(false);
return false;
}
if (!this._identified) {
Zotero.debug("Can't get file path for unsaved file");
return false;
}
// Imported file with relative path
if (this.isStoredFileAttachment()) {
if (!path.includes("storage:")) {
Zotero.logError("Invalid attachment path '" + path + "'");
this._updateAttachmentStates(false);
return false;
}
// Strip "storage:"
path = path.substr(8);
// Ignore .zotero* files that were relinked before we started blocking them
if (path.startsWith(".zotero")) {
Zotero.debug("Ignoring attachment file " + path, 2);
return false;
}
return OS.Path.join(
OS.Path.normalize(Zotero.Attachments.getStorageDirectory(this).path), path
);
}
// Linked file with relative path
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE &&
path.indexOf(Zotero.Attachments.BASE_PATH_PLACEHOLDER) == 0) {
path = Zotero.Attachments.resolveRelativePath(path);
if (!path) {
this._updateAttachmentStates(false);
}
return path;
}
// Old-style OS X persistent descriptor (Base64-encoded opaque alias record)
//
// These should only exist if they weren't converted in the 80 DB upgrade step because
// the file couldn't be found.
if (path.startsWith('AAAA')) {
// These can only be resolved on Macs
if (!Zotero.isMac) {
Zotero.debug(`Can't resolve old-style attachment path '${path}' on non-Mac platform`);
this._updateAttachmentStates(false);
return false;
}
let file = Components.classes["@mozilla.org/file/local;1"]
.createInstance(Components.interfaces.nsIFile);
try {
file.persistentDescriptor = path;
}
catch (e) {
Zotero.debug(`Can't resolve old-style attachment path '${path}'`);
this._updateAttachmentStates(false);
return false;
}
// If valid, convert this to a regular string in the background
Zotero.DB.queryAsync(
"UPDATE itemAttachments SET path=? WHERE itemID=?",
[file.path, this._id]
);
return file.path;
}
return path;
};
/**
* Get the absolute path for the attachment, if the file exists
*
* @return {Promise<String|false>} - A promise for either the absolute path of the attachment
* or false for invalid paths or if the file doesn't exist
*/
Zotero.Item.prototype.getFilePathAsync = Zotero.Promise.coroutine(function* () {
if (!this.isAttachment()) {
throw new Error("getFilePathAsync() can only be called on attachment items");
}
var linkMode = this.attachmentLinkMode;
var path = this.attachmentPath;
// No associated files for linked URLs
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
this._updateAttachmentStates(false);
return false;
}
if (!path) {
Zotero.debug("Attachment path is empty", 2);
this._updateAttachmentStates(false);
return false;
}
// Imported file with relative path
if (this.isStoredFileAttachment()) {
if (!path.includes("storage:")) {
Zotero.logError("Invalid attachment path '" + path + "'");
this._updateAttachmentStates(false);
return false;
}
// Strip "storage:"
path = path.substr(8);
// Ignore .zotero* files that were relinked before we started blocking them
if (path.startsWith(".zotero")) {
Zotero.debug("Ignoring attachment file " + path, 2);
this._updateAttachmentStates(false);
return false;
}
path = OS.Path.join(
OS.Path.normalize(Zotero.Attachments.getStorageDirectory(this).path), path
);
if (!(yield OS.File.exists(path))) {
Zotero.debug("Attachment file '" + path + "' not found", 2);
this._updateAttachmentStates(false);
return false;
}
this._updateAttachmentStates(true);
return path;
}
// Linked file with relative path
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE &&
path.indexOf(Zotero.Attachments.BASE_PATH_PLACEHOLDER) == 0) {
path = Zotero.Attachments.resolveRelativePath(path);
if (!path) {
this._updateAttachmentStates(false);
return false;
}
if (!(yield OS.File.exists(path))) {
Zotero.debug("Attachment file '" + path + "' not found", 2);
this._updateAttachmentStates(false);
return false;
}
this._updateAttachmentStates(true);
return path;
}
// Old-style OS X persistent descriptor (Base64-encoded opaque alias record)
//
// These should only exist if they weren't converted in the 80 DB upgrade step because
// the file couldn't be found
if (Zotero.isMac && path.startsWith('AAAA')) {
let file = Components.classes["@mozilla.org/file/local;1"]
.createInstance(Components.interfaces.nsIFile);
try {
file.persistentDescriptor = path;
}
catch (e) {
this._updateAttachmentStates(false);
return false;
}
// If valid, convert this to a regular string
yield Zotero.DB.queryAsync(
"UPDATE itemAttachments SET path=? WHERE itemID=?",
[file.leafName, this._id]
);
if (!(yield OS.File.exists(file.path))) {
Zotero.debug("Attachment file '" + file.path + "' not found", 2);
this._updateAttachmentStates(false);
return false;
}
this._updateAttachmentStates(true);
return file.path;
}
if (!(yield OS.File.exists(path))) {
Zotero.debug("Attachment file '" + path + "' not found", 2);
this._updateAttachmentStates(false);
return false;
}
this._updateAttachmentStates(true);
return path;
});
/**
* Update file existence state of this item and best attachment state of parent item
*/
Zotero.Item.prototype._updateAttachmentStates = function (exists) {
this._fileExists = exists;
if (this.isTopLevelItem()) {
return;
}
try {
var parentKey = this.parentKey;
}
// This can happen during classic sync conflict resolution, if a
// standalone attachment was modified locally and remotely was changed
// into a child attachment
catch (e) {
Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} doesn't exist for `
+ "source key in Zotero.Item.updateAttachmentStates()");
return;
}
try {
var item = this.ObjectsClass.getByLibraryAndKey(this.libraryID, parentKey);
}
catch (e) {
if (e instanceof Zotero.Exception.UnloadedDataException) {
Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} not yet loaded in `
+ "Zotero.Item.updateAttachmentStates()");
return;
}
throw e;
}
if (!item) {
Zotero.logError(`Attachment parent ${this.libraryID}/${parentKey} doesn't exist`);
return;
}
item.clearBestAttachmentState();
};
Zotero.Item.prototype.getFilename = function () {
Zotero.debug("getFilename() deprecated -- use .attachmentFilename");
return this.attachmentFilename;
}
/**
* Asynchronous check for file existence
*/
Zotero.Item.prototype.fileExists = Zotero.Promise.coroutine(function* () {
if (!this.isAttachment()) {
throw new Error("Zotero.Item.fileExists() can only be called on attachment items");
}
if (this.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
throw new Error("Zotero.Item.fileExists() cannot be called on link attachments");
}
return !!(yield this.getFilePathAsync());
});
/**
* Synchronous cached check for file existence, used for items view
*/
Zotero.Item.prototype.fileExistsCached = function () {
return this._fileExists;
}
/**
* Rename file associated with an attachment
*
* @param {String} newName
* @param {Boolean} [overwrite=false] - Overwrite file if one exists
* @param {Boolean} [unique=false] - Add suffix to create unique filename if necessary
* @return {Number|false} -- true - Rename successful
* -1 - Destination file exists; use _force_ to overwrite
* -2 - Error renaming
* false - Attachment file not found
*/
Zotero.Item.prototype.renameAttachmentFile = async function (newName, overwrite = false, unique = false) {
var origPath = await this.getFilePathAsync();
if (!origPath) {
Zotero.debug("Attachment file not found in renameAttachmentFile()", 2);
return false;
}
try {
let origName = OS.Path.basename(origPath);
if (this.isStoredFileAttachment()) {
var origModDate = (await OS.File.stat(origPath)).lastModificationDate;
}
// No change
if (origName === newName) {
Zotero.debug("Filename has not changed");
return true;
}
// Update mod time and clear hash so the file syncs
// TODO: use an integer counter instead of mod time for change detection
// Update mod time first, because it may fail for read-only files on Windows
if (this.isStoredFileAttachment()) {
await OS.File.setDates(origPath, null, null);
}
newName = await Zotero.File.rename(
origPath,
newName,
{
overwrite,
unique
}
);
let destPath = OS.Path.join(OS.Path.dirname(origPath), newName);
await this.relinkAttachmentFile(destPath);
if (this.isStoredFileAttachment()) {
this.attachmentSyncedHash = null;
this.attachmentSyncState = "to_upload";
await this.saveTx({ skipAll: true });
}
return true;
}
catch (e) {
Zotero.logError(e);
// Restore original modification date in case we managed to change it
if (this.isStoredFileAttachment()) {
try {
OS.File.setDates(origPath, null, origModDate);
} catch (e) {
Zotero.debug(e, 2);
}
}
return -2;
}
};
/**
* @param {string} path File path
* @param {Boolean} [skipItemUpdate] Don't update attachment item mod time, so that item doesn't
* sync. Used when a file needs to be renamed to be accessible but the user doesn't have
* access to modify the attachment metadata. This also allows a save when the library is
* read-only.
*/
Zotero.Item.prototype.relinkAttachmentFile = Zotero.Promise.coroutine(function* (path, skipItemUpdate) {
if (path instanceof Components.interfaces.nsIFile) {
Zotero.debug("WARNING: Zotero.Item.prototype.relinkAttachmentFile() now takes an absolute "
+ "file path instead of an nsIFile");
path = path.path;
}
var linkMode = this.attachmentLinkMode;
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
throw new Error('Cannot relink linked URL');
}
var fileName = OS.Path.basename(path);
if (fileName.endsWith(".lnk")) {
throw new Error("Cannot relink to Windows shortcut");
}
var newPath;
var newName = Zotero.File.getValidFileName(fileName);
if (!newName) {
throw new Error("No valid characters in filename after filtering");
}
// If selected file isn't in the attachment's storage directory,
// copy it in and use that one instead
var storageDir = Zotero.Attachments.getStorageDirectory(this).path;
if (this.isStoredFileAttachment() && OS.Path.dirname(path) != storageDir) {
newPath = OS.Path.join(storageDir, newName);
// If file with same name already exists in the storage directory,
// move it out of the way
let backupCreated = false;
if (yield OS.File.exists(newPath)) {
backupCreated = true;
yield OS.File.move(newPath, newPath + ".bak");
}
// Create storage directory if necessary
else if (!(yield OS.File.exists(storageDir))) {
yield Zotero.Attachments.createDirectoryForItem(this);
}
let newFile;
try {
newFile = Zotero.File.copyToUnique(path, newPath);
}
catch (e) {
// Restore backup file if copying failed
if (backupCreated) {
yield OS.File.move(newPath + ".bak", newPath);
}
throw e;
}
newPath = newFile.path;
// Delete backup file
if (backupCreated) {
yield OS.File.remove(newPath + ".bak");
}
}
else {
newPath = OS.Path.join(OS.Path.dirname(path), newName);
// Rename file to filtered name if necessary
if (fileName != newName) {
Zotero.debug("Renaming file '" + fileName + "' to '" + newName + "'");
try {
yield OS.File.move(path, newPath, { noOverwrite: true });
}
catch (e) {
if (e instanceof OS.File.Error && e.becauseExists && fileName.normalize() == newName) {
// Ignore normalization differences that the filesystem ignores
}
else {
throw e;
}
}
}
}
this.attachmentPath = newPath;
yield this.saveTx({
skipDateModifiedUpdate: true,
skipClientDateModifiedUpdate: skipItemUpdate,
skipEditCheck: skipItemUpdate
});
this._updateAttachmentStates(true);
yield Zotero.Notifier.trigger('refresh', 'item', this.id);
return true;
});
Zotero.Item.prototype.deleteAttachmentFile = Zotero.Promise.coroutine(function* () {
if (!this.isImportedAttachment()) {
throw new Error("deleteAttachmentFile() can only be called on imported attachment items");
}
var path = yield this.getFilePathAsync();
if (!path) {
Zotero.debug(`File not found for item ${this.libraryKey} in deleteAttachmentFile()`, 2);
return false;
}
Zotero.debug("Deleting attachment file for item " + this.libraryKey);
try {
yield Zotero.File.removeIfExists(path);
this.attachmentSyncState = "to_download";
yield this.saveTx({ skipAll: true });
return true;
}
catch (e) {
Zotero.logError(e);
return false;
}
});
/*
* Return a file:/// URL path to files and snapshots
*/
Zotero.Item.prototype.getLocalFileURL = function() {
if (!this.isAttachment()) {
throw ("getLocalFileURL() can only be called on attachment items");
}
var file = this.getFile();
if (!file) {
return false;
}
var nsIFPH = Components.classes["@mozilla.org/network/protocol;1?name=file"]
.getService(Components.interfaces.nsIFileProtocolHandler);
return nsIFPH.getURLSpecFromFile(file);
}
Zotero.Item.prototype.getAttachmentLinkMode = function() {
Zotero.debug("getAttachmentLinkMode() deprecated -- use .attachmentLinkMode");
return this.attachmentLinkMode;
}
/**
* Link mode of an attachment
*
* Possible values specified as constants in Zotero.Attachments
* (e.g. Zotero.Attachments.LINK_MODE_LINKED_FILE)
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentLinkMode', {
get: function() {
if (!this.isAttachment()) {
return undefined;
}
return this._attachmentLinkMode;
},
set: function(val) {
if (!this.isAttachment()) {
throw (".attachmentLinkMode can only be set for attachment items");
}
// Allow 'imported_url', etc.
if (typeof val == 'string') {
let code = Zotero.Attachments["LINK_MODE_" + val.toUpperCase()];
if (code !== undefined) {
val = code;
}
}
switch (val) {
case Zotero.Attachments.LINK_MODE_IMPORTED_FILE:
case Zotero.Attachments.LINK_MODE_IMPORTED_URL:
case Zotero.Attachments.LINK_MODE_LINKED_FILE:
case Zotero.Attachments.LINK_MODE_LINKED_URL:
case Zotero.Attachments.LINK_MODE_EMBEDDED_IMAGE:
break;
default:
throw new Error("Invalid attachment link mode '" + val
+ "' in Zotero.Item.attachmentLinkMode setter");
}
if (val === this.attachmentLinkMode) {
return;
}
if (!this._changed.attachmentData) {
this._changed.attachmentData = {};
}
this._changed.attachmentData.linkMode = true;
this._attachmentLinkMode = val;
}
});
Zotero.Item.prototype.getAttachmentMIMEType = function() {
Zotero.debug("getAttachmentMIMEType() deprecated -- use .attachmentContentType");
return this.attachmentContentType;
};
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentMIMEType', {
get: function() {
Zotero.debug(".attachmentMIMEType deprecated -- use .attachmentContentType");
return this.attachmentContentType;
},
enumerable: false
});
/**
* Content type of an attachment (e.g. 'text/plain')
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentContentType', {
get: function() {
if (!this.isAttachment()) {
return undefined;
}
return this._attachmentContentType;
},
set: function(val) {
if (!this.isAttachment()) {
throw (".attachmentContentType can only be set for attachment items");
}
if (!val) {
val = '';
}
if (val == this.attachmentContentType) {
return;
}
if (!this._changed.attachmentData) {
this._changed.attachmentData = {};
}
this._changed.attachmentData.contentType = true;
this._attachmentContentType = val;
}
});
Zotero.Item.prototype.getAttachmentCharset = function() {
Zotero.debug("getAttachmentCharset() deprecated -- use .attachmentCharset");
return this.attachmentCharset;
}
/**
* Character set of an attachment
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentCharset', {
get: function() {
if (!this.isAttachment()) {
return undefined;
}
return this._attachmentCharset
},
set: function(val) {
if (!this.isAttachment()) {
throw (".attachmentCharset can only be set for attachment items");
}
if (typeof val == 'number') {
throw new Error("Character set must be a string");
}
oldVal = this.attachmentCharset;
if (val) {
val = Zotero.CharacterSets.toCanonical(val);
}
if (!val) {
val = "";
}
if (val === oldVal) {
return;
}
if (!this._changed.attachmentData) {
this._changed.attachmentData= {};
}
this._changed.attachmentData.charset = true;
this._attachmentCharset = val;
}
});
/**
* Get or set the filename of file attachments
*
* This will return the filename for all file attachments, but the filename can only be set
* for stored file attachments. Linked file attachments should be set using .attachmentPath.
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentFilename', {
get: function () {
if (!this.isAttachment()) {
return undefined;
}
var path = this.attachmentPath;
if (!path) {
return '';
}
var prefixedPath = path.match(/^(?:attachments|storage):(.*)$/);
if (prefixedPath) {
return prefixedPath[1].split('/').pop();
}
return OS.Path.basename(path);
},
set: function (val) {
if (!this.isAttachment()) {
throw new Error("Attachment filename can only be set for attachment items");
}
var linkMode = this.attachmentLinkMode;
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE
|| linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
throw new Error("Attachment filename can only be set for stored files");
}
if (!val) {
throw new Error("Attachment filename cannot be blank");
}
this.attachmentPath = 'storage:' + val;
}
});
/**
* Returns raw attachment path string as stored in DB
* (e.g., "storage:foo.pdf", "attachments:foo/bar.pdf", "/Users/foo/Desktop/bar.pdf")
*
* Can be set as absolute path or prefixed string ("storage:foo.pdf")
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentPath', {
get: function() {
if (!this.isAttachment()) {
return undefined;
}
return this._attachmentPath;
},
set: function(val) {
if (!this.isAttachment()) {
throw new Error(".attachmentPath can only be set for attachment items");
}
if (typeof val != 'string') {
throw new Error(".attachmentPath must be a string");
}
var linkMode = this.attachmentLinkMode;
if (linkMode === null) {
throw new Error("Link mode must be set before setting attachment path");
}
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
throw new Error('attachmentPath cannot be set for link attachments');
}
if (!val) {
val = '';
}
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
if (this._libraryID) {
let libraryType = Zotero.Libraries.get(this._libraryID).libraryType;
if (libraryType != 'user') {
throw new Error("Linked files can only be added to user library");
}
}
// If base directory is enabled, save attachment within as relative path
if (Zotero.Prefs.get('saveRelativeAttachmentPath')) {
val = Zotero.Attachments.getBaseDirectoryRelativePath(val);
}
// Otherwise, convert relative path to absolute if possible
else {
val = Zotero.Attachments.resolveRelativePath(val) || val;
}
}
else if (this.isStoredFileAttachment()) {
if (val && !val.startsWith('storage:')) {
let storagePath = Zotero.Attachments.getStorageDirectory(this).path;
if (!val.startsWith(storagePath)) {
throw new Error("Imported file path must be within storage directory");
}
val = 'storage:' + OS.Path.basename(val);
}
}
if (val == this.attachmentPath) {
return;
}
if (!this._changed.attachmentData) {
this._changed.attachmentData = {};
}
this._changed.attachmentData.path = true;
this._attachmentPath = val;
}
});
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncState', {
get: function() {
if (!this.isAttachment()) {
return undefined;
}
return this._attachmentSyncState;
},
set: function(val) {
if (!this.isAttachment()) {
throw new Error("attachmentSyncState can only be set for attachment items");
}
if (typeof val == 'string') {
val = Zotero.Sync.Storage.Local["SYNC_STATE_" + val.toUpperCase()];
}
if (!this.isStoredFileAttachment()) {
throw new Error("attachmentSyncState can only be set for stored files");
}
switch (val) {
case Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD:
case Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD:
case Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC:
case Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_UPLOAD:
case Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_DOWNLOAD:
case Zotero.Sync.Storage.Local.SYNC_STATE_IN_CONFLICT:
break;
default:
throw new Error("Invalid sync state '" + val + "'");
}
if (val == this.attachmentSyncState) {
return;
}
if (!this._changed.attachmentData) {
this._changed.attachmentData = {};
}
this._changed.attachmentData.syncState = true;
this._attachmentSyncState = val;
}
});
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncedModificationTime', {
get: function () {
if (!this.isFileAttachment()) {
return undefined;
}
return this._attachmentSyncedModificationTime;
},
set: function (val) {
if (!this.isAttachment()) {
throw new Error("attachmentSyncedModificationTime can only be set for attachment items");
}
if (!this.isStoredFileAttachment()) {
throw new Error("attachmentSyncedModificationTime can only be set for stored files");
}
if (typeof val != 'number') {
Zotero.debug(val, 2);
throw new Error("attachmentSyncedModificationTime must be a number");
}
if (parseInt(val) != val || val < 0) {
Zotero.debug(val, 2);
throw new Error("attachmentSyncedModificationTime must be a timestamp in milliseconds");
}
if (val < 10000000000) {
Zotero.logError("attachmentSyncedModificationTime should be a timestamp in milliseconds "
+ "-- " + val + " given");
}
if (val == this._attachmentSyncedModificationTime) {
return;
}
if (!this._changed.attachmentData) {
this._changed.attachmentData = {};
}
this._changed.attachmentData.syncedModificationTime = true;
this._attachmentSyncedModificationTime = val;
}
});
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncedHash', {
get: function () {
if (!this.isFileAttachment()) {
return undefined;
}
return this._attachmentSyncedHash;
},
set: function (val) {
if (!this.isAttachment()) {
throw ("attachmentSyncedHash can only be set for attachment items");
}
if (!this.isStoredFileAttachment()) {
throw new Error("attachmentSyncedHash can only be set for stored files");
}
if (val !== null && val.length != 32) {
throw new Error("Invalid attachment hash '" + val + "'");
}
if (val == this._attachmentSyncedHash) {
return;
}
if (!this._changed.attachmentData) {
this._changed.attachmentData = {};
}
this._changed.attachmentData.syncedHash = true;
this._attachmentSyncedHash = val;
}
});
//
// PDF attachment properties
//
for (let name of ['lastProcessedModificationTime']) {
let prop = 'attachment' + Zotero.Utilities.capitalize(name);
Zotero.defineProperty(Zotero.Item.prototype, prop, {
get: function () {
if (!this.isFileAttachment()) {
return undefined;
}
return this['_' + prop];
},
set: function (val) {
if (!this.isFileAttachment()) {
throw new Error(`${prop} can only be set for file attachments`);
}
if (this.isEmbeddedImageAttachment()) {
throw new Error(`${prop} cannot be set for embedded-image attachments`);
}
switch (name) {
case 'lastProcessedModificationTime':
if (typeof val != 'number') {
Zotero.debug(val, 2);
throw new Error(`${prop} must be a number`);
}
if (parseInt(val) != val || val < 0) {
Zotero.debug(val, 2);
throw new Error(`${prop} must be a timestamp in seconds`);
}
// Change before 2065!
if (val > 3000000000) {
throw new Error(`${prop} should be a timestamp in seconds `
+ "-- " + val + " given");
}
break;
}
if (val == this['_' + prop]) {
return;
}
if (!this._changed.attachmentData) {
this._changed.attachmentData = {};
}
this._changed.attachmentData[name] = true;
this['_' + prop] = val;
}
});
}
Zotero.Item.prototype.getAttachmentLastPageIndex = function () {
if (!this.isFileAttachment()) {
throw new Error("getAttachmentLastPageIndex() can only be called on file attachments");
}
var id = this._getLastPageIndexSettingKey();
var val = Zotero.SyncedSettings.get(Zotero.Libraries.userLibraryID, id);
if (val !== null && (typeof val != 'number' || val != parseInt(val))) {
Zotero.logError(`Setting contains an invalid attachment page index ('${val}') -- discarding`);
return null;
}
return val;
};
Zotero.Item.prototype.setAttachmentLastPageIndex = async function (val) {
if (!this.isFileAttachment()) {
throw new Error("setAttachmentLastPageIndex() can only be called on file attachments");
}
if (typeof val != 'number' || val != parseInt(val)) {
Zotero.debug(val, 2);
throw new Error(`setAttachmentLastPageIndex() must be passed an integer`);
}
var id = this._getLastPageIndexSettingKey();
if (val === null) {
return Zotero.SyncedSettings.clear(id);
}
return Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, id, val);
};
/**
* Get the key for the item's pageIndex synced setting
*
* E.g., 'lastPageIndex_u_ABCD2345' or 'lastPageIndex_g123_ABCD2345'
*/
Zotero.Item.prototype._getLastPageIndexSettingKey = function (ignoreInvalid) {
var library = Zotero.Libraries.get(this.libraryID);
var id = 'lastPageIndex_';
switch (library.libraryType) {
case 'user':
id += 'u';
break;
case 'group':
id += 'g' + library.libraryTypeID;
break;
default:
var msg = `Can't get last page index key for ${library.libraryType} item`;
if (ignoreInvalid) {
Zotero.logError(msg);
return false;
}
throw new Error(msg);
}
id += "_" + this.key;
return id;
};
/**
* Modification time of an attachment file
*
* Note: This is the mod time of the file itself, not the last-known mod time
* of the file on the storage server as stored in the database
*
* @return {Promise<Number|undefined>} File modification time as timestamp in milliseconds,
* or undefined if no file
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentModificationTime', {
get: Zotero.Promise.coroutine(function* () {
if (!this.isFileAttachment()) {
return undefined;
}
if (!this.id) {
return undefined;
}
var path = yield this.getFilePathAsync();
if (!path) {
return undefined;
}
var fmtime = ((yield OS.File.stat(path)).lastModificationDate).getTime();
if (fmtime < 1) {
Zotero.debug("File mod time " + fmtime + " is less than 1 -- interpreting as 1", 2);
fmtime = 1;
}
return fmtime;
})
});
/**
* MD5 hash of an attachment file
*
* Note: This is the hash of the file itself, not the last-known hash
* of the file on the storage server as stored in the database
*
* @return {Promise<String>} - MD5 hash of file as hex string
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentHash', {
get: Zotero.Promise.coroutine(function* () {
if (!this.isAttachment()) {
return undefined;
}
if (!this.id) {
return undefined;
}
var path = yield this.getFilePathAsync();
if (!path) {
return undefined;
}
return Zotero.Utilities.Internal.md5Async(path);
})
});
/**
* Return plain text of attachment content
*
* - Currently works on HTML, PDF and plaintext attachments
* - Paragraph breaks will be lost in PDF content
*
* @return {Promise<String>} - A promise for attachment text or empty string if unavailable
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentText', {
get: async function () {
if (!this.isAttachment()) {
return undefined;
}
if (!this.id) {
return null;
}
var path = await this.getFilePathAsync();
var contentType = this.attachmentContentType;
if (!contentType) {
if (!path) {
Zotero.debug(`Can't get attachment text for item ${this.libraryKey}`);
return '';
}
contentType = await Zotero.MIME.getMIMETypeFromFile(path);
}
var str;
if (Zotero.Fulltext.isCachedMIMEType(contentType)) {
// If no cache file or not fully indexed, get text on-demand
let cacheFile = Zotero.Fulltext.getItemCacheFile(this);
if (!cacheFile.exists() || !await Zotero.FullText.isFullyIndexed(this)) {
// Use processor cache file if it exists
let processorCacheFile = Zotero.FullText.getItemProcessorCacheFile(this).path;
if (await OS.File.exists(processorCacheFile)) {
let json = await Zotero.File.getContentsAsync(processorCacheFile);
let data = JSON.parse(json);
str = data.text;
}
// Otherwise extract text to temporary file and read that
else if (contentType == 'application/pdf') {
let tmpCacheFile = OS.Path.join(
Zotero.getTempDirectory().path, Zotero.Utilities.randomString()
);
let { exec, args } = Zotero.FullText.getPDFConverterExecAndArgs();
args.push(
'-nopgbrk',
path,
tmpCacheFile
);
await Zotero.Utilities.Internal.exec(exec, args);
if (!await OS.File.exists(tmpCacheFile)) {
Zotero.logError("Cache file not found after running PDF converter");
return '';
}
str = await Zotero.File.getContentsAsync(tmpCacheFile);
await OS.File.remove(tmpCacheFile);
}
else {
Zotero.logError("Unsupported cached file type in .attachmentText");
return '';
}
}
else {
str = await Zotero.File.getContentsAsync(cacheFile);
}
}
else if (contentType == 'text/html') {
str = await Zotero.File.getContentsAsync(path);
str = Zotero.Utilities.unescapeHTML(str);
}
else if (contentType == 'text/plain') {
str = await Zotero.File.getContentsAsync(path);
}
else {
return '';
}
return str.trim();
}
});
/**
* Return dataURI of attachment content
*
* @return {Promise<String>} - A promise for attachment dataURI or empty string if unavailable
*/
Zotero.defineProperty(Zotero.Item.prototype, 'attachmentDataURI', {
get: async function () {
if (!this.isAttachment()) {
throw new Error("'attachmentDataURI' is only valid for attachments");
}
let path = await this.getFilePathAsync();
if (!path || !(await OS.File.exists(path))) {
return '';
}
let buf = await OS.File.read(path, {});
let bytes = new Uint8Array(buf);
let binary = '';
let len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return 'data:' + this.attachmentContentType + ';base64,' + btoa(binary);
}
});
/**
* Returns child attachments of this item
*
* @param {Boolean} includeTrashed Include trashed child items
* @return {Integer[]} Array of itemIDs
*/
Zotero.Item.prototype.getAttachments = function(includeTrashed) {
if (this.isAttachment()) {
throw new Error("getAttachments() cannot be called on attachment items");
}
this._requireData('childItems');
if (!this._attachments) {
return [];
}
var cacheKey = (Zotero.Prefs.get('sortAttachmentsChronologically') ? 'chronological' : 'alphabetical')
+ 'With' + (includeTrashed ? '' : 'out') + 'Trashed';
if (this._attachments[cacheKey]) {
return this._attachments[cacheKey];
}
var rows = this._attachments.rows.concat();
// Remove trashed items if necessary
if (!includeTrashed) {
rows = rows.filter(row => !row.trashed);
}
// Sort by title if necessary
if (!Zotero.Prefs.get('sortAttachmentsChronologically')) {
var collation = Zotero.getLocaleCollation();
rows.sort((a, b) => collation.compareString(1, a.title, b.title));
}
var ids = rows.map(row => row.itemID);
this._attachments[cacheKey] = ids;
return ids;
}
/**
* Looks for attachment in the following order: oldest PDF attachment matching parent URL,
* oldest non-PDF attachment matching parent URL, oldest PDF attachment not matching URL,
* old non-PDF attachment not matching URL
*
* @return {Promise<Zotero.Item|FALSE>} - A promise for attachment item or FALSE if none
*/
Zotero.Item.prototype.getBestAttachment = Zotero.Promise.coroutine(function* () {
if (!this.isRegularItem()) {
throw ("getBestAttachment() can only be called on regular items");
}
var attachments = yield this.getBestAttachments();
return attachments ? attachments[0] : false;
});
/**
* Looks for attachment in the following order: oldest PDF attachment matching parent URL,
* oldest PDF attachment not matching parent URL, oldest non-PDF attachment matching parent URL,
* old non-PDF attachment not matching parent URL
*
* @return {Promise<Zotero.Item[]>} - A promise for an array of Zotero items
*/
Zotero.Item.prototype.getBestAttachments = Zotero.Promise.coroutine(function* () {
if (!this.isRegularItem()) {
throw new Error("getBestAttachments() can only be called on regular items");
}
var url = this.getField('url');
var urlFieldID = Zotero.ItemFields.getID('url');
var sql = "SELECT IA.itemID FROM itemAttachments IA NATURAL JOIN items I "
+ `LEFT JOIN itemData ID ON (IA.itemID=ID.itemID AND fieldID=${urlFieldID}) `
+ "LEFT JOIN itemDataValues IDV ON (ID.valueID=IDV.valueID) "
+ `WHERE parentItemID=? AND linkMode NOT IN (${Zotero.Attachments.LINK_MODE_LINKED_URL}) `
+ "AND IA.itemID NOT IN (SELECT itemID FROM deletedItems) "
+ "ORDER BY contentType='application/pdf' DESC, value=? DESC, dateAdded ASC";
var itemIDs = yield Zotero.DB.columnQueryAsync(sql, [this.id, url]);
return this.ObjectsClass.get(itemIDs);
});
/**
* Return state of best attachment
*
* @return {Promise<Object>} - Promise for object with string 'type' ('none'|'pdf'|'snapshot'|'other')
* and boolean 'exists'
*/
Zotero.Item.prototype.getBestAttachmentState = async function () {
if (this._bestAttachmentState !== null) {
return this._bestAttachmentState;
}
var item = await this.getBestAttachment();
if (!item) {
return this._bestAttachmentState = {
type: 'none'
};
}
var type;
if (item.isPDFAttachment()) {
type = 'pdf';
}
else if (item.isSnapshotAttachment()) {
type = 'snapshot';
}
else {
type = 'other';
}
var exists = await item.fileExists();
return this._bestAttachmentState = { type, exists };
};
/**
* Return cached state of best attachment for use in items view
*
* @return {Object|null} - Resolved value from getBestAttachmentState() or { type: null } if
* unavailable
*/
Zotero.Item.prototype.getBestAttachmentStateCached = function () {
return this._bestAttachmentState || { type: null };
}
Zotero.Item.prototype.clearBestAttachmentState = function () {
this._bestAttachmentState = null;
}
////////////////////////////////////////////////////////
//
//
// Annotation methods
//
//
////////////////////////////////////////////////////////
// Main annotation properties (required for items list display)
for (let name of ['type', 'authorName', 'text', 'comment', 'color', 'pageLabel', 'sortIndex', 'isExternal']) {
let field = 'annotation' + name[0].toUpperCase() + name.substr(1);
Zotero.defineProperty(Zotero.Item.prototype, field, {
get: function () {
this._requireData('annotation');
return this._getLatestField(field);
},
set: function (value) {
this._requireData('annotation');
// Normalize values
if (typeof value == 'string') {
value = value.trim().normalize();
if (value === "") {
value = null;
}
}
if (this._getLatestField(field) === value) {
return;
}
if (name != 'type' && !this._getLatestField('annotationType')) {
throw new Error("annotationType must be set before other annotation properties");
}
switch (name) {
case 'type': {
let currentType = this._getLatestField('annotationType');
if (currentType && currentType != value) {
throw new Error("Cannot change annotation type");
}
if (!['highlight', 'note', 'image', 'ink'].includes(value)) {
let e = new Error(`Unknown annotation type '${value}'`);
e.name = "ZoteroInvalidDataError";
throw e;
}
break;
}
case 'text':
if (this._getLatestField('annotationType') != 'highlight') {
throw new Error("'annotationText' can only be set for highlight annotations");
}
break;
case 'color':
// Require 6-char hex value
if (!value.match(/#[a-f0-9]{6}/)) {
let e = new Error(`Invalid annotation color '${value}'`);
e.name = "ZoteroInvalidDataError";
throw e;
}
break;
case 'sortIndex':
if (!/^\d{5}\|\d{6}\|\d{5}$/.test(value)) {
throw new Error(`Invalid sortIndex '${value}'`);
}
break;
case 'isExternal':
if (typeof value != 'boolean') {
throw new Error('annotationIsExternal must be a boolean');
}
let currentValue = this._getLatestField('annotationIsExternal');
if (currentValue !== null && currentValue !== value) {
throw new Error("Cannot change annotationIsExternal");
}
break;
}
this._markFieldChange(field, value);
this._changed.annotation = true;
}
});
}
// Deferred annotation properties (not necessary until viewed)
for (let name of ['position']) {
let field = 'annotation' + name[0].toUpperCase() + name.substr(1);
Zotero.defineProperty(Zotero.Item.prototype, field, {
get: function () {
this._requireData('annotationDeferred');
return this._getLatestField(field);
},
set: function (value) {
this._requireData('annotationDeferred');
if (typeof value != 'string') {
throw new Error(`${field} must be a string`);
}
if (this._getLatestField(field) === value) {
return;
}
this._markFieldChange(field, value);
this._changed.annotationDeferred = true;
}
});
}
/**
* Determine if an item is an annotation
*
* @return {Boolean}
**/
Zotero.Item.prototype.isAnnotation = function() {
return Zotero.ItemTypes.getName(this.itemTypeID) == 'annotation';
}
/**
* Determine if an item is an annotation
*
* @return {Boolean}
**/
Zotero.Item.prototype.isAnnotationSupportingImage = function() {
return this.isAnnotation() && ['image', 'ink'].includes(this._getLatestField('annotationType'));
}
Zotero.Item.prototype.numAnnotations = function (includeTrashed) {
if (!this.isFileAttachment()) {
throw new Error("numAnnotations() can only be called on file attachments");
}
this._requireData('childItems');
if (!this._annotations) {
return 0;
}
var cacheKey = 'with' + (includeTrashed ? '' : 'out') + 'Trashed';
if (this._annotations[cacheKey]) {
return this._annotations[cacheKey].length
}
var rows = this._annotations.rows;
// Remove trashed items if necessary
if (!includeTrashed) {
rows = rows.filter(row => !row.trashed);
}
var ids = rows.map(row => row.itemID);
this._annotations[cacheKey] = ids;
return rows.length;
};
/**
* Returns child annotations for an attachment item
*
* @param {Boolean} [includeTrashed=false] - Include annotations in trash
* @return {Zotero.Item[]}
*/
Zotero.Item.prototype.getAnnotations = function (includeTrashed) {
if (!this.isFileAttachment()) {
throw new Error("getAnnotations() can only be called on file attachments");
}
this._requireData('childItems');
if (!this._annotations) {
return [];
}
var cacheKey = 'with' + (includeTrashed ? '' : 'out') + 'Trashed';
if (this._annotations[cacheKey]) {
return Zotero.Items.get([...this._annotations[cacheKey]]);
}
var rows = this._annotations.rows;
// Remove trashed items if necessary
if (!includeTrashed) {
rows = rows.filter(row => !row.trashed);
}
var ids = rows.map(row => row.itemID);
this._annotations[cacheKey] = ids;
return Zotero.Items.get(ids);
};
//
// Methods dealing with item tags
//
//
/**
* Returns all tags assigned to an item
*
* @return {Array} Array of tag data in API JSON format
*/
Zotero.Item.prototype.getTags = function () {
this._requireData('tags');
// BETTER DEEP COPY?
return JSON.parse(JSON.stringify(this._getLatestField('tags')));
};
/**
* Check if the item has a given tag
*
* @param {String}
* @return {Boolean}
*/
Zotero.Item.prototype.hasTag = function (tagName) {
this._requireData('tags');
var tags = this._getLatestField('tags');
return tags.some(tagData => tagData.tag == tagName);
}
/**
* Get the assigned type for a given tag of the item
*/
Zotero.Item.prototype.getTagType = function (tagName) {
this._requireData('tags');
var tags = this._getLatestField('tags');
for (let tag of tags) {
if (tag.tag === tagName) {
return tag.type ? tag.type : 0;
}
}
return null;
}
/**
* Set the item's tags
*
* A separate save() is required to update the database.
*
* @param {String[]|Object[]} tags - Array of strings or object in API JSON format
* (e.g., [{tag: 'tag', type: 1}])
*/
Zotero.Item.prototype.setTags = function (tags) {
this._requireData('tags');
var oldTags = this._getLatestField('tags');
var newTags = tags.concat()
// Allow array of strings
.map(tag => typeof tag == 'string' ? { tag } : tag);
for (let i=0; i<oldTags.length; i++) {
oldTags[i] = Zotero.Tags.cleanData(oldTags[i]);
}
for (let i=0; i<newTags.length; i++) {
newTags[i] = Zotero.Tags.cleanData(newTags[i]);
}
// Sort to allow comparison with JSON, which maybe we'll stop doing if it's too slow
var sorter = function (a, b) {
if (a.type < b.type) return -1;
if (a.type > b.type) return 1;
return a.tag.localeCompare(b.tag);
};
oldTags.sort(sorter);
newTags.sort(sorter);
if (JSON.stringify(oldTags) == JSON.stringify(newTags)) {
Zotero.debug("Tags haven't changed", 4);
return;
}
this._markFieldChange('tags', newTags);
}
/**
* Add a single tag to the item. If type is 1 and an automatic tag with the same name already
* exists, replace it with a manual one.
*
* A separate save() is required to update the database.
*
* @param {String} name
* @param {Number} [type=0]
* @return {Boolean} - True if the tag was added; false if the item already had the tag
*/
Zotero.Item.prototype.addTag = function (name, type) {
type = type ? parseInt(type) : 0;
var changed = false;
var tags = this.getTags();
for (let i=0; i<tags.length; i++) {
let tag = tags[i];
if (tag.tag === name) {
if (tag.type == type) {
Zotero.debug("Tag '" + name + "' already exists on item " + this.libraryKey);
return false;
}
tag.type = type;
changed = true;
break;
}
}
if (!changed) {
tags.push({
tag: name,
type: type
});
}
this.setTags(tags);
return true;
}
/**
* Replace an existing tag with a new manual tag
*
* A separate save() is required to update the database.
*
* @param {String} oldTag
* @param {String} newTag
*/
Zotero.Item.prototype.replaceTag = function (oldTag, newTag) {
var tags = this.getTags();
newTag = newTag.trim();
if (newTag === "") {
Zotero.debug('Not replacing with empty tag', 2);
return false;
}
var changed = false;
for (let i=0; i<tags.length; i++) {
let tag = tags[i];
if (tag.tag === oldTag) {
tag.tag = newTag;
tag.type = 0;
changed = true;
}
}
if (!changed) {
Zotero.debug("Tag '" + oldTag + "' not found on item -- not replacing", 2);
return false;
}
this.setTags(tags);
return true;
}
/**
* Remove a tag from the item
*
* A separate save() is required to update the database.
*
* @param {String} tagName
* @return {Boolean} - True if the tag was removed; false if the item didn't have the tag
*/
Zotero.Item.prototype.removeTag = function(tagName) {
this._requireData('tags');
var oldTags = this._getLatestField('tags');
var newTags = oldTags.filter(tagData => tagData.tag !== tagName);
if (newTags.length == oldTags.length) {
Zotero.debug('Cannot remove missing tag ' + tagName + ' from item ' + this.libraryKey);
return false;
}
this.setTags(newTags);
return true;
}
/**
* Remove all tags from the item
*
* A separate save() is required to update the database.
*/
Zotero.Item.prototype.removeAllTags = function() {
this._requireData('tags');
this.setTags([]);
}
//
// Methods dealing with collections
//
/**
* Gets the collections the item is in
*
* @return {Array<Integer>} An array of collectionIDs for all collections the item belongs to
*/
Zotero.Item.prototype.getCollections = function () {
this._requireData('collections');
return this._collections.concat();
};
/**
* Sets the collections the item is in
*
* A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes.
*
* @param {Array<String|Integer>} collectionIDsOrKeys Collection ids or keys
*/
Zotero.Item.prototype.setCollections = function (collectionIDsOrKeys) {
if (!this.libraryID) {
this.libraryID = Zotero.Libraries.userLibraryID;
}
this._requireData('collections');
if (!collectionIDsOrKeys) {
collectionIDsOrKeys = [];
}
// Convert any keys to ids
var collectionIDs = collectionIDsOrKeys.map(function (val) {
if (typeof val == 'number') {
return val;
}
var id = this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, val);
if (!id) {
let e = new Error("Collection " + val + " not found for item " + this.libraryKey);
e.name = "ZoteroMissingObjectError";
throw e;
}
return id;
}.bind(this));
collectionIDs = Zotero.Utilities.arrayUnique(collectionIDs);
if (Zotero.Utilities.arrayEquals(this._collections, collectionIDs)) {
Zotero.debug("Collections have not changed for item " + this.id);
return;
}
this._markFieldChange("collections", this._collections);
this._collections = collectionIDs;
this._changed.collections = true;
};
/**
* Add this item to a collection
*
* A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes.
*
* @param {Number} collectionID
*/
Zotero.Item.prototype.addToCollection = function (collectionIDOrKey) {
if (!this.libraryID) {
this.libraryID = Zotero.Libraries.userLibraryID;
}
var collectionID = parseInt(collectionIDOrKey) == collectionIDOrKey
? parseInt(collectionIDOrKey)
: this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, collectionIDOrKey)
if (!collectionID) {
throw new Error("Invalid collection '" + collectionIDOrKey + "'");
}
this._requireData('collections');
if (this._collections.indexOf(collectionID) != -1) {
Zotero.debug("Item is already in collection " + collectionID);
return;
}
this.setCollections(this._collections.concat(collectionID));
};
/**
* Remove this item from a collection
*
* A separate save() (with options.skipDateModifiedUpdate, possibly) is required to save changes.
*
* @param {Number} collectionID
*/
Zotero.Item.prototype.removeFromCollection = function (collectionIDOrKey) {
if (!this.libraryID) {
this.libraryID = Zotero.Libraries.userLibraryID;
}
var collectionID = parseInt(collectionIDOrKey) == collectionIDOrKey
? parseInt(collectionIDOrKey)
: this.ContainerObjectsClass.getIDFromLibraryAndKey(this.libraryID, collectionIDOrKey)
if (!collectionID) {
throw new Error("Invalid collection '" + collectionIDOrKey + "'");
}
this._requireData('collections');
var pos = this._collections.indexOf(collectionID);
if (pos == -1) {
Zotero.debug("Item is not in collection " + collectionID);
return;
}
this.setCollections(this._collections.slice(0, pos).concat(this._collections.slice(pos + 1)));
};
/**
* Determine whether the item belongs to a given collectionID
**/
Zotero.Item.prototype.inCollection = function (collectionID) {
this._requireData('collections');
return this._collections.indexOf(collectionID) != -1;
};
/**
* Update item deleted (i.e., trash) state without marking as changed or modifying DB
*
* This is used by Zotero.Items.trash().
*
* Database state must be set separately!
*
* @param {Boolean} deleted
*/
Zotero.DataObject.prototype.setDeleted = Zotero.Promise.coroutine(function* (deleted) {
if (!this.id) {
throw new Error("Cannot update deleted state of unsaved item");
}
this._deleted = !!deleted;
if (this._changedData.deleted !== undefined) {
delete this._changedData.deleted;
}
});
/**
* Update item publications state without marking as changed or modifying DB
*
* This is used by Zotero.Items.addToPublications()/removeFromPublications()
*
* Database state must be set separately!
*
* @param {Boolean} inPublications
*/
Zotero.DataObject.prototype.setPublications = Zotero.Promise.coroutine(function* (inPublications) {
if (!this.id) {
throw new Error("Cannot update publications state of unsaved item");
}
this._inPublications = !!inPublications;
if (this._changed.inPublications) {
delete this._changed.inPublications;
}
});
Zotero.Item.prototype.getImageSrc = function() {
var itemType = Zotero.ItemTypes.getName(this.itemTypeID);
if (itemType == 'attachment') {
var linkMode = this.attachmentLinkMode;
if (this.attachmentContentType == 'application/pdf' && this.isFileAttachment()) {
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
itemType += '-pdf-link';
}
else {
itemType += '-pdf';
}
}
else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) {
itemType += "-file";
}
else if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
itemType += "-link";
}
else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) {
itemType += "-snapshot";
}
else if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
itemType += "-web-link";
}
}
return Zotero.ItemTypes.getImageSrc(itemType);
}
Zotero.Item.prototype.getTagColors = function () {
Zotero.warn("Zotero.Item::getTagColors() is deprecated -- use Zotero.Item::getColoredTags()");
return this.getColoredTags().map(x => x.color);
};
/**
* Return tags and colors
*
* @return {Object[]} - Array of object with 'tag' and 'color' properties
*/
Zotero.Item.prototype.getColoredTags = function () {
var tags = this.getTags();
if (!tags.length) return [];
let colorData = [];
let tagColors = Zotero.Tags.getColors(this.libraryID);
for (let tag of tags) {
let data = tagColors.get(tag.tag);
if (data) {
colorData.push({tag: tag.tag, ...data});
}
}
return colorData.sort((a, b) => a.position - b.position).map(x => ({ tag: x.tag, color: x.color }));
};
/**
* Compares this item to another
*
* Returns a two-element array containing two objects with the differing values,
* or FALSE if no differences
*
* @param {Zotero.Item} item Zotero.Item to compare this item to
* @param {Boolean} includeMatches Include all fields, even those that aren't different
* @param {Boolean} ignoreFields If no fields other than those specified
* are different, just return false --
* only works for primary fields
*/
Zotero.Item.prototype.diff = function (item, includeMatches, ignoreFields) {
var diff = [];
if (!ignoreFields) {
ignoreFields = [];
}
var thisData = this.serialize();
var otherData = item.serialize();
var numDiffs = this.ObjectsClass.diff(thisData, otherData, diff, includeMatches);
diff[0].creators = [];
diff[1].creators = [];
// TODO: creators?
// TODO: tags?
// TODO: related?
// TODO: annotations
var changed = false;
changed = thisData.parentKey != otherData.parentKey;
if (includeMatches || changed) {
diff[0].parentKey = thisData.parentKey;
diff[1].parentKey = otherData.parentKey;
if (changed) {
numDiffs++;
}
}
if (thisData.attachment) {
for (var field in thisData.attachment) {
changed = thisData.attachment[field] != otherData.attachment[field];
if (includeMatches || changed) {
if (!diff[0].attachment) {
diff[0].attachment = {};
diff[1].attachment = {};
}
diff[0].attachment[field] = thisData.attachment[field];
diff[1].attachment[field] = otherData.attachment[field];
}
if (changed) {
numDiffs++;
}
}
}
if (thisData.note != undefined) {
// Whitespace and entity normalization
//
// Ideally this would all be fixed elsewhere so we didn't have to
// convert on every sync diff
//
// TEMP: Using a try/catch to avoid unexpected errors in 2.1 releases
try {
var thisNote = thisData.note;
var otherNote = otherData.note;
// Stop non-Unix newlines from triggering erroneous conflicts
thisNote = thisNote.replace(/\r\n?/g, "\n");
otherNote = otherNote.replace(/\r\n?/g, "\n");
// Normalize multiple spaces (due to differences TinyMCE, Z.U.text2html(),
// and the server)
var re = /(&nbsp; |&nbsp;&nbsp;|\u00a0 |\u00a0\u00a0)/g;
thisNote = thisNote.replace(re, " ");
otherNote = otherNote.replace(re, " ");
// Normalize new paragraphs
var re = /<p>(&nbsp;|\u00a0)<\/p>/g;
thisNote = thisNote.replace(re, "<p> </p>");
otherNote = otherNote.replace(re, "<p> </p>");
// Unencode XML entities
thisNote = thisNote.replace(/&amp;/g, "&");
otherNote = otherNote.replace(/&amp;/g, "&");
thisNote = thisNote.replace(/&apos;/g, "'");
otherNote = otherNote.replace(/&apos;/g, "'");
thisNote = thisNote.replace(/&quot;/g, '"');
otherNote = otherNote.replace(/&quot;/g, '"');
thisNote = thisNote.replace(/&lt;/g, "<");
otherNote = otherNote.replace(/&lt;/g, "<");
thisNote = thisNote.replace(/&gt;/g, ">");
otherNote = otherNote.replace(/&gt;/g, ">");
changed = thisNote != otherNote;
}
catch (e) {
Zotero.debug(e);
Components.utils.reportError(e);
changed = thisNote != otherNote;
}
if (includeMatches || changed) {
diff[0].note = thisNote;
diff[1].note = otherNote;
}
if (changed) {
numDiffs++;
}
}
//Zotero.debug(thisData);
//Zotero.debug(otherData);
//Zotero.debug(diff);
if (numDiffs == 0) {
return false;
}
if (ignoreFields.length && diff[0].primary) {
if (includeMatches) {
throw ("ignoreFields cannot be used if includeMatches is set");
}
var realDiffs = numDiffs;
for (let field of ignoreFields) {
if (diff[0].primary[field] != undefined) {
realDiffs--;
if (realDiffs == 0) {
return false;
}
}
}
}
return diff;
}
/**
* Compare multiple items against this item and return fields that differ
*
* Currently compares only item data, not primary fields
*/
Zotero.Item.prototype.multiDiff = function (otherItems, ignoreFields) {
var thisData = this.toJSON();
var alternatives = {};
var hasDiffs = false;
for (let i = 0; i < otherItems.length; i++) {
let otherData = otherItems[i].toJSON();
let changeset = Zotero.DataObjectUtilities.diff(thisData, otherData, ignoreFields);
for (let i = 0; i < changeset.length; i++) {
let change = changeset[i];
if (change.op == 'delete') {
continue;
}
if (!alternatives[change.field]) {
hasDiffs = true;
alternatives[change.field] = [change.value];
}
else if (alternatives[change.field].indexOf(change.value) == -1) {
hasDiffs = true;
alternatives[change.field].push(change.value);
}
}
}
if (!hasDiffs) {
return false;
}
return alternatives;
};
/**
* Returns an unsaved copy of the item without itemID and key
*
* This is used to duplicate items and copy them between libraries.
*
* @param {Number} [libraryID] - libraryID of the new item, or the same as original if omitted
* @param {Boolean} [options.skipTags=false] - Skip tags
* @param {Boolean} [options.includeCollections=false] - Add new item to all collections
* @return {Zotero.Item}
*/
Zotero.Item.prototype.clone = function (libraryID, options = {}) {
Zotero.debug('Cloning item ' + this.id);
if (libraryID !== undefined && libraryID !== null && typeof libraryID !== 'number') {
throw new Error("libraryID must be null or an integer");
}
if (libraryID === undefined || libraryID === null) {
libraryID = this.libraryID;
}
var sameLibrary = libraryID == this.libraryID;
var newItem = new Zotero.Item;
newItem.libraryID = libraryID;
newItem.setType(this.itemTypeID);
var fieldIDs = this.getUsedFields();
for (let i = 0; i < fieldIDs.length; i++) {
let fieldID = fieldIDs[i];
newItem.setField(fieldID, this.getField(fieldID));
}
// Regular item
if (this.isRegularItem()) {
newItem.setCreators(this.getCreators());
}
else if (this.isNote() || this.isAttachment()) {
newItem.setNote(this.getNote());
if (sameLibrary) {
var parent = this.parentKey;
if (parent) {
newItem.parentKey = parent;
}
}
if (this.isAttachment()) {
newItem.attachmentLinkMode = this.attachmentLinkMode;
newItem.attachmentContentType = this.attachmentContentType;
newItem.attachmentCharset = this.attachmentCharset;
if (sameLibrary) {
if (this.attachmentPath) {
newItem.attachmentPath = this.attachmentPath;
}
}
}
}
else if (this.isAnnotation()) {
let props = Zotero.Annotations.PROPS;
for (let prop of props) {
let fullProp = 'annotation' + Zotero.Utilities.capitalize(prop);
newItem[fullProp] = this[fullProp];
}
}
if (!options.skipTags) {
newItem.setTags(this.getTags());
}
if (options.includeCollections) {
if (!sameLibrary) {
throw new Error("Can't include collections when cloning to different library");
}
newItem.setCollections(this.getCollections());
}
if (sameLibrary) {
// DEBUG: this will add reverse-only relateds too
let relations = this.getRelations();
// Only include certain relations
let predicates = [
Zotero.Relations.relatedItemPredicate,
];
let any = false;
let newRelations = {};
for (let predicate of predicates) {
if (relations[predicate]) {
newRelations[predicate] = relations[predicate];
any = true;
}
}
if (any) {
newItem.setRelations(newRelations);
}
}
return newItem;
}
/**
* @param {Zotero.Item} item
* @param {Integer} libraryID
* @return {Zotero.Item} - New item
*/
Zotero.Item.prototype.moveToLibrary = async function (libraryID, onSkippedAttachment) {
if (!this.isEditable()) {
throw new Error("Can't move item in read-only library");
}
var library = Zotero.Libraries.get(libraryID);
Zotero.debug("Moving item to " + library.name);
if (!library.editable) {
throw new Error("Can't move item to read-only library");
}
var filesEditable = library.filesEditable;
var allowsLinkedFiles = library.allowsLinkedFiles;
var newItem = await Zotero.DB.executeTransaction(async function () {
// Create new clone item in target library
var newItem = this.clone(libraryID);
var newItemID = await newItem.save({
skipSelect: true
});
if (this.isNote()) {
// Delete old item
await this.erase();
return newItem;
}
// For regular items, add child items
// Child notes
var noteIDs = this.getNotes();
var notes = Zotero.Items.get(noteIDs);
for (let note of notes) {
let newNote = note.clone(libraryID);
newNote.parentID = newItemID;
await newNote.save({
skipSelect: true
});
}
// Child attachments
var attachmentIDs = this.getAttachments();
var attachments = Zotero.Items.get(attachmentIDs);
for (let attachment of attachments) {
let linkMode = attachment.attachmentLinkMode;
// Skip linked files if not allowed in destination
if (!allowsLinkedFiles && linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Target library doesn't support linked files -- skipping attachment");
if (onSkippedAttachment) {
await onSkippedAttachment(attachment);
}
continue;
}
// Skip files if not allowed in destination
if (!filesEditable && linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
Zotero.debug("Target library doesn't allow file editing -- skipping attachment");
if (onSkippedAttachment) {
await onSkippedAttachment(attachment);
}
continue;
}
await Zotero.Attachments.moveAttachmentToLibrary(
attachment, libraryID, newItemID
);
}
return newItem;
}.bind(this));
// Delete old item. Do this outside of a transaction so we don't leave stranded files
// in the target library if deleting fails.
await this.eraseTx();
return newItem;
};
Zotero.Item.prototype._eraseData = Zotero.Promise.coroutine(function* (env) {
Zotero.DB.requireTransaction();
// Remove item from parent collections
var parentCollectionIDs = this._collections;
for (let parentCollectionID of parentCollectionIDs) {
let parentCollection = yield Zotero.Collections.getAsync(parentCollectionID);
yield parentCollection.removeItem(
this.id,
{
skipEditCheck: env.options.skipEditCheck
}
);
}
var parentItem = this.parentKey;
parentItem = parentItem
? (yield this.ObjectsClass.getByLibraryAndKeyAsync(this.libraryID, parentItem))
: null;
if (parentItem && !env.options.skipParentRefresh) {
Zotero.Notifier.queue('refresh', 'item', parentItem.id);
}
// // Delete associated attachment files
if (this.isAttachment()) {
let linkMode = this.attachmentLinkMode;
// If link only, nothing to delete
if (linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
try {
let file = Zotero.Attachments.getStorageDirectory(this);
yield OS.File.removeDir(file.path, {
ignoreAbsent: true,
ignorePermissions: true
});
}
catch (e) {
Zotero.debug(e, 2);
Components.utils.reportError(e);
}
}
if (this.isFileAttachment()) {
// Delete child annotations
let sql = "SELECT itemID FROM itemAnnotations WHERE parentItemID=?";
let toDelete = yield Zotero.DB.columnQueryAsync(sql, [this.id]);
for (let i = 0; i < toDelete.length; i++) {
let obj = yield this.ObjectsClass.getAsync(toDelete[i]);
yield obj.erase({
skipParentRefresh: true,
skipEditCheck: env.options.skipEditCheck
});
}
// Delete last page index
//
// Getting a key is optional so that the deletion doesn't fail if the attachment
// exists in a type of library where it doesn't belong, most likely if a plugin created
// one and we didn't properly prevent it:
// https://forums.zotero.org/discussion/93453/unsubscribe-rss-feed-fail
let id = this._getLastPageIndexSettingKey(true);
if (id) {
yield Zotero.SyncedSettings.clear(Zotero.Libraries.userLibraryID, id);
}
}
// Zotero.Sync.EventListeners.ChangeListener needs to know if this was a storage file
env.notifierData[this.id].storageDeleteLog = this.isStoredFileAttachment();
}
// Delete cached file for image and ink annotations
else if (this.isAnnotation()) {
if (this.isAnnotationSupportingImage()) {
yield Zotero.Annotations.removeCacheImage(this);
}
}
// Regular item
else {
// Delete child items
let sql = "SELECT itemID FROM itemNotes WHERE parentItemID=?1 UNION "
+ "SELECT itemID FROM itemAttachments WHERE parentItemID=?1";
let toDelete = yield Zotero.DB.columnQueryAsync(sql, [this.id]);
for (let i=0; i<toDelete.length; i++) {
let obj = yield this.ObjectsClass.getAsync(toDelete[i]);
yield obj.erase({
skipParentRefresh: true,
skipEditCheck: env.options.skipEditCheck
});
}
}
// Don't add non-syncing items to delete log
if (!Zotero.Sync.Data.Local.isSyncItem(this)) {
env.options.skipDeleteLog = true;
}
// Remove related-item relations pointing to this item
var relatedItems = yield Zotero.Relations.getByPredicateAndObject(
'item', Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(this)
);
for (let relatedItem of relatedItems) {
relatedItem.removeRelatedItem(this);
yield relatedItem.save({
skipDateModifiedUpdate: true,
skipEditCheck: env.options.skipEditCheck
});
}
// Clear fulltext cache
if (this.isAttachment()) {
yield Zotero.Fulltext.clearItemWords(this.id);
//Zotero.Fulltext.clearItemContent(this.id);
}
yield Zotero.DB.queryAsync('DELETE FROM items WHERE itemID=?', this.id);
if (parentItem && !env.options.skipParentRefresh) {
yield parentItem.reload(['primaryData', 'childItems'], true);
parentItem.clearBestAttachmentState();
}
Zotero.Prefs.set('purge.items', true);
Zotero.Prefs.set('purge.creators', true);
Zotero.Prefs.set('purge.tags', true);
});
Zotero.Item.prototype.isCollection = function() {
return false;
}
/**
* Populate the object's data from an API JSON data object
*
* @param {Object} json
* @param {Object} [options]
* @param {Boolean} [options.strict = false] - Throw on unknown field or invalid field for type
*/
Zotero.Item.prototype.fromJSON = function (json, options = {}) {
var strict = !!options.strict;
if (!json.itemType && !this._itemTypeID) {
throw new Error("itemType property not provided");
}
let itemTypeID = Zotero.ItemTypes.getID(json.itemType);
if (!itemTypeID) {
let e = new Error(`Unknown item type '${json.itemType}'`);
e.name = "ZoteroInvalidDataError";
throw e;
}
this.setType(itemTypeID);
var isValidForType = {};
var setFields = new Set();
var { itemType, fields: extraFields, creators: extraCreators, extra } =
Zotero.Utilities.Internal.extractExtraFields(
json.extra || '',
this,
Object.keys(json)
// TEMP until we move creator lines to real creators
.concat('creators')
);
// If a different item type was parsed out of Extra, use that instead
if (itemType && json.itemType != itemType) {
itemTypeID = Zotero.ItemTypes.getID(itemType);
this.setType(itemTypeID);
}
var invalidFieldLogLines = new Map();
// Transfer valid fields from Extra to regular fields
// Currently disabled
/*for (let [field, value] of extraFields) {
this.setField(field, value);
setFields.add(field);
extraFields.delete(field);
}*/
for (let field in json) {
let val = json[field];
switch (field) {
case 'key':
case 'version':
case 'itemType':
case 'note':
// Use?
case 'md5':
case 'mtime':
//
// Handled below
//
case 'note':
case 'collections':
case 'parentItem':
case 'deleted':
case 'inPublications':
case 'extra':
break;
case 'accessDate':
if (val && !Zotero.Date.isSQLDate(val)) {
let d = Zotero.Date.isoToDate(val);
if (!d) {
Zotero.logError(`Discarding invalid ${field} '${val}' for item ${this.libraryKey}`);
continue;
}
val = Zotero.Date.dateToSQL(d, true);
}
this.setField(field, val);
setFields.add(field);
break;
case 'dateAdded':
case 'dateModified':
if (val) {
let d = Zotero.Date.isoToDate(val);
if (!d) {
Zotero.logError(`Discarding invalid ${field} '${val}' for item ${this.libraryKey}`);
continue;
}
val = Zotero.Date.dateToSQL(d, true);
}
this[field] = val;
break;
case 'creators':
//this.setCreators(json.creators.concat(extraCreators), options);
this.setCreators(json.creators, options);
break;
case 'tags':
this.setTags(json.tags);
break;
case 'relations':
this.setRelations(json.relations);
break;
//
// Attachment metadata
//
case 'linkMode':
let linkMode = Zotero.Attachments["LINK_MODE_" + val.toUpperCase()];
if (linkMode === undefined) {
let e = new Error(`Unknown attachment link mode '${val}'`);
e.name = "ZoteroInvalidDataError";
throw e;
}
this.attachmentLinkMode = linkMode;
break;
case 'filename':
if (val === "") {
Zotero.logError("Ignoring empty attachment filename in JSON for item " + this.libraryKey);
}
else {
this.attachmentFilename = val;
}
break;
case 'contentType':
case 'charset':
case 'path':
this['attachment' + field[0].toUpperCase() + field.substr(1)] = val;
break;
//
// Annotation fields
//
case 'annotationType':
case 'annotationType':
case 'annotationAuthorName':
case 'annotationText':
case 'annotationComment':
case 'annotationColor':
case 'annotationPageLabel':
case 'annotationSortIndex':
case 'annotationPosition':
this[field] = val;
break;
// Item fields
default:
let fieldID = Zotero.ItemFields.getID(field);
if (!fieldID) {
// In strict mode, fail on unknown field
if (strict) {
let e = new Error(`Unknown field '${field}'`);
e.name = "ZoteroInvalidDataError";
throw e;
}
// Otherwise store in Extra
if (typeof val == 'string') {
Zotero.warn(`Storing unknown field '${field}' in Extra for item ${this.libraryKey}`);
extraFields.set(field, val);
break;
}
Zotero.warn(`Discarding unknown JSON ${typeof val} '${field}' for item ${this.libraryKey}`);
continue;
}
// Convert to base-mapped field if necessary, so that setFields has the base-mapped field
// when it's checked for values from getUsedFields() below
let origFieldID = fieldID;
let origField = field;
fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(itemTypeID, fieldID) || fieldID;
if (origFieldID != fieldID) {
field = Zotero.ItemFields.getName(fieldID);
}
isValidForType[field] = Zotero.ItemFields.isValidForType(fieldID, this.itemTypeID);
if (!isValidForType[field]) {
let type = Zotero.ItemTypes.getName(itemTypeID);
// In strict mode, fail on invalid field for type
if (strict) {
let e = new Error(`Invalid field '${origField}' for type ${type}`);
e.name = "ZoteroInvalidDataError";
throw e;
}
// Otherwise store in Extra
extraFields.set(field, val);
let msg = `Storing invalid field '${origField}' for type ${type} in Extra for `
+ `item ${this.libraryKey}`;
invalidFieldLogLines.set(field, msg);
continue;
}
this.setField(field, json[origField]);
setFields.add(field);
}
}
// If one of the valid fields is a base field or a base-mapped field, remove all other
// associated fields from Extra. This could be removed if we made sure that translators didn't
// try to save multiple versions of base-mapped fields, which they shouldn't need to do.
//
// https://github.com/zotero/zotero/issues/1504#issuecomment-572415083
if (!strict && extraFields.size) {
for (let field of setFields.keys()) {
let baseField;
if (Zotero.ItemFields.isBaseField(field)) {
baseField = field;
}
else {
let baseFieldID = Zotero.ItemFields.getBaseIDFromTypeAndField(itemTypeID, field);
if (baseFieldID) {
baseField = baseFieldID;
}
}
if (baseField) {
let mappedFieldNames = Zotero.ItemFields.getTypeFieldsFromBase(baseField, true);
for (let mappedField of mappedFieldNames) {
if (extraFields.has(mappedField)) {
extraFields.delete(mappedField);
invalidFieldLogLines.delete(mappedField);
}
}
}
}
//
// Deduplicate remaining Extra fields
//
// For each invalid-for-type base field, remove any mapped fields with the same value
let baseFields = [];
for (let field of extraFields.keys()) {
if (Zotero.ItemFields.getID(field) && Zotero.ItemFields.isBaseField(field)) {
baseFields.push(field);
}
}
for (let baseField of baseFields) {
let value = extraFields.get(baseField);
let mappedFieldNames = Zotero.ItemFields.getTypeFieldsFromBase(baseField, true);
for (let mappedField of mappedFieldNames) {
if (extraFields.has(mappedField) && extraFields.get(mappedField) === value) {
extraFields.delete(mappedField);
invalidFieldLogLines.delete(mappedField);
}
}
}
// Remove Type-mapped fields from Extra, since 'Type' is mapped to Item Type by citeproc-js
// and Type values mostly aren't going to be useful for item types without a Type-mapped field.
let typeFieldNames = Zotero.ItemFields.getTypeFieldsFromBase('type', true)
// This is actually 'medium' but as of 2/2020 the Embedded Metadata translator
// assigns it along with the other 'type' fields.
.concat('audioFileType');
for (let typeFieldName of typeFieldNames) {
if (extraFields.has(typeFieldName)) {
extraFields.delete(typeFieldName);
invalidFieldLogLines.delete(typeFieldName);
}
}
// Remove Extra lines created by double assignments in the RDF translator for fields that
// aren't base-field mappings (which are deduped above). These should probably just become
// base-field mappings, at which point this could be removed.
var temporaryRDFFixes = [
['versionNumber', 'edition'],
['conferenceName', 'meetingName'],
['publicationTitle', 'reporter'],
['bookTitle', 'reporter'],
['blogTitle', 'reporter'],
['dictionaryTitle', 'reporter'],
['encyclopediaTitle', 'reporter'],
['forumTitle', 'reporter'],
['proceedingsTitle', 'reporter'],
['programTitle', 'reporter'],
['websiteTitle', 'reporter'],
];
for (let x of temporaryRDFFixes) {
if (extraFields.has(x[0]) && setFields.has(x[1])
&& extraFields.get(x[0]) == this.getField(x[1])) {
extraFields.delete(x[0]);
invalidFieldLogLines.delete(x[0]);
}
if (extraFields.has(x[1]) && setFields.has(x[0])
&& extraFields.get(x[1]) == this.getField(x[0])) {
extraFields.delete(x[1]);
invalidFieldLogLines.delete(x[1]);
}
}
}
for (let line of invalidFieldLogLines.values()) {
Zotero.warn(line);
}
if (extra || extraFields.size || this.getField('extra')) {
this.setField('extra', Zotero.Utilities.Internal.combineExtraFields(extra, extraFields));
}
if (json.collections || this._collections.length) {
this.setCollections(json.collections);
}
// Clear existing fields not specified
var previousFields = this.getUsedFields(true);
for (let field of previousFields) {
if (!setFields.has(field) && isValidForType[field] !== false && field != 'extra') {
this.setField(field, false);
}
}
// Both notes and attachments might have parents and notes
if (this.isNote() || this.isAttachment() || this.isAnnotation()) {
let parentKey = json.parentItem;
this.parentKey = parentKey ? parentKey : false;
if (!this.isAnnotation()) {
let note = json.note;
this.setNote(note !== undefined ? note : "");
}
}
// Update boolean fields that might not be present in JSON
['deleted', 'inPublications'].forEach(field => {
if (json[field] || this[field]) {
this[field] = !!json[field];
}
});
}
/**
* @param {Object} options
*/
Zotero.Item.prototype.toJSON = function (options = {}) {
var env = this._preToJSON(options);
var mode = env.mode;
var obj = env.obj = {};
obj.key = this.key;
obj.version = this.version;
obj.itemType = Zotero.ItemTypes.getName(this.itemTypeID);
var embeddedImage = this.isEmbeddedImageAttachment();
// Fields
for (let i in this._itemData) {
let val = this.getField(i) + '';
if (val !== '' || mode == 'full') {
obj[Zotero.ItemFields.getName(i)] = val;
}
}
// Creators
if (this.isRegularItem()) {
obj.creators = this.getCreatorsJSON();
}
else {
var parent = this.parentKey;
if (parent || mode == 'full') {
obj.parentItem = parent ? parent : false;
}
// Attachment fields
if (this.isAttachment()) {
let linkMode = this.attachmentLinkMode;
obj.linkMode = Zotero.Attachments.linkModeToName(linkMode);
obj.contentType = this.attachmentContentType;
if (!embeddedImage) {
obj.charset = this.attachmentCharset;
}
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
obj.path = this.attachmentPath;
}
else if (linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) {
obj.filename = this.attachmentFilename;
}
if (this.isStoredFileAttachment() && !options.skipStorageProperties) {
if (options.syncedStorageProperties) {
let mtime = this.attachmentSyncedModificationTime;
// There's never a reason to include these if they're null. This can happen if
// we're restoring to server from a copy of the database that was never
// file-synced. We don't want to clear the remote file associations when that
// happens.
if (mtime !== null) {
obj.mtime = mtime;
}
let md5 = this.attachmentSyncedHash;
if (md5 !== null) {
obj.md5 = md5;
}
}
else {
// TEMP
//obj.mtime = (yield this.attachmentModificationTime) || null;
//obj.md5 = (yield this.attachmentHash) || null;
}
}
}
// Notes and embedded attachment notes
if (this.isAttachment() || this.isNote()) {
let note = this.getNote();
if (note !== "" || mode == 'full' || (mode == 'new' && this.isNote())) {
obj.note = note;
}
}
if (this.isAnnotation()) {
let type = this.annotationType;
obj.annotationType = type;
obj.annotationAuthorName = this.annotationAuthorName || '';
if (type == 'highlight') {
obj.annotationText = this.annotationText || '';
}
obj.annotationComment = this.annotationComment || '';
obj.annotationColor = this.annotationColor || '';
obj.annotationPageLabel = this.annotationPageLabel || '';
obj.annotationSortIndex = this.annotationSortIndex || '';
obj.annotationPosition = this.annotationPosition || '';
}
}
if (!embeddedImage) {
// Tags
obj.tags = [];
var tags = this.getTags();
for (let i=0; i<tags.length; i++) {
obj.tags.push(tags[i]);
}
// Collections
if (this.isTopLevelItem()) {
obj.collections = this.getCollections().map(function (id) {
var { libraryID, key } = this.ContainerObjectsClass.getLibraryAndKeyFromID(id);
if (!key) {
throw new Error("Collection " + id + " not found for item " + this.libraryKey);
}
return key;
}.bind(this));
}
// Relations
obj.relations = this.getRelations();
}
// My Publications
if (this._inPublications
// Include in 'full' mode, but only in My Library
|| (mode == 'full' && this.library && this.library.libraryType == 'user')) {
obj.inPublications = this._inPublications;
}
if (obj.accessDate) obj.accessDate = Zotero.Date.sqlToISO8601(obj.accessDate);
if (this.dateAdded) {
obj.dateAdded = Zotero.Date.sqlToISO8601(this.dateAdded);
}
if (this.dateModified) {
obj.dateModified = Zotero.Date.sqlToISO8601(this.dateModified);
}
var json = this._postToJSON(env);
// TODO: Remove once we stop clearing props from the cached JSON in patch mode
if (options.skipStorageProperties) {
delete json.md5;
delete json.mtime;
}
return json;
}
Zotero.Item.prototype.toResponseJSON = function (options = {}) {
// Default to showing synced storage properties, since that's what the API does, and this function
// is generally used to emulate the API
if (options.syncedStorageProperties === undefined) {
options.syncedStorageProperties = true;
}
var json = this.constructor._super.prototype.toResponseJSON.call(this, options);
// creatorSummary
var firstCreator = this.getField('firstCreator');
if (firstCreator) {
json.meta.creatorSummary = firstCreator;
}
// parsedDate
var parsedDate = Zotero.Date.multipartToSQL(this.getField('date', true, true));
if (parsedDate) {
// 0000?
json.meta.parsedDate = parsedDate;
}
// numChildren
if (this.isRegularItem()) {
json.meta.numChildren = this.numChildren();
}
return json;
};
/**
* Migrate valid fields in Extra to real fields
*
* A separate save is required
*/
Zotero.Item.prototype.migrateExtraFields = function () {
if (!this.isEditable()) {
return false;
}
var originalExtra = this.getField('extra');
var log = function () {
Zotero.debug("Original Extra:\n\n" + originalExtra);
if (itemType) {
Zotero.debug("Item Type: " + itemType);
}
if (fields && fields.size) {
Zotero.debug("Fields:\n\n" + Array.from(fields.entries()).map(x => `${x[0]}: ${x[1]}`).join("\n"));
}
if (creators && creators.length) {
Zotero.debug("Creators:");
Zotero.debug(creators);
}
if (extra) {
Zotero.debug("Remaining Extra:\n\n" + extra);
}
};
try {
var { itemType, fields, creators, extra } = Zotero.Utilities.Internal.extractExtraFields(
originalExtra, this
);
if (itemType) {
let originalType = this.itemTypeID;
let preJSON = this.toJSON();
let preKeys = Object.keys(preJSON);
this.setType(Zotero.ItemTypes.getID(itemType));
// Move any fields that were removed by the item type switch to Extra
let postJSON = this.toJSON();
let postKeys = Object.keys(postJSON)
let removedKeys = Zotero.Utilities.arrayDiff(preKeys, postKeys);
let addToExtra = [];
for (let key of removedKeys) {
// Follow base-field mappings
let baseFieldID = Zotero.ItemFields.getBaseIDFromTypeAndField(originalType, key);
let newField = baseFieldID
? Zotero.ItemFields.getFieldIDFromTypeAndBase(itemType, baseFieldID)
: null;
if (!newField) {
// "numPages" → "Num Pages"
let formattedKey = key[0].toUpperCase()
+ key.substr(1).replace(/([a-z])([A-Z])/, '$1 $2');
addToExtra.push(formattedKey + ': ' + preJSON[key]);
}
}
if (addToExtra.length) {
extra = (addToExtra.join('\n') + '\n' + extra).trim();
}
}
for (let [field, value] of fields) {
this.setField(field, value);
}
if (creators.length) {
this.setCreators([...this.getCreators(), ...creators]);
}
this.setField('extra', extra);
if (!this.hasChanged()) {
return false;
}
}
catch (e) {
Zotero.logError("Error migrating Extra fields for item " + this.libraryKey);
log();
throw e;
}
Zotero.debug("Migrating Extra fields for item " + this.libraryKey);
log();
return true;
}
/**
* Return an item in the specified library equivalent to this item
*
* @return {Promise<Zotero.Item>}
*/
Zotero.Item.prototype.getLinkedItem = async function (libraryID, bidirectional) {
var item = await this._getLinkedObject(libraryID, bidirectional);
if (item) {
await item.loadAllData();
}
return item;
};
/**
* Add a linked-object relation pointing to the given item
*
* Does not require a separate save()
*
* @return {Promise}
*/
Zotero.Item.prototype.addLinkedItem = Zotero.Promise.coroutine(function* (item) {
return this._addLinkedObject(item);
});
/**
* Update createdByUserID/lastModifiedByUserID, efficiently
*
* Used by sync code
*/
Zotero.Item.prototype.updateCreatedByUser = async function (createdByUserID, lastModifiedByUserID) {
this._createdByUserID = createdByUserID || null;
this._lastModifiedByUserID = lastModifiedByUserID || null;
var sql = "REPLACE INTO groupItems VALUES (?, ?, ?)";
await Zotero.DB.queryAsync(sql, [this.id, this._createdByUserID, this._lastModifiedByUserID]);
if (this._changed.primaryData) {
for (let x of ['createdByUserID', 'lastModifiedByUserID']) {
if (this._changed.primaryData[x]) {
if (Objects.keys(this._changed.primaryData).length == 1) {
delete this._changed.primaryData;
}
else {
delete this._changed.primaryData[x];
}
}
}
}
};
//////////////////////////////////////////////////////////////////////////////
//
// Private methods
//
//////////////////////////////////////////////////////////////////////////////
/**
* Returns related items this item points to
*
* @return {String[]} - Keys of related items
*/
Zotero.Item.prototype._getRelatedItems = function () {
this._requireData('relations');
var predicate = Zotero.Relations.relatedItemPredicate;
var relatedItemURIs = this.getRelationsByPredicate(predicate);
// Pull out object values from related-item relations, turn into items, and pull out keys
var keys = [];
for (let i=0; i<relatedItemURIs.length; i++) {
let {libraryID, key} = Zotero.URI.getURIItemLibraryKey(relatedItemURIs[i]);
if (key) {
keys.push(key);
}
}
return keys;
}
/**
* @return {Object} Return a copy of the creators, with additional 'id' properties
*/
Zotero.Item.prototype._getOldCreators = function () {
var oldCreators = {};
for (i=0; i<this._creators.length; i++) {
let old = {};
for (let field in this._creators[i]) {
old[field] = this._creators[i][field];
}
// Add 'id' property for efficient DB updates
old.id = this._creatorIDs[i];
oldCreators[i] = old;
}
return oldCreators;
}