zotero/chrome/content/zotero/xpcom/data/items.js
Dan Stillman 3ec883a7f6 Get attachment text on-demand if not cached in Item::attachmentText
Follow-up to 58f515058 with a better approach: if no full-text cache
file, just get text directly without indexing. In the one existing use
of `attachmentText`, attachment merging, this is better anyway, because
we might be deleting the file, so there's no point wasting time
inserting words into the database.
2022-03-12 20:22:29 -05:00

2005 lines
60 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 *****
*/
/*
* Primary interface for accessing Zotero items
*/
Zotero.Items = function() {
this.constructor = null;
this._ZDO_object = 'item';
// This needs to wait until all Zotero components are loaded to initialize,
// but otherwise it can be just a simple property
Zotero.defineProperty(this, "_primaryDataSQLParts", {
get: function () {
var itemTypeAttachment = Zotero.ItemTypes.getID('attachment');
var itemTypeNote = Zotero.ItemTypes.getID('note');
var itemTypeAnnotation = Zotero.ItemTypes.getID('annotation');
return {
itemID: "O.itemID",
itemTypeID: "O.itemTypeID",
dateAdded: "O.dateAdded",
dateModified: "O.dateModified",
libraryID: "O.libraryID",
key: "O.key",
version: "O.version",
synced: "O.synced",
createdByUserID: "createdByUserID",
lastModifiedByUserID: "lastModifiedByUserID",
firstCreator: _getFirstCreatorSQL(),
sortCreator: _getSortCreatorSQL(),
deleted: "DI.itemID IS NOT NULL AS deleted",
inPublications: "PI.itemID IS NOT NULL AS inPublications",
parentID: `(CASE O.itemTypeID `
+ `WHEN ${itemTypeAttachment} THEN IAP.itemID `
+ `WHEN ${itemTypeNote} THEN INoP.itemID `
+ `WHEN ${itemTypeAnnotation} THEN IAnP.itemID `
+ `END) AS parentID`,
parentKey: `(CASE O.itemTypeID `
+ `WHEN ${itemTypeAttachment} THEN IAP.key `
+ `WHEN ${itemTypeNote} THEN INoP.key `
+ `WHEN ${itemTypeAnnotation} THEN IAnP.key `
+ `END) AS parentKey`,
attachmentCharset: "CS.charset AS attachmentCharset",
attachmentLinkMode: "IA.linkMode AS attachmentLinkMode",
attachmentContentType: "IA.contentType AS attachmentContentType",
attachmentPath: "IA.path AS attachmentPath",
attachmentSyncState: "IA.syncState AS attachmentSyncState",
attachmentSyncedModificationTime: "IA.storageModTime AS attachmentSyncedModificationTime",
attachmentSyncedHash: "IA.storageHash AS attachmentSyncedHash",
attachmentLastProcessedModificationTime: "IA.lastProcessedModificationTime AS attachmentLastProcessedModificationTime",
};
}
}, {lazy: true});
this._primaryDataSQLFrom = "FROM items O "
+ "LEFT JOIN itemAttachments IA USING (itemID) "
+ "LEFT JOIN items IAP ON (IA.parentItemID=IAP.itemID) "
+ "LEFT JOIN itemNotes INo ON (O.itemID=INo.itemID) "
+ "LEFT JOIN items INoP ON (INo.parentItemID=INoP.itemID) "
+ "LEFT JOIN itemAnnotations IAn ON (O.itemID=IAn.itemID) "
+ "LEFT JOIN items IAnP ON (IAn.parentItemID=IAnP.itemID) "
+ "LEFT JOIN deletedItems DI ON (O.itemID=DI.itemID) "
+ "LEFT JOIN publicationsItems PI ON (O.itemID=PI.itemID) "
+ "LEFT JOIN charsets CS ON (IA.charsetID=CS.charsetID)"
+ "LEFT JOIN groupItems GI ON (O.itemID=GI.itemID)";
this._relationsTable = "itemRelations";
/**
* @param {Integer} libraryID
* @return {Promise<Boolean>} - True if library has items in trash, false otherwise
*/
this.hasDeleted = Zotero.Promise.coroutine(function* (libraryID) {
var sql = "SELECT COUNT(*) > 0 FROM items JOIN deletedItems USING (itemID) WHERE libraryID=?";
return !!(yield Zotero.DB.valueQueryAsync(sql, [libraryID]));
});
/**
* Returns all items in a given library
*
* @param {Integer} libraryID
* @param {Boolean} [onlyTopLevel=false] If true, don't include child items
* @param {Boolean} [includeDeleted=false] If true, include deleted items
* @param {Boolean} [asIDs=false] If true, resolves only with IDs
* @return {Promise<Array<Zotero.Item|Integer>>}
*/
this.getAll = Zotero.Promise.coroutine(function* (libraryID, onlyTopLevel, includeDeleted, asIDs=false) {
var sql = 'SELECT A.itemID FROM items A';
if (onlyTopLevel) {
sql += ' LEFT JOIN itemNotes B USING (itemID) '
+ 'LEFT JOIN itemAttachments C ON (C.itemID=A.itemID) '
+ 'WHERE B.parentItemID IS NULL AND C.parentItemID IS NULL';
}
else {
sql += " WHERE 1";
}
if (!includeDeleted) {
sql += " AND A.itemID NOT IN (SELECT itemID FROM deletedItems)";
}
sql += " AND libraryID=?";
var ids = yield Zotero.DB.columnQueryAsync(sql, libraryID);
if (asIDs) {
return ids;
}
return this.getAsync(ids);
});
/**
* Return item data in web API format
*
* var data = Zotero.Items.getAPIData(0, 'collections/NF3GJ38A/items');
*
* @param {Number} libraryID
* @param {String} [apiPath='items'] - Web API style
* @return {Promise<String>}.
*/
this.getAPIData = Zotero.Promise.coroutine(function* (libraryID, apiPath) {
var gen = this.getAPIDataGenerator(...arguments);
var data = "";
while (true) {
var result = gen.next();
if (result.done) {
break;
}
var val = yield result.value;
if (typeof val == 'string') {
data += val;
}
else if (val === undefined) {
continue;
}
else {
throw new Error("Invalid return value from generator");
}
}
return data;
});
/**
* Zotero.Utilities.Internal.getAsyncInputStream-compatible generator that yields item data
* in web API format as strings
*
* @param {Object} params - Request parameters from Zotero.API.parsePath()
*/
this.apiDataGenerator = function* (params) {
Zotero.debug(params);
var s = new Zotero.Search;
s.addCondition('libraryID', 'is', params.libraryID);
if (params.scopeObject == 'collections') {
s.addCondition('collection', 'is', params.scopeObjectKey);
}
s.addCondition('title', 'contains', 'test');
var ids = yield s.search();
yield '[\n';
for (let i=0; i<ids.length; i++) {
let prefix = i > 0 ? ',\n' : '';
let item = yield this.getAsync(ids[i], { noCache: true });
var json = item.toResponseJSON();
yield prefix + JSON.stringify(json, null, 4);
}
yield '\n]';
};
//
// Bulk data loading functions
//
// These are called by Zotero.DataObjects.prototype._loadDataType().
//
this._loadItemData = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
var missingItems = {};
var itemFieldsCached = {};
var sql = "SELECT itemID, fieldID, value FROM items "
+ "JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) WHERE libraryID=? AND itemTypeID!=?" + idSQL;
var params = [libraryID, Zotero.ItemTypes.getID('note')];
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
let fieldID = row.getResultByIndex(1);
let value = row.getResultByIndex(2);
//Zotero.debug('Setting field ' + fieldID + ' for item ' + itemID);
if (this._objectCache[itemID]) {
if (value === null) {
value = false;
}
this._objectCache[itemID].setField(fieldID, value, true);
}
else {
if (!missingItems[itemID]) {
missingItems[itemID] = true;
Zotero.logError("itemData row references nonexistent item " + itemID);
}
}
if (!itemFieldsCached[itemID]) {
itemFieldsCached[itemID] = {};
}
itemFieldsCached[itemID][fieldID] = true;
}.bind(this)
}
);
var sql = "SELECT itemID FROM items WHERE libraryID=?" + idSQL;
var params = [libraryID];
var allItemIDs = [];
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
let item = this._objectCache[itemID];
// Set nonexistent fields in the cache list to false (instead of null)
let fieldIDs = Zotero.ItemFields.getItemTypeFields(item.itemTypeID);
for (let j=0; j<fieldIDs.length; j++) {
let fieldID = fieldIDs[j];
if (!itemFieldsCached[itemID] || !itemFieldsCached[itemID][fieldID]) {
//Zotero.debug('Setting field ' + fieldID + ' to false for item ' + itemID);
item.setField(fieldID, false, true);
}
}
allItemIDs.push(itemID);
}.bind(this)
}
);
var titleFieldID = Zotero.ItemFields.getID('title');
// Note titles
var sql = "SELECT itemID, title FROM items JOIN itemNotes USING (itemID) "
+ "WHERE libraryID=? AND itemID NOT IN (SELECT itemID FROM itemAttachments)" + idSQL;
var params = [libraryID];
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
let title = row.getResultByIndex(1);
//Zotero.debug('Setting title for note ' + row.itemID);
if (this._objectCache[itemID]) {
this._objectCache[itemID].setField(titleFieldID, title, true);
}
else {
if (!missingItems[itemID]) {
missingItems[itemID] = true;
Zotero.logError("itemData row references nonexistent item " + itemID);
}
}
}.bind(this)
}
);
for (let i=0; i<allItemIDs.length; i++) {
let itemID = allItemIDs[i];
let item = this._objectCache[itemID];
// Mark as loaded
item._loaded.itemData = true;
item._clearChanged('itemData');
// Display titles
try {
item.updateDisplayTitle()
}
catch (e) {
// A few item types need creators to be loaded. Instead of making
// updateDisplayTitle() async and loading conditionally, just catch the error
// and load on demand
if (e instanceof Zotero.Exception.UnloadedDataException) {
yield item.loadDataType('creators');
item.updateDisplayTitle()
}
else {
throw e;
}
}
}
});
this._loadCreators = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
var sql = 'SELECT itemID, creatorID, creatorTypeID, orderIndex '
+ 'FROM items LEFT JOIN itemCreators USING (itemID) '
+ 'WHERE libraryID=?' + idSQL + " ORDER BY itemID, orderIndex";
var params = [libraryID];
var rows = yield Zotero.DB.queryAsync(sql, params, { noCache: true });
// Mark creator indexes above the number of creators as changed,
// so that they're cleared if the item is saved
var fixIncorrectIndexes = function (item, numCreators, maxOrderIndex) {
Zotero.debug("Fixing incorrect creator indexes for item " + item.libraryKey
+ " (" + numCreators + ", " + maxOrderIndex + ")", 2);
var i = numCreators;
if (!item._changed.creators) {
item._changed.creators = {};
}
while (i <= maxOrderIndex) {
item._changed.creators[i] = true;
i++;
}
};
var lastItemID;
var item;
var index = 0;
var maxOrderIndex = -1;
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
let itemID = row.itemID;
if (itemID != lastItemID) {
if (!this._objectCache[itemID]) {
throw new Error("Item " + itemID + " not loaded");
}
item = this._objectCache[itemID];
item._creators = [];
item._creatorIDs = [];
item._loaded.creators = true;
item._clearChanged('creators');
if (!row.creatorID) {
lastItemID = row.itemID;
continue;
}
if (index <= maxOrderIndex) {
fixIncorrectIndexes(item, index, maxOrderIndex);
}
index = 0;
maxOrderIndex = -1;
}
lastItemID = row.itemID;
if (row.orderIndex > maxOrderIndex) {
maxOrderIndex = row.orderIndex;
}
let creatorData = Zotero.Creators.get(row.creatorID);
creatorData.creatorTypeID = row.creatorTypeID;
item._creators[index] = creatorData;
item._creatorIDs[index] = row.creatorID;
index++;
}
if (index <= maxOrderIndex) {
fixIncorrectIndexes(item, index, maxOrderIndex);
}
});
this._loadNotes = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
var notesToUpdate = [];
var sql = "SELECT itemID, note FROM items "
+ "JOIN itemNotes USING (itemID) "
+ "WHERE libraryID=?" + idSQL;
var params = [libraryID];
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
let item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not found");
}
let note = row.getResultByIndex(1);
// Convert non-HTML notes on-the-fly
if (note !== "") {
if (typeof note == 'number') {
note = '' + note;
}
if (typeof note == 'string') {
if (!note.substr(0, 36).match(/^<div class="zotero-note znv[0-9]+">/)) {
note = Zotero.Utilities.htmlSpecialChars(note);
note = Zotero.Notes.notePrefix + '<p>'
+ note.replace(/\n/g, '</p><p>')
.replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;')
.replace(/ /g, '&nbsp;&nbsp;')
+ '</p>' + Zotero.Notes.noteSuffix;
note = note.replace(/<p>\s*<\/p>/g, '<p>&nbsp;</p>');
notesToUpdate.push([item.id, note]);
}
// Don't include <div> wrapper when returning value
let startLen = note.substr(0, 36).match(/^<div class="zotero-note znv[0-9]+">/)[0].length;
let endLen = 6; // "</div>".length
note = note.substr(startLen, note.length - startLen - endLen);
}
// Clear null notes
else {
note = '';
notesToUpdate.push([item.id, '']);
}
}
item._noteText = note ? note : '';
item._loaded.note = true;
item._clearChanged('note');
}.bind(this)
}
);
if (notesToUpdate.length) {
yield Zotero.DB.executeTransaction(function* () {
for (let i = 0; i < notesToUpdate.length; i++) {
let row = notesToUpdate[i];
let sql = "UPDATE itemNotes SET note=? WHERE itemID=?";
yield Zotero.DB.queryAsync(sql, [row[1], row[0]]);
}
}.bind(this));
}
// Mark notes and attachments without notes as loaded
sql = "SELECT itemID FROM items WHERE libraryID=?" + idSQL
+ " AND itemTypeID IN (?, ?) AND itemID NOT IN (SELECT itemID FROM itemNotes)";
params = [libraryID, Zotero.ItemTypes.getID('note'), Zotero.ItemTypes.getID('attachment')];
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
let item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not loaded");
}
item._noteText = '';
item._loaded.note = true;
item._clearChanged('note');
}.bind(this)
}
);
});
this._loadAnnotations = async function (libraryID, ids, idSQL) {
var sql = "SELECT itemID, IA.parentItemID, IA.type, IA.authorName, IA.text, IA.comment, "
+ "IA.color, IA.sortIndex, IA.isExternal "
+ "FROM items JOIN itemAnnotations IA USING (itemID) "
+ "WHERE libraryID=?" + idSQL;
var params = [libraryID];
await Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
let item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not found");
}
item._parentItemID = row.getResultByIndex(1);
var typeID = row.getResultByIndex(2);
var type;
switch (typeID) {
case Zotero.Annotations.ANNOTATION_TYPE_HIGHLIGHT:
type = 'highlight';
break;
case Zotero.Annotations.ANNOTATION_TYPE_NOTE:
type = 'note';
break;
case Zotero.Annotations.ANNOTATION_TYPE_IMAGE:
type = 'image';
break;
case Zotero.Annotations.ANNOTATION_TYPE_INK:
type = 'ink';
break;
default:
throw new Error(`Unknown annotation type id ${typeID}`);
}
item._annotationType = type;
item._annotationAuthorName = row.getResultByIndex(3);
item._annotationText = row.getResultByIndex(4);
item._annotationComment = row.getResultByIndex(5);
item._annotationColor = row.getResultByIndex(6);
item._annotationSortIndex = row.getResultByIndex(7);
item._annotationIsExternal = !!row.getResultByIndex(8);
item._loaded.annotation = true;
item._clearChanged('annotation');
}.bind(this)
}
);
};
this._loadAnnotationsDeferred = async function (libraryID, ids, idSQL) {
var sql = "SELECT itemID, IA.position, IA.pageLabel FROM items "
+ "JOIN itemAnnotations IA USING (itemID) "
+ "WHERE libraryID=?" + idSQL;
var params = [libraryID];
await Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
let item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not found");
}
item._annotationPosition = row.getResultByIndex(1);
item._annotationPageLabel = row.getResultByIndex(2);
item._loaded.annotationDeferred = true;
item._clearChanged('annotationDeferred');
}.bind(this)
}
);
};
this._loadChildItems = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
var params = [libraryID];
var rows = [];
var onRow = function (row, setFunc) {
var itemID = row.getResultByIndex(0);
// If we've finished a set of rows for an item, process them
if (lastItemID && itemID !== lastItemID) {
setFunc(lastItemID, rows);
rows = [];
}
lastItemID = itemID;
rows.push({
itemID: row.getResultByIndex(1),
title: row.getResultByIndex(2),
trashed: row.getResultByIndex(3)
});
};
//
// Attachments
//
var titleFieldID = Zotero.ItemFields.getID('title');
var sql = "SELECT parentItemID, A.itemID, value AS title, "
+ "CASE WHEN DI.itemID IS NULL THEN 0 ELSE 1 END AS trashed "
+ "FROM itemAttachments A "
+ "JOIN items I ON (A.parentItemID=I.itemID) "
+ `LEFT JOIN itemData ID ON (fieldID=${titleFieldID} AND A.itemID=ID.itemID) `
+ "LEFT JOIN itemDataValues IDV USING (valueID) "
+ "LEFT JOIN deletedItems DI USING (itemID) "
+ "WHERE libraryID=?"
+ (ids.length ? " AND parentItemID IN (" + ids.map(id => parseInt(id)).join(", ") + ")" : "")
+ " ORDER BY parentItemID";
// Since we do the sort here and cache these results, a restart will be required
// if this pref (off by default) is turned on, but that's OK
if (Zotero.Prefs.get('sortAttachmentsChronologically')) {
sql += ", dateAdded";
}
var setAttachmentItem = function (itemID, rows) {
var item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not loaded");
}
item._attachments = {
rows,
chronologicalWithTrashed: null,
chronologicalWithoutTrashed: null,
alphabeticalWithTrashed: null,
alphabeticalWithoutTrashed: null
};
}.bind(this);
var lastItemID = null;
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
onRow(row, setAttachmentItem);
}
}
);
// Process unprocessed rows
if (lastItemID) {
setAttachmentItem(lastItemID, rows);
}
// Otherwise clear existing entries for passed items
else if (ids.length) {
ids.forEach(id => setAttachmentItem(id, []));
}
//
// Notes
//
sql = "SELECT parentItemID, N.itemID, title, "
+ "CASE WHEN DI.itemID IS NULL THEN 0 ELSE 1 END AS trashed "
+ "FROM itemNotes N "
+ "JOIN items I ON (N.parentItemID=I.itemID) "
+ "LEFT JOIN deletedItems DI USING (itemID) "
+ "WHERE libraryID=?"
+ (ids.length ? " AND parentItemID IN (" + ids.map(id => parseInt(id)).join(", ") + ")" : "")
+ " ORDER BY parentItemID";
if (Zotero.Prefs.get('sortNotesChronologically')) {
sql += ", dateAdded";
}
var setNoteItem = function (itemID, rows) {
var item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not loaded");
}
item._notes = {
rows,
rowsEmbedded: null,
chronologicalWithTrashed: null,
chronologicalWithoutTrashed: null,
alphabeticalWithTrashed: null,
alphabeticalWithoutTrashed: null,
numWithTrashed: null,
numWithoutTrashed: null,
numWithTrashedWithEmbedded: null,
numWithoutTrashedWithoutEmbedded: null
};
}.bind(this);
lastItemID = null;
rows = [];
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
onRow(row, setNoteItem);
}
}
);
// Process unprocessed rows
if (lastItemID) {
setNoteItem(lastItemID, rows);
}
// Otherwise clear existing entries for passed items
else if (ids.length) {
ids.forEach(id => setNoteItem(id, []));
}
//
// Annotations
//
sql = "SELECT parentItemID, IAn.itemID, "
+ "text || ' - ' || comment AS title, " // TODO: Make better
+ "CASE WHEN DI.itemID IS NULL THEN 0 ELSE 1 END AS trashed "
+ "FROM itemAnnotations IAn "
+ "JOIN items I ON (IAn.parentItemID=I.itemID) "
+ "LEFT JOIN deletedItems DI USING (itemID) "
+ "WHERE libraryID=?"
+ (ids.length ? " AND parentItemID IN (" + ids.map(id => parseInt(id)).join(", ") + ")" : "")
+ " ORDER BY parentItemID, sortIndex";
var setAnnotationItem = function (itemID, rows) {
var item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not loaded");
}
rows.sort((a, b) => a.sortIndex - b.sortIndex);
item._annotations = {
rows,
withTrashed: null,
withoutTrashed: null
};
}.bind(this);
lastItemID = null;
rows = [];
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
onRow(row, setAnnotationItem);
}
}
);
// Process unprocessed rows
if (lastItemID) {
setAnnotationItem(lastItemID, rows);
}
// Otherwise clear existing entries for passed items
else if (ids.length) {
ids.forEach(id => setAnnotationItem(id, []));
}
// Mark either all passed items or all items as having child items loaded
sql = "SELECT itemID FROM items I WHERE libraryID=?";
if (idSQL) {
sql += idSQL;
}
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
var itemID = row.getResultByIndex(0);
var item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not loaded");
}
item._loaded.childItems = true;
item._clearChanged('childItems');
}.bind(this)
}
);
});
this._loadTags = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
var sql = "SELECT itemID, name, type FROM items "
+ "LEFT JOIN itemTags USING (itemID) "
+ "LEFT JOIN tags USING (tagID) WHERE libraryID=?" + idSQL;
var params = [libraryID];
var lastItemID;
var rows = [];
var setRows = function (itemID, rows) {
var item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not found");
}
item._tags = [];
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
item._tags.push(Zotero.Tags.cleanData(row));
}
item._loaded.tags = true;
}.bind(this);
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
if (lastItemID && itemID !== lastItemID) {
setRows(lastItemID, rows);
rows = [];
}
lastItemID = itemID;
// Item has no tags
let tag = row.getResultByIndex(1);
if (tag === null) {
return;
}
rows.push({
tag: tag,
type: row.getResultByIndex(2)
});
}.bind(this)
}
);
if (lastItemID) {
setRows(lastItemID, rows);
}
});
this._loadCollections = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
var sql = "SELECT itemID, collectionID FROM items "
+ "LEFT JOIN collectionItems USING (itemID) "
+ "WHERE libraryID=?" + idSQL;
var params = [libraryID];
var lastItemID;
var rows = [];
var setRows = function (itemID, rows) {
var item = this._objectCache[itemID];
if (!item) {
throw new Error("Item " + itemID + " not found");
}
item._collections = rows;
item._loaded.collections = true;
item._clearChanged('collections');
}.bind(this);
yield Zotero.DB.queryAsync(
sql,
params,
{
noCache: true,
onRow: function (row) {
let itemID = row.getResultByIndex(0);
if (lastItemID && itemID !== lastItemID) {
setRows(lastItemID, rows);
rows = [];
}
lastItemID = itemID;
let collectionID = row.getResultByIndex(1);
// No collections
if (collectionID === null) {
return;
}
rows.push(collectionID);
}.bind(this)
}
);
if (lastItemID) {
setRows(lastItemID, rows);
}
});
/**
* Copy child items from one item to another (e.g., in another library)
*
* Requires a transaction
*/
this.copyChildItems = async function (fromItem, toItem) {
Zotero.DB.requireTransaction();
var fromGroup = fromItem.library.isGroup;
// Annotations on files
if (fromItem.isFileAttachment()) {
let annotations = fromItem.getAnnotations();
for (let annotation of annotations) {
// Don't copy embedded PDF annotations
if (annotation.annotationIsExternal) {
continue;
}
let newAnnotation = annotation.clone(toItem.libraryID);
newAnnotation.parentItemID = toItem.id;
// If there's no explicit author and we're copying an annotation created by another
// user from a group, set the author to the creating user
if (fromGroup
&& !annotation.annotationAuthorName
&& annotation.createdByUserID != Zotero.Users.getCurrentUserID()) {
newAnnotation.annotationAuthorName =
Zotero.Users.getName(annotation.createdByUserID);
}
await newAnnotation.save();
}
}
// TODO: Other things as necessary
};
/**
* Move child items from one item to another
*
* Requires a transaction
*
* @param {Zotero.Item} fromItem
* @param {Zotero.Item} toItem
* @param {Boolean} includeTrashed
* @return {Promise}
*/
this.moveChildItems = async function (fromItem, toItem, includeTrashed = false) {
//Zotero.DB.requireTransaction();
// Annotations on files
if (fromItem.isFileAttachment()) {
let fn = async function () {
let annotations = fromItem.getAnnotations(includeTrashed);
for (let annotation of annotations) {
annotation.parentItemID = toItem.id;
await annotation.save();
}
};
if (!Zotero.DB.inTransaction) {
Zotero.logError("moveChildItems() now requires a transaction -- please update your code");
await Zotero.DB.executeTransaction(fn);
}
else {
await fn();
}
}
// TODO: Other things as necessary
};
this.merge = function (item, otherItems) {
Zotero.debug("Merging items");
return Zotero.DB.executeTransaction(function* () {
var replPred = Zotero.Relations.replacedItemPredicate;
var toSave = {};
toSave[item.id] = item;
var earliestDateAdded = item.dateAdded;
let remapAttachmentKeys = yield this._mergePDFAttachments(item, otherItems);
yield this._mergeWebAttachments(item, otherItems);
yield this._mergeOtherAttachments(item, otherItems);
for (let otherItem of otherItems) {
if (otherItem.libraryID !== item.libraryID) {
throw new Error('Items being merged must be in the same library');
}
// Use the earliest date added of all the items
if (otherItem.dateAdded < earliestDateAdded) {
earliestDateAdded = otherItem.dateAdded;
}
// Move notes to master
var noteIDs = otherItem.getNotes(true);
for (let id of noteIDs) {
var note = yield this.getAsync(id);
note.parentItemID = item.id;
Zotero.Notes.replaceItemKey(note, otherItem.key, item.key);
Zotero.Notes.replaceAllItemKeys(note, remapAttachmentKeys);
toSave[note.id] = note;
}
// Move relations to master
yield this._moveRelations(otherItem, item);
// All other operations are additive only and do not affect the
// old item, which will be put in the trash
// Add collections to master
otherItem.getCollections().forEach(id => item.addToCollection(id));
// Add tags to master
var tags = otherItem.getTags();
for (let j = 0; j < tags.length; j++) {
let tagName = tags[j].tag;
if (item.hasTag(tagName)) {
let type = item.getTagType(tagName);
// If existing manual tag, leave that
if (type == 0) {
continue;
}
// Otherwise, add the non-master item's tag, which may be manual, in which
// case it will remain at the end
item.addTag(tagName, tags[j].type);
}
// If no existing tag, add with the type from the non-master item
else {
item.addTag(tagName, tags[j].type);
}
}
// Trash other item
otherItem.deleted = true;
toSave[otherItem.id] = otherItem;
}
item.setField('dateAdded', earliestDateAdded);
for (let i in toSave) {
yield toSave[i].save();
}
// Hack to remove master item from duplicates view without recalculating duplicates
Zotero.Notifier.trigger('removeDuplicatesMaster', 'item', item.id);
}.bind(this));
};
this._mergePDFAttachments = async function (item, otherItems) {
Zotero.DB.requireTransaction();
let remapAttachmentKeys = new Map();
let masterAttachmentHashes = await this._hashItem(item, 'bytes');
let hashesIncludeText = false;
for (let otherItem of otherItems) {
let mergedMasterAttachments = new Set();
for (let otherAttachment of await this.getAsync(otherItem.getAttachments(true))) {
if (!otherAttachment.isPDFAttachment()) {
continue;
}
// First check if master has an attachment with identical MD5 hash
let matchingHash = await otherAttachment.attachmentHash;
let masterAttachmentID = masterAttachmentHashes.get(matchingHash);
if (!masterAttachmentID && item.numAttachments(true)) {
// If that didn't work, hash master attachments by the
// most common words in their text and check again.
if (!hashesIncludeText) {
masterAttachmentHashes = new Map([
...masterAttachmentHashes,
...await this._hashItem(item, 'text')
]);
hashesIncludeText = true;
}
matchingHash = await this._hashAttachmentText(otherAttachment);
masterAttachmentID = masterAttachmentHashes.get(matchingHash);
}
if (!masterAttachmentID || mergedMasterAttachments.has(masterAttachmentID)) {
Zotero.debug(`No unmerged match for attachment ${otherAttachment.id} in master item - moving`);
otherAttachment.parentItemID = item.id;
await otherAttachment.save();
continue;
}
mergedMasterAttachments.add(masterAttachmentID);
let masterAttachment = await this.getAsync(masterAttachmentID);
if (masterAttachment.attachmentContentType !== otherAttachment.attachmentContentType) {
Zotero.debug(`Master attachment ${masterAttachmentID} matches ${otherAttachment.id}, `
+ 'but content types differ - moving');
otherAttachment.parentItemID = item.id;
await otherAttachment.save();
continue;
}
Zotero.debug(`Master attachment ${masterAttachmentID} matches ${otherAttachment.id} - merging`);
await this.moveChildItems(otherAttachment, masterAttachment, true);
await this._moveEmbeddedNote(otherAttachment, masterAttachment);
await this._moveRelations(otherAttachment, masterAttachment);
otherAttachment.deleted = true;
await otherAttachment.save();
// Later on, when processing notes, we'll use this to remap
// URLs pointing to the old attachment.
remapAttachmentKeys.set(otherAttachment.key, masterAttachment.key);
// Items can only have one replaced item predicate
if (!masterAttachment.getRelationsByPredicate(Zotero.Relations.replacedItemPredicate)) {
masterAttachment.addRelation(Zotero.Relations.replacedItemPredicate,
Zotero.URI.getItemURI(otherAttachment));
}
await masterAttachment.save();
}
}
return remapAttachmentKeys;
};
this._mergeWebAttachments = async function (item, otherItems) {
Zotero.DB.requireTransaction();
let masterAttachments = (await this.getAsync(item.getAttachments(true)))
.filter(attachment => attachment.isWebAttachment());
for (let otherItem of otherItems) {
for (let otherAttachment of await this.getAsync(otherItem.getAttachments(true))) {
if (!otherAttachment.isWebAttachment()) {
continue;
}
// If we can find an attachment with the same title *and* URL, use it.
let masterAttachment = (
masterAttachments.find(attachment => attachment.getField('title') == otherAttachment.getField('title')
&& attachment.getField('url') == otherAttachment.getField('url')
&& attachment.attachmentLinkMode === otherAttachment.attachmentLinkMode)
|| masterAttachments.find(attachment => attachment.getField('title') == otherAttachment.getField('title')
&& attachment.attachmentLinkMode === otherAttachment.attachmentLinkMode)
);
if (!masterAttachment) {
Zotero.debug(`No match for web attachment ${otherAttachment.id} in master item - moving`);
otherAttachment.parentItemID = item.id;
await otherAttachment.save();
continue;
}
otherAttachment.deleted = true;
await this._moveRelations(otherAttachment, masterAttachment);
await otherAttachment.save();
masterAttachment.addRelation(Zotero.Relations.replacedItemPredicate,
Zotero.URI.getItemURI(otherAttachment));
await masterAttachment.save();
// Don't match with this attachment again
masterAttachments = masterAttachments.filter(a => a !== masterAttachment);
}
}
};
this._mergeOtherAttachments = async function (item, otherItems) {
Zotero.DB.requireTransaction();
for (let otherItem of otherItems) {
for (let otherAttachment of await this.getAsync(otherItem.getAttachments(true))) {
if (otherAttachment.isPDFAttachment() || otherAttachment.isWebAttachment()) {
continue;
}
otherAttachment.parentItemID = item.id;
await otherAttachment.save();
}
}
};
/**
* Hash each attachment of the provided item. Return a map from hashes to
* attachment IDs.
*
* @param {Zotero.Item} item
* @param {String} hashType 'bytes' or 'text'
* @return {Promise<Map<String, String>>}
*/
this._hashItem = async function (item, hashType) {
if (!['bytes', 'text'].includes(hashType)) {
throw new Error('Invalid hash type');
}
let attachments = (await this.getAsync(item.getAttachments(true)))
.filter(attachment => attachment.isFileAttachment());
let hashes = new Map();
await Promise.all(attachments.map(async (attachment) => {
let hash = hashType === 'bytes'
? await attachment.attachmentHash
: await this._hashAttachmentText(attachment);
if (hash) {
hashes.set(hash, attachment.id);
}
}));
return hashes;
};
/**
* Hash an attachment by the most common words in its text.
* @param {Zotero.Item} attachment
* @return {Promise<String>}
*/
this._hashAttachmentText = async function (attachment) {
if ((await OS.File.stat(await attachment.getFilePathAsync())).size > 5e8) {
Zotero.debug('_hashAttachmentText: Attachment too large');
return null;
}
let text;
try {
text = await attachment.attachmentText;
}
catch (e) {
Zotero.logError(e);
}
if (!text) {
Zotero.debug('_hashAttachmentText: Attachment has no text');
return null;
}
let mostCommonWords = this._getMostCommonWords(text, 50);
if (mostCommonWords.length < 10) {
Zotero.debug('_hashAttachmentText: Not enough unique words');
return null;
}
return Zotero.Utilities.Internal.md5(mostCommonWords.sort().join(' '));
};
/**
* Get the n most common words in s in descending order of frequency.
* If s contains fewer than n unique words, the size of the returned array
* will be less than n.
*
* @param {String} s
* @param {Number} n
* @return {String[]}
*/
this._getMostCommonWords = function (s, n) {
// Use an iterative approach for better performance.
const whitespaceRe = /\s/;
const wordCharRe = /\p{Letter}/u; // [a-z] only matches Latin
let freqs = new Map();
let currentWord = '';
for (let codePoint of s) {
if (whitespaceRe.test(codePoint)) {
if (currentWord.length > 3) {
freqs.set(currentWord, (freqs.get(currentWord) || 0) + 1);
}
currentWord = '';
continue;
}
if (wordCharRe.test(codePoint)) {
currentWord += codePoint.toLowerCase();
}
}
// Break ties in locale order.
return [...freqs.keys()]
.sort((a, b) => (freqs.get(b) - freqs.get(a)) || Zotero.localeCompare(a, b))
.slice(0, n);
};
/**
* Move fromItem's embedded note, if it has one, to toItem.
* If toItem already has an embedded note, the note will be added as a new
* child note item on toItem's parent.
* Requires a transaction.
*/
this._moveEmbeddedNote = async function (fromItem, toItem) {
Zotero.DB.requireTransaction();
if (fromItem.getNote()) {
let noteItem = toItem;
if (toItem.getNote()) {
noteItem = new Zotero.Item('note');
noteItem.parentItemID = toItem.parentItemID;
}
noteItem.setNote(fromItem.getNote());
fromItem.setNote('');
Zotero.Notes.replaceItemKey(noteItem, fromItem.key, toItem.key);
await noteItem.save();
}
};
/**
* Move fromItem's relations to toItem as part of a merge.
* Requires a transaction.
*
* @param {Zotero.Item} fromItem
* @param {Zotero.Item} toItem
* @return {Promise}
*/
this._moveRelations = async function (fromItem, toItem) {
Zotero.DB.requireTransaction();
let replPred = Zotero.Relations.replacedItemPredicate;
let fromURI = Zotero.URI.getItemURI(fromItem);
let toURI = Zotero.URI.getItemURI(toItem);
// Add relations to toItem
let oldRelations = fromItem.getRelations();
for (let pred in oldRelations) {
oldRelations[pred].forEach(obj => toItem.addRelation(pred, obj));
}
// Remove merge-tracking relations from fromItem, so that there aren't two
// subjects for a given deleted object
let replItems = fromItem.getRelationsByPredicate(replPred);
for (let replItem of replItems) {
fromItem.removeRelation(replPred, replItem);
}
// Update relations on items in the library that point to the other item
// to point to the master instead
let rels = await Zotero.Relations.getByObject('item', fromURI);
for (let rel of rels) {
// Skip merge-tracking relations, which are dealt with above
if (rel.predicate == replPred) continue;
// Skip items in other libraries. They might not be editable, and even
// if they are, merging items in one library shouldn't affect another library,
// so those will follow the merge-tracking relations and can optimize their
// path if they're resaved.
if (rel.subject.libraryID != toItem.libraryID) continue;
rel.subject.removeRelation(rel.predicate, fromURI);
rel.subject.addRelation(rel.predicate, toURI);
await rel.subject.save();
}
// Add relation to track merge
toItem.addRelation(replPred, fromURI);
await fromItem.save();
await toItem.save();
};
this.trash = Zotero.Promise.coroutine(function* (ids) {
Zotero.DB.requireTransaction();
var libraryIDs = new Set();
ids = Zotero.flattenArguments(ids);
var items = [];
for (let id of ids) {
let item = this.get(id);
if (!item) {
Zotero.debug('Item ' + id + ' does not exist in Items.trash()!', 1);
Zotero.Notifier.queue('trash', 'item', id);
continue;
}
if (!item.isEditable()) {
throw new Error(item._ObjectType + " " + item.libraryKey + " is not editable");
}
if (!Zotero.Libraries.get(item.libraryID).hasTrash) {
throw new Error(Zotero.Libraries.getName(item.libraryID) + " does not have a trash");
}
items.push(item);
libraryIDs.add(item.libraryID);
}
var parentItemIDs = new Set();
items.forEach(item => {
item.setDeleted(true);
item.synced = false;
if (item.parentItemID) {
parentItemIDs.add(item.parentItemID);
}
});
yield Zotero.Utilities.Internal.forEachChunkAsync(ids, 250, Zotero.Promise.coroutine(function* (chunk) {
yield Zotero.DB.queryAsync(
"UPDATE items SET synced=0, clientDateModified=CURRENT_TIMESTAMP "
+ `WHERE itemID IN (${chunk.map(id => parseInt(id)).join(", ")})`
);
yield Zotero.DB.queryAsync(
"INSERT OR IGNORE INTO deletedItems (itemID) VALUES "
+ chunk.map(id => "(" + id + ")").join(", ")
);
}.bind(this)));
// Keep in sync with Zotero.Item::saveData()
for (let parentItemID of parentItemIDs) {
let parentItem = yield Zotero.Items.getAsync(parentItemID);
yield parentItem.reload(['primaryData', 'childItems'], true);
}
Zotero.Notifier.queue('modify', 'item', ids);
Zotero.Notifier.queue('trash', 'item', ids);
Array.from(libraryIDs).forEach(libraryID => {
Zotero.Notifier.queue('refresh', 'trash', libraryID);
});
});
this.trashTx = function (ids) {
return Zotero.DB.executeTransaction(function* () {
return this.trash(ids);
}.bind(this));
}
/**
* @param {Integer} libraryID - Library to delete from
* @param {Object} [options]
* @param {Function} [options.onProgress] - fn(progress, progressMax)
* @param {Integer} [options.days] - Only delete items deleted more than this many days ago
* @param {Integer} [options.limit] - Number of items to delete
*/
this.emptyTrash = async function (libraryID, options = {}) {
if (arguments.length > 2 || typeof arguments[1] == 'number') {
Zotero.warn("Zotero.Items.emptyTrash() has changed -- update your code");
options.days = arguments[1];
options.limit = arguments[2];
}
if (!libraryID) {
throw new Error("Library ID not provided");
}
var t = new Date();
var deleted = await this.getDeleted(libraryID, false, options.days);
if (options.limit) {
deleted = deleted.slice(0, options.limit);
}
var processed = 0;
if (deleted.length) {
let toDelete = {
top: [],
child: []
};
deleted.forEach((item) => {
item.isTopLevelItem() ? toDelete.top.push(item.id) : toDelete.child.push(item.id)
});
// Show progress meter during deletions
let eraseOptions = options.onProgress
? {
onProgress: function (progress, progressMax) {
options.onProgress(processed + progress, deleted.length);
}
}
: undefined;
for (let x of ['top', 'child']) {
await Zotero.Utilities.Internal.forEachChunkAsync(
toDelete[x],
1000,
async function (chunk) {
await this.erase(chunk, eraseOptions);
processed += chunk.length;
}.bind(this)
);
}
Zotero.debug("Emptied " + deleted.length + " item(s) from trash in " + (new Date() - t) + " ms");
}
return deleted.length;
};
/**
* Start idle observer to delete trashed items older than a certain number of days
*/
this._emptyTrashIdleObserver = null;
this._emptyTrashTimeoutID = null;
this.startEmptyTrashTimer = function () {
this._emptyTrashIdleObserver = {
observe: (subject, topic, data) => {
if (topic == 'idle' || topic == 'timer-callback') {
var days = Zotero.Prefs.get('trashAutoEmptyDays');
if (!days) {
return;
}
// TODO: empty group trashes if permissions
// Delete a few items a time
//
// TODO: increase number after dealing with slow
// tag.getLinkedItems() call during deletes
let num = 50;
this.emptyTrash(
Zotero.Libraries.userLibraryID,
{
days,
limit: num
}
)
.then((deleted) => {
if (!deleted) {
this._emptyTrashTimeoutID = null;
return;
}
// Set a timer to do more every few seconds
this._emptyTrashTimeoutID = setTimeout(() => {
this._emptyTrashIdleObserver.observe(null, 'timer-callback', null);
}, 2500);
});
}
// When no longer idle, cancel timer
else if (topic === 'active') {
if (this._emptyTrashTimeoutID) {
clearTimeout(this._emptyTrashTimeoutID);
this._emptyTrashTimeoutID = null;
}
}
}
};
var idleService = Components.classes["@mozilla.org/widget/idleservice;1"].
getService(Components.interfaces.nsIIdleService);
idleService.addIdleObserver(this._emptyTrashIdleObserver, 305);
}
this.addToPublications = function (items, options = {}) {
if (!items.length) return;
return Zotero.DB.executeTransaction(function* () {
var timestamp = Zotero.DB.transactionTimestamp;
var allItems = [...items];
if (options.license) {
for (let item of items) {
if (!options.keepRights || !item.getField('rights')) {
item.setField('rights', options.licenseName);
}
}
}
if (options.childNotes) {
for (let item of items) {
item.getNotes().forEach(id => allItems.push(Zotero.Items.get(id)));
}
}
if (options.childFileAttachments || options.childLinks) {
for (let item of items) {
item.getAttachments().forEach(id => {
var attachment = Zotero.Items.get(id);
var linkMode = attachment.attachmentLinkMode;
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping child linked file attachment on drag");
return;
}
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
if (!options.childLinks) {
Zotero.debug("Skipping child link attachment on drag");
return;
}
}
else if (!options.childFileAttachments) {
Zotero.debug("Skipping child file attachment on drag");
return;
}
allItems.push(attachment);
});
}
}
yield Zotero.Utilities.Internal.forEachChunkAsync(allItems, 250, Zotero.Promise.coroutine(function* (chunk) {
for (let item of chunk) {
item.setPublications(true);
item.synced = false;
}
let ids = chunk.map(item => item.id);
yield Zotero.DB.queryAsync(
`UPDATE items SET synced=0, clientDateModified=? WHERE itemID IN (${ids.join(", ")})`,
timestamp
);
yield Zotero.DB.queryAsync(
`INSERT OR IGNORE INTO publicationsItems VALUES (${ids.join("), (")})`
);
}.bind(this)));
Zotero.Notifier.queue('modify', 'item', allItems.map(item => item.id));
}.bind(this));
};
this.removeFromPublications = function (items) {
return Zotero.DB.executeTransaction(function* () {
let allItems = [];
for (let item of items) {
if (!item.inPublications) {
throw new Error(`Item ${item.libraryKey} is not in My Publications`);
}
// Remove all child items too
if (item.isRegularItem()) {
allItems.push(...this.get(item.getNotes(true).concat(item.getAttachments(true))));
}
allItems.push(item);
}
allItems.forEach(item => {
item.setPublications(false);
item.synced = false;
});
var timestamp = Zotero.DB.transactionTimestamp;
yield Zotero.Utilities.Internal.forEachChunkAsync(allItems, 250, Zotero.Promise.coroutine(function* (chunk) {
let idStr = chunk.map(item => item.id).join(", ");
yield Zotero.DB.queryAsync(
`UPDATE items SET synced=0, clientDateModified=? WHERE itemID IN (${idStr})`,
timestamp
);
yield Zotero.DB.queryAsync(`DELETE FROM publicationsItems WHERE itemID IN (${idStr})`);
}.bind(this)));
Zotero.Notifier.queue('modify', 'item', items.map(item => item.id));
}.bind(this));
};
/**
* Purge unused data values
*/
this.purge = Zotero.Promise.coroutine(function* () {
Zotero.DB.requireTransaction();
if (!Zotero.Prefs.get('purge.items')) {
return;
}
var sql = "DELETE FROM itemDataValues WHERE valueID NOT IN "
+ "(SELECT valueID FROM itemData)";
yield Zotero.DB.queryAsync(sql);
Zotero.Prefs.set('purge.items', false)
});
this.getFirstCreatorFromJSON = function (json) {
Zotero.warn("Zotero.Items.getFirstCreatorFromJSON() is deprecated "
+ "-- use Zotero.Utilities.Internal.getFirstCreatorFromItemJSON()");
return Zotero.Utilities.Internal.getFirstCreatorFromItemJSON(json);
};
/**
* Return a firstCreator string from internal creators data (from Zotero.Item::getCreators()).
*
* Used in Zotero.Item::getField() for unsaved items
*
* @param {Integer} itemTypeID
* @param {Object} creatorData
* @return {String}
*/
this.getFirstCreatorFromData = function (itemTypeID, creatorsData) {
if (creatorsData.length === 0) {
return "";
}
var validCreatorTypes = [
Zotero.CreatorTypes.getPrimaryIDForType(itemTypeID),
Zotero.CreatorTypes.getID('editor'),
Zotero.CreatorTypes.getID('contributor')
];
for (let creatorTypeID of validCreatorTypes) {
let matches = creatorsData.filter(data => data.creatorTypeID == creatorTypeID)
if (!matches.length) {
continue;
}
if (matches.length === 1) {
return matches[0].lastName;
}
if (matches.length === 2) {
let a = matches[0];
let b = matches[1];
return a.lastName + " " + Zotero.getString('general.and') + " " + b.lastName;
}
if (matches.length >= 3) {
return matches[0].lastName + " " + Zotero.getString('general.etAl');
}
}
return "";
};
/**
* Returns an array of items with children of selected parents removed
*
* @return {Zotero.Item[]}
*/
this.keepParents = function (items) {
var parentItems = new Set(
items
.filter(item => item.isTopLevelItem())
.map(item => item.id)
);
return items.filter(item => {
var parentItemID = item.parentItemID;
// Not a child item or not a child of one of the passed items
return !parentItemID || !parentItems.has(parentItemID);
});
}
/*
* Generate SQL to retrieve firstCreator field
*
* Why do we do this entirely in SQL? Because we're crazy. Crazy like foxes.
*/
var _firstCreatorSQL = '';
function _getFirstCreatorSQL() {
if (_firstCreatorSQL) {
return _firstCreatorSQL;
}
var editorCreatorTypeID = Zotero.CreatorTypes.getID('editor');
var contributorCreatorTypeID = Zotero.CreatorTypes.getID('contributor');
/* This whole block is to get the firstCreator */
var localizedAnd = Zotero.getString('general.and');
var localizedEtAl = Zotero.getString('general.etAl');
var sql = "COALESCE(" +
// First try for primary creator types
"CASE (" +
"SELECT COUNT(*) FROM itemCreators IC " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1" +
") " +
"WHEN 0 THEN NULL " +
"WHEN 1 THEN (" +
"SELECT lastName FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1" +
") " +
"WHEN 2 THEN (" +
"SELECT " +
"(SELECT lastName FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1)" +
" || ' " + localizedAnd + " ' || " +
"(SELECT lastName FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1,1)" +
") " +
"ELSE (" +
"SELECT " +
"(SELECT lastName FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1)" +
" || ' " + localizedEtAl + "' " +
") " +
"END, " +
// Then try editors
"CASE (" +
"SELECT COUNT(*) FROM itemCreators " +
`WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID}` +
") " +
"WHEN 0 THEN NULL " +
"WHEN 1 THEN (" +
"SELECT lastName FROM itemCreators NATURAL JOIN creators " +
`WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID}` +
") " +
"WHEN 2 THEN (" +
"SELECT " +
"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
`WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID} ` +
"ORDER BY orderIndex LIMIT 1)" +
" || ' " + localizedAnd + " ' || " +
"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
`WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID} ` +
"ORDER BY orderIndex LIMIT 1,1) " +
") " +
"ELSE (" +
"SELECT " +
"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
`WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID} ` +
"ORDER BY orderIndex LIMIT 1)" +
" || ' " + localizedEtAl + "' " +
") " +
"END, " +
// Then try contributors
"CASE (" +
"SELECT COUNT(*) FROM itemCreators " +
`WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID}` +
") " +
"WHEN 0 THEN NULL " +
"WHEN 1 THEN (" +
"SELECT lastName FROM itemCreators NATURAL JOIN creators " +
`WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID}` +
") " +
"WHEN 2 THEN (" +
"SELECT " +
"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
`WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID} ` +
"ORDER BY orderIndex LIMIT 1)" +
" || ' " + localizedAnd + " ' || " +
"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
`WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID} ` +
"ORDER BY orderIndex LIMIT 1,1) " +
") " +
"ELSE (" +
"SELECT " +
"(SELECT lastName FROM itemCreators NATURAL JOIN creators " +
`WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID} ` +
"ORDER BY orderIndex LIMIT 1)" +
" || ' " + localizedEtAl + "' " +
") " +
"END" +
") AS firstCreator";
_firstCreatorSQL = sql;
return sql;
}
/*
* Generate SQL to retrieve sortCreator field
*/
var _sortCreatorSQL = '';
function _getSortCreatorSQL() {
if (_sortCreatorSQL) {
return _sortCreatorSQL;
}
var editorCreatorTypeID = Zotero.CreatorTypes.getID('editor');
var contributorCreatorTypeID = Zotero.CreatorTypes.getID('contributor');
var nameSQL = "lastName || ' ' || firstName ";
var sql = "COALESCE("
// First try for primary creator types
+ "CASE (" +
"SELECT COUNT(*) FROM itemCreators IC " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1" +
") " +
"WHEN 0 THEN NULL " +
"WHEN 1 THEN (" +
"SELECT " + nameSQL + "FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1" +
") " +
"WHEN 2 THEN (" +
"SELECT " +
"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1)" +
" || ' ' || " +
"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1,1)" +
") " +
"ELSE (" +
"SELECT " +
"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1)" +
" || ' ' || " +
"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 1,1)" +
" || ' ' || " +
"(SELECT " + nameSQL + " FROM itemCreators IC NATURAL JOIN creators " +
"LEFT JOIN itemTypeCreatorTypes ITCT " +
"ON (IC.creatorTypeID=ITCT.creatorTypeID AND ITCT.itemTypeID=O.itemTypeID) " +
"WHERE itemID=O.itemID AND primaryField=1 ORDER BY orderIndex LIMIT 2,1)" +
") "
+ "END, "
// Then try editors
+ "CASE ("
+ "SELECT COUNT(*) FROM itemCreators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID}`
+ ") "
+ "WHEN 0 THEN NULL "
+ "WHEN 1 THEN ("
+ "SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID}`
+ ") "
+ "WHEN 2 THEN ("
+ "SELECT "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 1)"
+ " || ' ' || "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 1,1) "
+ ") "
+ "ELSE ("
+ "SELECT "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 1)"
+ " || ' ' || "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 1,1)"
+ " || ' ' || "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${editorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 2,1)"
+ ") "
+ "END, "
// Then try contributors
+ "CASE ("
+ "SELECT COUNT(*) FROM itemCreators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID}`
+ ") "
+ "WHEN 0 THEN NULL "
+ "WHEN 1 THEN ("
+ "SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID}`
+ ") "
+ "WHEN 2 THEN ("
+ "SELECT "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 1)"
+ " || ' ' || "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 1,1) "
+ ") "
+ "ELSE ("
+ "SELECT "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 1)"
+ " || ' ' || "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 1,1)"
+ " || ' ' || "
+ "(SELECT " + nameSQL + " FROM itemCreators NATURAL JOIN creators "
+ `WHERE itemID=O.itemID AND creatorTypeID=${contributorCreatorTypeID} `
+ "ORDER BY orderIndex LIMIT 2,1)"
+ ") "
+ "END"
+ ") AS sortCreator";
_sortCreatorSQL = sql;
return sql;
}
let _stripFromSortTitle = [
'</?i>',
'</?b>',
'</?sub>',
'</?sup>',
'<span style="font-variant:small-caps;">',
'<span class="nocase">',
'</span>',
'\\p{P}'
].map(re => Zotero.Utilities.XRegExp(re, 'g'));
this.getSortTitle = function (title) {
if (!title) {
return '';
}
if (typeof title == 'number') {
return title.toString();
}
for (let re of _stripFromSortTitle) {
title = title.replace(re, '');
}
return title;
};
Zotero.DataObjects.call(this);
return this;
}.bind(Object.create(Zotero.DataObjects.prototype))();