From 1c366de5463284db6c996e657b02bdbcd8876553 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Sat, 20 Jun 2020 01:29:32 -0400 Subject: [PATCH] Initial annotation support --- chrome/content/zotero/bindings/itembox.xml | 2 +- chrome/content/zotero/xpcom/annotations.js | 129 +++++++++ chrome/content/zotero/xpcom/attachments.js | 74 ++++- .../content/zotero/xpcom/data/cachedTypes.js | 13 +- .../content/zotero/xpcom/data/dataObject.js | 2 +- .../content/zotero/xpcom/data/dataObjects.js | 9 +- chrome/content/zotero/xpcom/data/item.js | 269 +++++++++++++++++- .../content/zotero/xpcom/data/itemFields.js | 4 +- chrome/content/zotero/xpcom/data/items.js | 148 +++++++++- chrome/content/zotero/xpcom/schema.js | 20 +- components/zotero-service.js | 1 + resource/schema/userdata.sql | 15 + test/content/support.js | 24 +- test/tests/annotationsTest.js | 266 +++++++++++++++++ test/tests/itemTest.js | 161 +++++++++++ 15 files changed, 1109 insertions(+), 28 deletions(-) create mode 100644 chrome/content/zotero/xpcom/annotations.js create mode 100644 test/tests/annotationsTest.js diff --git a/chrome/content/zotero/bindings/itembox.xml b/chrome/content/zotero/bindings/itembox.xml index b1f961e3ca..506280aae3 100644 --- a/chrome/content/zotero/bindings/itembox.xml +++ b/chrome/content/zotero/bindings/itembox.xml @@ -652,7 +652,7 @@ for (var i=0; i. + + ***** END LICENSE BLOCK ***** +*/ + +"use strict"; + +Zotero.Annotations = new function () { + // Keep in sync with items.js::loadAnnotations() + Zotero.defineProperty(this, 'ANNOTATION_TYPE_HIGHLIGHT', { value: 1 }); + Zotero.defineProperty(this, 'ANNOTATION_TYPE_NOTE', { value: 2 }); + Zotero.defineProperty(this, 'ANNOTATION_TYPE_AREA', { value: 3 }); + + + this.toJSON = function (item) { + var o = {}; + o.key = item.key; + o.type = item.annotationType; + o.isAuthor = !item.createdByUserID || item.createdByUserID == Zotero.Users.getCurrentUserID(); + if (!o.isAuthor) { + o.authorName = Zotero.Users.getName(item.createdByUserID); + } + if (o.type == 'highlight') { + o.text = item.annotationText; + } + else if (o.type == 'area') { + o.imageURL = item.annotationImageURL; + } + o.comment = item.annotationComment; + o.pageLabel = item.annotationPageLabel; + o.color = item.annotationColor; + o.sortIndex = item.annotationSortIndex; + o.position = item.annotationPosition; + + // Add tags and tag colors + var tagColors = Zotero.Tags.getColors(item.libraryID); + var tags = item.getTags().map((t) => { + let obj = { + name: t.tag + }; + if (tagColors.has(t.tag)) { + obj.color = tagColors.get(t.tag).color; + // Add 'position' for sorting + obj.position = tagColors.get(t.tag).position; + } + return obj; + }); + // Sort colored tags by position and other tags by name + tags.sort((a, b) => { + if (!a.color && !b.color) return Zotero.localeCompare(a.name, b.name); + if (!a.color && !b.color) return -1; + if (!a.color && b.color) return 1; + return a.position - b.position; + }); + // Remove temporary 'position' value + tags.forEach(t => delete t.position); + if (tags.length) { + o.tags = tags; + } + + o.dateModified = item.dateModified; + return o; + }; + + + /** + * @param {Zotero.Item} attachment - Saved parent attachment item + * @param {Object} json + * @return {Promise} - Promise for an annotation item + */ + this.saveFromJSON = async function (attachment, json, saveOptions = {}) { + if (!attachment) { + throw new Error("'attachment' not provided"); + } + if (!attachment.libraryID) { + throw new Error("'attachment' is not saved"); + } + if (!json.key) { + throw new Error("'key' not provided in JSON"); + } + + var item = Zotero.Items.getByLibraryAndKey(attachment.libraryID, json.key); + if (!item) { + item = new Zotero.Item('annotation'); + item.libraryID = attachment.libraryID; + item.key = json.key; + await item.loadPrimaryData(); + } + item.parentID = attachment.id; + + item._requireData('annotation'); + item._requireData('annotationDeferred'); + item.annotationType = json.type; + if (json.type == 'highlight') { + item.annotationText = json.text; + } + item.annotationComment = json.comment; + item.annotationColor = json.color; + item.annotationPageLabel = json.pageLabel; + item.annotationSortIndex = json.sortIndex; + item.annotationPosition = Object.assign({}, json.position); + // TODO: Can colors be set? + item.setTags((json.tags || []).map(t => ({ tag: t.name }))); + + await item.saveTx(saveOptions); + + return item; + }; +}; diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js index d896bf325d..f19a4efb0c 100644 --- a/chrome/content/zotero/xpcom/attachments.js +++ b/chrome/content/zotero/xpcom/attachments.js @@ -24,11 +24,13 @@ */ Zotero.Attachments = new function(){ - // Keep in sync with Zotero.Schema.integrityCheck() + // Keep in sync with Zotero.Schema.integrityCheck() and this.linkModeToName() this.LINK_MODE_IMPORTED_FILE = 0; this.LINK_MODE_IMPORTED_URL = 1; this.LINK_MODE_LINKED_FILE = 2; this.LINK_MODE_LINKED_URL = 3; + this.LINK_MODE_EMBEDDED_IMAGE = 4; + this.BASE_PATH_PLACEHOLDER = 'attachments:'; var _findPDFQueue = []; @@ -351,6 +353,71 @@ Zotero.Attachments = new function(){ }); + /** + * Saves an image for a parent note or area annotation + * + * @param {Object} params + * @param {Blob} params.blob - Image to save + * @param {Integer} params.parentItemID - Annotation item to add item to + * @param {Object} [params.saveOptions] - Options to pass to Zotero.Item::save() + * @return {Promise} + */ + this.importEmbeddedImage = async function ({ blob, parentItemID, saveOptions }) { + Zotero.debug('Importing annotation image'); + + var contentType = blob.type; + var fileExt; + switch (contentType) { + case 'image/png': + fileExt = 'png'; + break; + + default: + throw new Error(`Unsupported embedded image content type '${contentType}`); + } + var filename = 'image.' + fileExt; + + var attachmentItem; + var destDir; + try { + await Zotero.DB.executeTransaction(async function () { + // Create a new attachment + attachmentItem = new Zotero.Item('attachment'); + let { libraryID: parentLibraryID } = Zotero.Items.getLibraryAndKeyFromID(parentItemID); + attachmentItem.libraryID = parentLibraryID; + attachmentItem.parentID = parentItemID; + attachmentItem.attachmentLinkMode = this.LINK_MODE_EMBEDDED_IMAGE; + attachmentItem.attachmentPath = 'storage:' + filename; + attachmentItem.attachmentContentType = contentType; + await attachmentItem.save(saveOptions); + + // Write blob to file in attachment directory + destDir = await this.createDirectoryForItem(attachmentItem); + let file = OS.Path.join(destDir, filename); + await Zotero.File.putContentsAsync(file, blob); + await Zotero.File.setNormalFilePermissions(file); + }.bind(this)); + } + catch (e) { + Zotero.logError("Failed importing image:\n\n" + e); + + // Clean up + try { + if (destDir) { + await OS.File.removeDir(destDir, { ignoreAbsent: true }); + } + } + catch (e) { + Zotero.logError(e); + } + + throw e; + } + + return attachmentItem; + }; + + /** * @param {Object} options * @param {Integer} options.libraryID @@ -2102,6 +2169,9 @@ Zotero.Attachments = new function(){ if (!(item instanceof Zotero.Item)) { throw new Error("'item' must be a Zotero.Item"); } + if (!item.key) { + throw new Error("Item key must be set"); + } return this.getStorageDirectoryByLibraryAndKey(item.libraryID, item.key); } @@ -2787,6 +2857,8 @@ Zotero.Attachments = new function(){ return 'linked_file'; case this.LINK_MODE_LINKED_URL: return 'linked_url'; + case this.LINK_MODE_EMBEDDED_IMAGE: + return 'embedded_image'; default: throw new Error(`Invalid link mode ${linkMode}`); } diff --git a/chrome/content/zotero/xpcom/data/cachedTypes.js b/chrome/content/zotero/xpcom/data/cachedTypes.js index 0b31ebd762..356f597e41 100644 --- a/chrome/content/zotero/xpcom/data/cachedTypes.js +++ b/chrome/content/zotero/xpcom/data/cachedTypes.js @@ -354,6 +354,8 @@ Zotero.ItemTypes = new function() { var _primaryTypeNames = ['book', 'bookSection', 'journalArticle', 'newspaperArticle', 'document']; var _primaryTypes; var _secondaryTypes; + // Item types hidden from New Item menu + var _hiddenTypeNames = ['webpage', 'attachment', 'note', 'annotation']; var _hiddenTypes; var _numPrimary = 5; @@ -371,12 +373,13 @@ Zotero.ItemTypes = new function() { // Secondary types _secondaryTypes = yield this._getTypesFromDB( - `WHERE display != 0 AND display NOT IN ('${_primaryTypeNames.join("', '")}')` - + " AND name != 'webpage'" + `WHERE typeName NOT IN ('${_primaryTypeNames.concat(_hiddenTypeNames).join("', '")}')` ); // Hidden types - _hiddenTypes = yield this._getTypesFromDB('WHERE display=0') + _hiddenTypes = yield this._getTypesFromDB( + `WHERE typeName IN ('${_hiddenTypeNames.join("', '")}')` + ); // Custom labels and icons var sql = "SELECT customItemTypeID AS id, label, icon FROM customItemTypes"; @@ -402,8 +405,8 @@ Zotero.ItemTypes = new function() { mru.split(',') .slice(0, _numPrimary) .map(name => this.getName(name)) - // Ignore 'webpage' item type - .filter(name => name && name != 'webpage') + // Ignore hidden item types and 'webpage' + .filter(name => name && !_hiddenTypeNames.concat('webpage').includes(name)) ); // Add types from defaults until we reach our limit diff --git a/chrome/content/zotero/xpcom/data/dataObject.js b/chrome/content/zotero/xpcom/data/dataObject.js index 6d62f91976..524088476c 100644 --- a/chrome/content/zotero/xpcom/data/dataObject.js +++ b/chrome/content/zotero/xpcom/data/dataObject.js @@ -763,7 +763,7 @@ Zotero.DataObject.prototype._getLatestField = function (field) { */ Zotero.DataObject.prototype._markFieldChange = function (field, value) { // New method (changedData) - if (['deleted', 'tags'].includes(field)) { + if (['deleted', 'tags'].includes(field) || field.startsWith('annotation')) { if (Array.isArray(value)) { this._changedData[field] = [...value]; } diff --git a/chrome/content/zotero/xpcom/data/dataObjects.js b/chrome/content/zotero/xpcom/data/dataObjects.js index b0bca903a3..c27d440412 100644 --- a/chrome/content/zotero/xpcom/data/dataObjects.js +++ b/chrome/content/zotero/xpcom/data/dataObjects.js @@ -486,9 +486,14 @@ Zotero.DataObjects.prototype.loadDataTypes = Zotero.Promise.coroutine(function* * @param {Integer[]} [ids] */ Zotero.DataObjects.prototype._loadDataTypeInLibrary = Zotero.Promise.coroutine(function* (dataType, libraryID, ids) { - var funcName = "_load" + dataType[0].toUpperCase() + dataType.substr(1) + // note → loadNotes + // itemData → loadItemData + // annotationDeferred → loadAnnotationsDeferred + var baseDataType = dataType.replace('Deferred', ''); + var funcName = "_load" + dataType[0].toUpperCase() + baseDataType.substr(1) // Single data types need an 's' (e.g., 'note' -> 'loadNotes()') - + ((dataType.endsWith('s') || dataType.endsWith('Data') ? '' : 's')); + + ((baseDataType.endsWith('s') || baseDataType.endsWith('Data') ? '' : 's')) + + (dataType.endsWith('Deferred') ? 'Deferred' : ''); if (!this[funcName]) { throw new Error(`Zotero.${this._ZDO_Objects}.${funcName} is not a function`); } diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index a7ff2ccc1b..eeaf3dee5d 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -64,6 +64,16 @@ Zotero.Item = function(itemTypeOrID) { this._attachments = null; this._notes = null; + // loadAnnotation + this._annotationType = null; + this._annotationText = null; + this._annotationImage = null; + this._annotationComment = null; + this._annotationColor = null; + this._annotationPageLabel = null; + this._annotationSortIndex = null; + this._annotationPosition = null; + this._tags = []; this._collections = []; @@ -91,6 +101,8 @@ Zotero.Item.prototype._dataTypes = Zotero.Item._super.prototype._dataTypes.conca 'creators', 'itemData', 'note', + 'annotation', + 'annotationDeferred', 'childItems', // 'relatedItems', // TODO: remove 'tags', @@ -122,6 +134,9 @@ for (let name of ['libraryID', 'key', 'dateAdded', 'dateModified', 'version', 's 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', { @@ -177,8 +192,8 @@ Zotero.Item.prototype._set = function () { } Zotero.Item.prototype._setParentKey = function() { - if (!this.isNote() && !this.isAttachment()) { - throw new Error("_setParentKey() can only be called on items of type 'note' or 'attachment'"); + 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); @@ -1426,6 +1441,7 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) { switch (Zotero.ItemTypes.getName(itemTypeID)) { case 'note': case 'attachment': + case 'annotation': reloadParentChildItems[parentItemID] = true; break; } @@ -1741,6 +1757,62 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) { } } + // + // 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 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'); + // This gets stringified, so make sure it's not null + if (!position) { + throw new Error("Annotation position not set"); + } + + let sql = "REPLACE INTO itemAnnotations " + + "(itemID, parentItemID, type, text, comment, color, pageLabel, sortIndex, position) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + yield Zotero.DB.queryAsync( + sql, + [ + itemID, + parentItemID, + typeID, + text || null, + comment || null, + color || null, + pageLabel || null, + sortIndex, + JSON.stringify(position) + ] + ); + + // Clear cached child items of the parent attachment + reloadParentChildItems[parentItemID] = true; + } + // Add to new collections if (env.collectionsAdded) { let toAdd = env.collectionsAdded; @@ -1895,7 +1967,9 @@ Zotero.Item.prototype.setSourceKey = function(sourceItemKey) { //////////////////////////////////////////////////////// // -// Methods dealing with note items +// +// Note methods +// // //////////////////////////////////////////////////////// /** @@ -2083,10 +2157,12 @@ Zotero.Item.prototype.getNotes = function(includeTrashed) { //////////////////////////////////////////////////////// // -// Methods dealing with attachments +// +// Attachment methods // // save() is not required for attachment functions // +// /////////////////////////////////////////////////////// /** * Determine if an item is an attachment @@ -2104,8 +2180,11 @@ Zotero.Item.prototype.isImportedAttachment = function() { return false; } var linkMode = this.attachmentLinkMode; - if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE || linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL) { - return true; + switch (linkMode) { + case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: + case Zotero.Attachments.LINK_MODE_IMPORTED_URL: + case Zotero.Attachments.LINK_MODE_EMBEDDED_IMAGE: + return true; } return false; } @@ -2310,8 +2389,7 @@ Zotero.Item.prototype.getFilePathAsync = Zotero.Promise.coroutine(function* () { } // Imported file with relative path - if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_URL || - linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) { + if (this.isImportedAttachment()) { if (!path.includes("storage:")) { Zotero.logError("Invalid attachment path '" + path + "'"); this._updateAttachmentStates(false); @@ -2731,6 +2809,7 @@ Zotero.defineProperty(Zotero.Item.prototype, 'attachmentLinkMode', { 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: @@ -3360,6 +3439,180 @@ Zotero.Item.prototype.clearBestAttachmentState = function () { } +//////////////////////////////////////////////////////// +// +// +// Annotation methods +// +// +//////////////////////////////////////////////////////// + +// Main annotation properties (required for items list display) +for (let name of ['type', 'text', 'comment', 'color', 'pageLabel', 'sortIndex']) { + 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'); + + if (this._getLatestField(field) === value) { + return; + } + + switch (name) { + case 'type': { + let currentType = this._getLatestField('annotationType'); + if (currentType && currentType != value) { + throw new Error("Cannot change annotation type"); + } + if (!['highlight', 'note', 'area'].includes(value)) { + throw new Error(`Invalid annotation type '${value}'`); + } + break; + } + case 'text': + if (this._getLatestField('annotationType') != 'highlight') { + throw new Error("'annotationText' can only be set for highlight annotations"); + } + break; + + case 'sortIndex': + if (!/^\d{6}\|\d{7}\|\d{6}\.\d{3}$/.test(value)) { + throw new Error(`Invalid sortIndex '${value}`); + } + 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 (this._getLatestField(field) === value) { + return; + } + this._markFieldChange(field, value); + this._changed.annotationDeferred = true; + } + }); +} + + +/** + * @property {Zotero.Item} annotationImageAttachment + */ +Zotero.defineProperty(Zotero.Item.prototype, 'annotationImageAttachment', { + get: function () { + if (!this.isAreaAnnotation()) { + throw new Error("'annotationImageAttachment' is only valid for area annotations"); + } + var attachments = this.getAttachments(); + if (!attachments.length) { + throw new Error("No attachments found for area annotation"); + } + return Zotero.Items.get(attachments[0]); + } +}); + + +/** + * @property {String} annotationImageURL + */ +Zotero.defineProperty(Zotero.Item.prototype, 'annotationImageURL', { + get: function () { + if (!this.isAreaAnnotation()) { + throw new Error("'annotationImageURL' is only valid for area annotations"); + } + var attachments = this.getAttachments(); + if (!attachments.length) { + throw new Error("No attachments found for area annotation"); + } + + var { libraryID, key } = Zotero.Items.getLibraryAndKeyFromID(attachments[0]); + var url = 'zotero://attachment/'; + if (libraryID == Zotero.Libraries.userLibraryID) { + url += 'library'; + } + else { + url += Zotero.URI.getLibraryPath(libraryID); + } + url += '/items/' + key; + + return url; + } +}); + + +/** + * 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.isAreaAnnotation = function() { + return this.isAnnotation() && this._getLatestField('annotationType') == 'area'; +} + + +/** + * 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.isAttachment()) { + throw new Error("getAnnotations() can only be called on attachment items"); + } + + this._requireData('childItems'); + + if (!this._annotations) { + return []; + } + + var cacheKey = 'with' + (includeTrashed ? '' : 'out') + 'Trashed'; + + if (this._annotations[cacheKey]) { + return [...this._annotations[cacheKey]]; + } + + var rows = this._annotations.rows.concat(); + // 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 ids; +}; + + + // // Methods dealing with item tags // diff --git a/chrome/content/zotero/xpcom/data/itemFields.js b/chrome/content/zotero/xpcom/data/itemFields.js index e21dde99d1..92104da40b 100644 --- a/chrome/content/zotero/xpcom/data/itemFields.js +++ b/chrome/content/zotero/xpcom/data/itemFields.js @@ -518,7 +518,9 @@ Zotero.ItemFields = new function() { var rows = yield Zotero.DB.queryAsync(sql); _itemTypeFields = { - [Zotero.ItemTypes.getID('note')]: [] // Notes have no fields + // Notes and annotations have no fields + [Zotero.ItemTypes.getID('note')]: [], + [Zotero.ItemTypes.getID('annotation')]: [] }; for (let i=0; i 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: ids.length != 1, + 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 all top-level items as having child items loaded sql = "SELECT itemID FROM items I WHERE libraryID=?" + idSQL + " AND itemID NOT IN " + "(SELECT itemID FROM itemAttachments UNION SELECT itemID FROM itemNotes)"; diff --git a/chrome/content/zotero/xpcom/schema.js b/chrome/content/zotero/xpcom/schema.js index 2369c02211..a0eb9139d5 100644 --- a/chrome/content/zotero/xpcom/schema.js +++ b/chrome/content/zotero/xpcom/schema.js @@ -41,7 +41,7 @@ Zotero.Schema = new function(){ // If updating from this userdata version or later, don't show "Upgrading database…" and don't make // DB backup first. This should be set to false when breaking compatibility or making major changes. - const minorUpdateFrom = 107; + const minorUpdateFrom = false; var _dbVersions = []; var _schemaVersions = []; @@ -344,9 +344,18 @@ Zotero.Schema = new function(){ * @return {Object} */ async function _readGlobalSchemaFromFile() { - return JSON.parse( + var data = JSON.parse( await Zotero.File.getResourceAsync('resource://zotero/schema/global/schema.json') ); + // TEMP: Add annotation to schema + // TODO: Move to schema.json + data.itemTypes.push({ + itemType: "annotation", + fields: [], + creatorTypes: [] + }); + data.locales['en-US'].itemTypes.annotation = 'Annotation'; + return data; } @@ -1944,8 +1953,8 @@ Zotero.Schema = new function(){ ], // Invalid link mode -- set to imported url [ - "SELECT COUNT(*) > 0 FROM itemAttachments WHERE linkMode NOT IN (0,1,2,3)", - "UPDATE itemAttachments SET linkMode=1 WHERE linkMode NOT IN (0,1,2,3)" + "SELECT COUNT(*) > 0 FROM itemAttachments WHERE linkMode NOT IN (0,1,2,3,4)", + "UPDATE itemAttachments SET linkMode=1 WHERE linkMode NOT IN (0,1,2,3,4)" ], // Creators with first name can't be fieldMode 1 [ @@ -3224,6 +3233,9 @@ Zotero.Schema = new function(){ yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS users"); yield Zotero.DB.queryAsync("CREATE TABLE users (\n userID INTEGER PRIMARY KEY,\n name TEXT NOT NULL\n)"); + + yield Zotero.DB.queryAsync("CREATE TABLE itemAnnotations (\n itemID INTEGER PRIMARY KEY,\n parentItemID INT NOT NULL,\n type INTEGER NOT NULL,\n text TEXT,\n comment TEXT,\n color TEXT,\n pageLabel TEXT,\n sortIndex TEXT NOT NULL,\n position TEXT NOT NULL,\n FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE,\n FOREIGN KEY (parentItemID) REFERENCES itemAttachments(itemID) ON DELETE CASCADE\n)"); + yield Zotero.DB.queryAsync("CREATE INDEX itemAnnotations_parentItemID ON itemAnnotations(parentItemID)"); } // If breaking compatibility or doing anything dangerous, clear minorUpdateFrom diff --git a/components/zotero-service.js b/components/zotero-service.js index 83b54b2d09..eb88e48a89 100644 --- a/components/zotero-service.js +++ b/components/zotero-service.js @@ -64,6 +64,7 @@ const xpcomFilesLocal = [ 'libraryTreeView', 'collectionTreeView', 'collectionTreeRow', + 'annotations', 'api', 'attachments', 'cite', diff --git a/resource/schema/userdata.sql b/resource/schema/userdata.sql index 1454624333..703e1ec1a2 100644 --- a/resource/schema/userdata.sql +++ b/resource/schema/userdata.sql @@ -113,6 +113,21 @@ CREATE INDEX itemAttachments_charsetID ON itemAttachments(charsetID); CREATE INDEX itemAttachments_contentType ON itemAttachments(contentType); CREATE INDEX itemAttachments_syncState ON itemAttachments(syncState); +CREATE TABLE itemAnnotations ( + itemID INTEGER PRIMARY KEY, + parentItemID INT NOT NULL, + type INTEGER NOT NULL, + text TEXT, + comment TEXT, + color TEXT, + pageLabel TEXT, + sortIndex TEXT NOT NULL, + position TEXT NOT NULL, + FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE, + FOREIGN KEY (parentItemID) REFERENCES itemAttachments(itemID) ON DELETE CASCADE +); +CREATE INDEX itemAnnotations_parentItemID ON itemAnnotations(parentItemID); + CREATE TABLE tags ( tagID INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE diff --git a/test/content/support.js b/test/content/support.js index 02ac0796af..972b829f28 100644 --- a/test/content/support.js +++ b/test/content/support.js @@ -689,7 +689,7 @@ function generateAllTypesAndFieldsData() { }; // Item types that should not be included in sample data - let excludeItemTypes = ['note', 'attachment']; + let excludeItemTypes = ['note', 'attachment', 'annotation']; for (let i = 0; i < itemTypes.length; i++) { if (excludeItemTypes.indexOf(itemTypes[i].name) != -1) continue; @@ -921,6 +921,28 @@ function importHTMLAttachment() { } +async function createAnnotation(type, parentItem) { + var annotation = new Zotero.Item('annotation'); + annotation.parentID = parentItem.id; + annotation.annotationType = type; + if (type == 'highlight') { + annotation.annotationText = Zotero.Utilities.randomString(); + } + annotation.annotationComment = Zotero.Utilities.randomString(); + var page = Zotero.Utilities.rand(1, 100).toString().padStart(6, '0'); + var pos = Zotero.Utilities.rand(1, 10000).toString().padStart(7, '0'); + annotation.annotationSortIndex = `${page}|${pos}|000000.000`; + annotation.annotationPosition = { + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }; + await annotation.saveTx(); + return annotation; +} + + /** * Sets the fake XHR server to response to a given response * diff --git a/test/tests/annotationsTest.js b/test/tests/annotationsTest.js new file mode 100644 index 0000000000..58e3ff6982 --- /dev/null +++ b/test/tests/annotationsTest.js @@ -0,0 +1,266 @@ +describe("Zotero.Annotations", function() { + var exampleHighlight = { + "key": "92JLMCVT", + "type": "highlight", + "isAuthor": true, + "text": "This is an extracted text with rich-text\nAnd a new line", + "comment": "This is a comment with rich-text\nAnd a new line", + "color": "#ffec00", + "pageLabel": "15", + "sortIndex": "000015|0002431|000000.000", + "position": { + "pageIndex": 1, + "rects": [ + [231.284, 402.126, 293.107, 410.142], + [54.222, 392.164, 293.107, 400.18], + [54.222, 382.201, 293.107, 390.217], + [54.222, 372.238, 293.107, 380.254], + [54.222, 362.276, 273.955, 370.292] + ] + }, + "tags": [ + { + "name": "math", + "color": "#ff0000" + }, + { + "name": "chemistry" + } + ], + "dateModified": "2019-05-14 06:50:40" + }; + + var exampleNote = { + "key": "5TKU34XX", + "type": "note", + "isAuthor": true, + "comment": "This is a note", + "color": "#ffec00", + "pageLabel": "14", + "sortIndex": "000014|0001491|000283.000", + "position": { + "pageIndex": 0, + "rects": [ + [371.395, 266.635, 486.075, 274.651] + ] + }, + "dateModified": "2019-05-14 06:50:54" + }; + + var exampleArea = { + "key": "QD32MQJF", + "type": "area", + "isAuthor": true, + "imageURL": "zotero://attachment/library/items/LB417FR4", + "comment": "This is a comment", + "color": "#ffec00", + "pageLabel": "XVI", + "sortIndex": "000016|0003491|000683.000", + "position": { + "pageIndex": 123, + "rects": [ + [314.4, 412.8, 556.2, 609.6] + ], + "width": 400, + "height": 200 + }, + "dateModified": "2019-05-14 06:51:22" + }; + + var exampleGroupHighlight = { + "key": "PE57YAYH", + "type": "highlight", + "isAuthor": false, + "authorName": "Kate Smith", + "text": "This is an extracted text with rich-text\nAnd a new line", + "comment": "This is a comment with rich-text\nAnd a new line", + "color": "#ffec00", + "pageLabel": "15", + "sortIndex": "000015|0002431|000000.000", + "position": { + "pageIndex": 1, + "rects": [ + [231.284, 402.126, 293.107, 410.142], + [54.222, 392.164, 293.107, 400.18], + [54.222, 382.201, 293.107, 390.217], + [54.222, 372.238, 293.107, 380.254], + [54.222, 362.276, 273.955, 370.292] + ] + }, + "dateModified": "2019-05-14 06:50:40" + }; + + var item; + var attachment; + var group; + var groupItem; + var groupAttachment; + + before(async function () { + item = await createDataObject('item'); + attachment = await importFileAttachment('test.pdf', { parentID: item.id }); + + group = await getGroup(); + groupItem = await createDataObject('item', { libraryID: group.libraryID }); + groupAttachment = await importFileAttachment( + 'test.pdf', + { libraryID: group.libraryID, parentID: groupItem.id } + ); + }); + + describe("#toJSON()", function () { + it("should generate an object for a highlight", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.libraryID = attachment.libraryID; + annotation.key = exampleHighlight.key; + await annotation.loadPrimaryData(); + annotation.parentID = attachment.id; + annotation.annotationType = 'highlight'; + for (let prop of ['text', 'comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + annotation[itemProp] = exampleHighlight[prop]; + } + annotation.addTag("math"); + annotation.addTag("chemistry"); + await annotation.saveTx(); + await Zotero.Tags.setColor(annotation.libraryID, "math", "#ff0000", 0); + var json = Zotero.Annotations.toJSON(annotation); + + assert.sameMembers(Object.keys(json), Object.keys(exampleHighlight)); + for (let prop of Object.keys(exampleHighlight)) { + if (prop == 'dateModified') { + continue; + } + assert.deepEqual(json[prop], exampleHighlight[prop], `'${prop}' doesn't match`); + } + + await annotation.eraseTx(); + }); + + it("should generate an object for a note", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.libraryID = attachment.libraryID; + annotation.key = exampleNote.key; + await annotation.loadPrimaryData(); + annotation.parentID = attachment.id; + annotation.annotationType = 'note'; + for (let prop of ['comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + annotation[itemProp] = exampleNote[prop]; + } + await annotation.saveTx(); + var json = Zotero.Annotations.toJSON(annotation); + + assert.sameMembers(Object.keys(json), Object.keys(exampleNote)); + for (let prop of Object.keys(exampleNote)) { + if (prop == 'dateModified') { + continue; + } + assert.deepEqual(json[prop], exampleNote[prop], `'${prop}' doesn't match`); + } + + await annotation.eraseTx(); + }); + + it("should generate an object for an area", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.libraryID = attachment.libraryID; + annotation.key = exampleArea.key; + await annotation.loadPrimaryData(); + annotation.parentID = attachment.id; + annotation.annotationType = 'area'; + for (let prop of ['comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + annotation[itemProp] = exampleArea[prop]; + } + await annotation.saveTx(); + + // Get Blob from file and attach it + var path = OS.Path.join(getTestDataDirectory().path, 'test.png'); + var imageData = await Zotero.File.getBinaryContentsAsync(path); + var array = new Uint8Array(imageData.length); + for (let i = 0; i < imageData.length; i++) { + array[i] = imageData.charCodeAt(i); + } + var imageAttachment = await Zotero.Attachments.importEmbeddedImage({ + blob: new Blob([array], { type: 'image/png' }), + parentItemID: annotation.id + }); + + var json = Zotero.Annotations.toJSON(annotation); + + assert.sameMembers(Object.keys(json), Object.keys(exampleArea)); + for (let prop of Object.keys(exampleArea)) { + if (prop == 'imageURL' + || prop == 'dateModified') { + continue; + } + assert.deepEqual(json[prop], exampleArea[prop], `'${prop}' doesn't match`); + } + assert.equal(json.imageURL, `zotero://attachment/library/items/${imageAttachment.key}`); + + await annotation.eraseTx(); + }); + + it("should generate an object for a highlight by another user in a group library", async function () { + await Zotero.Users.setName(12345, 'Kate Smith'); + + var annotation = new Zotero.Item('annotation'); + annotation.libraryID = group.libraryID; + annotation.key = exampleGroupHighlight.key; + await annotation.loadPrimaryData(); + annotation.createdByUserID = 12345; + annotation.parentID = groupAttachment.id; + annotation.annotationType = 'highlight'; + for (let prop of ['text', 'comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + annotation[itemProp] = exampleGroupHighlight[prop]; + } + await annotation.saveTx(); + var json = Zotero.Annotations.toJSON(annotation); + + assert.isFalse(json.isAuthor); + assert.equal(json.authorName, 'Kate Smith'); + + await annotation.eraseTx(); + }); + }); + + + describe("#saveFromJSON()", function () { + it("should create an item from a highlight", async function () { + var annotation = await Zotero.Annotations.saveFromJSON(attachment, exampleHighlight); + + assert.equal(annotation.key, exampleHighlight.key); + for (let prop of ['text', 'comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + assert.deepEqual(annotation[itemProp], exampleHighlight[prop], `'${prop}' doesn't match`); + } + var itemTags = annotation.getTags().map(t => t.tag); + var jsonTags = exampleHighlight.tags.map(t => t.name); + assert.sameMembers(itemTags, jsonTags); + }); + + it("should create an item from a note", async function () { + var annotation = await Zotero.Annotations.saveFromJSON(attachment, exampleNote); + + assert.equal(annotation.key, exampleNote.key); + for (let prop of ['comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + assert.deepEqual(annotation[itemProp], exampleNote[prop], `'${prop}' doesn't match`); + } + }); + + it("should create an item from an area", async function () { + var annotation = await Zotero.Annotations.saveFromJSON(attachment, exampleArea); + + // Note: Image is created separately using Zotero.Attachments.importEmbeddedImage() + + assert.equal(annotation.key, exampleArea.key); + for (let prop of ['comment', 'color', 'pageLabel', 'sortIndex', 'position']) { + let itemProp = 'annotation' + prop[0].toUpperCase() + prop.substr(1); + assert.deepEqual(annotation[itemProp], exampleArea[prop], `'${prop}' doesn't match`); + } + }); + }); +}) \ No newline at end of file diff --git a/test/tests/itemTest.js b/test/tests/itemTest.js index 6fb0d18ca6..d2e9f94252 100644 --- a/test/tests/itemTest.js +++ b/test/tests/itemTest.js @@ -709,6 +709,27 @@ describe("Zotero.Item", function () { assert.equal(attachments[0], attachment.id); }) + it("should update after an attachment is moved to the trash", async function () { + var item = await createDataObject('item'); + var attachment = new Zotero.Item("attachment"); + attachment.parentID = item.id; + attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_IMPORTED_FILE; + await attachment.saveTx(); + + // Attachment should show up initially + var attachments = item.getAttachments(); + assert.lengthOf(attachments, 1); + assert.equal(attachments[0], attachment.id); + + // Move attachment to trash + attachment.deleted = true; + await attachment.saveTx(); + + // Attachment should not show up without includeTrashed=true + attachments = item.getAttachments(); + assert.lengthOf(attachments, 0); + }); + it("#should return an empty array for an item with no attachments", function* () { var item = yield createDataObject('item'); assert.lengthOf(item.getAttachments(), 0); @@ -1138,6 +1159,146 @@ describe("Zotero.Item", function () { }); + describe("Annotations", function () { + var item; + var attachment; + + before(async function () { + item = await createDataObject('item'); + attachment = await importFileAttachment('test.pdf', { parentID: item.id }); + }); + + describe("#annotationText", function () { + it("should not be changeable", async function () { + var a = new Zotero.Item('annotation'); + a.annotationType = 'highlight'; + assert.doesNotThrow(() => a.annotationType = 'highlight'); + assert.throws(() => a.annotationType = 'note'); + }); + }); + + describe("#annotationText", function () { + it("should only be allowed for highlights", async function () { + var a = new Zotero.Item('annotation'); + a.annotationType = 'highlight'; + assert.doesNotThrow(() => a.annotationText = "This is highlighted text."); + + a = new Zotero.Item('annotation'); + a.annotationType = 'note'; + assert.throws(() => a.annotationText = "This is highlighted text."); + + a = new Zotero.Item('annotation'); + a.annotationType = 'area'; + assert.throws(() => a.annotationText = "This is highlighted text."); + }); + }); + + describe("#saveTx()", function () { + it("should save a highlight annotation", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.parentID = attachment.id; + annotation.annotationType = 'highlight'; + annotation.annotationText = "This is highlighted text."; + annotation.annotationSortIndex = '000015|0002431|000000.000'; + annotation.annotationPosition = { + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }; + await annotation.saveTx(); + }); + + it("should save a note annotation", async function () { + var annotation = new Zotero.Item('annotation'); + annotation.parentID = attachment.id; + annotation.annotationType = 'note'; + annotation.annotationComment = "This is a comment."; + annotation.annotationSortIndex = '000015|0002431|000000.000'; + annotation.annotationPosition = { + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }; + await annotation.saveTx(); + }); + + it("should save an area annotation", async function () { + // Create a Blob from a PNG + var path = OS.Path.join(getTestDataDirectory().path, 'test.png'); + var imageData = await Zotero.File.getBinaryContentsAsync(path); + var array = new Uint8Array(imageData.length); + for (let i = 0; i < imageData.length; i++) { + array[i] = imageData.charCodeAt(i); + } + + var annotation = new Zotero.Item('annotation'); + annotation.parentID = attachment.id; + annotation.annotationType = 'area'; + annotation.annotationSortIndex = '000015|0002431|000000.000'; + annotation.annotationPosition = { + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ], + width: 1, + height: 1 + }; + await annotation.saveTx(); + + await Zotero.Attachments.importEmbeddedImage({ + blob: new Blob([array], { type: 'image/png' }), + parentItemID: annotation.id + }); + + var attachments = annotation.getAttachments(); + assert.lengthOf(attachments, 1); + var imageAttachment = Zotero.Items.get(attachments[0]); + var imagePath = await imageAttachment.getFilePathAsync(); + assert.ok(imagePath); + assert.equal(OS.Path.basename(imagePath), 'image.png'); + assert.equal( + await Zotero.File.getBinaryContentsAsync(imagePath), + imageData + ); + assert.equal(imageAttachment.attachmentContentType, 'image/png'); + + assert.equal( + annotation.annotationImageURL, + `zotero://attachment/library/items/${imageAttachment.key}` + ); + }); + }); + + describe("#getAnnotations()", function () { + var item; + var attachment; + var annotation1; + var annotation2; + + before(async function () { + item = await createDataObject('item'); + attachment = await importFileAttachment('test.pdf', { parentID: item.id }); + annotation1 = await createAnnotation('highlight', attachment); + annotation2 = await createAnnotation('highlight', attachment); + annotation2.deleted = true; + await annotation2.saveTx(); + }); + + it("should return ids of annotations not in trash", async function () { + var ids = attachment.getAnnotations(); + assert.sameMembers(ids, [annotation1.id]); + }); + + it("should return ids of annotations in trash if includeTrashed=true", async function () { + var ids = attachment.getAnnotations(true); + assert.sameMembers(ids, [annotation1.id, annotation2.id]); + }); + }); + }); + + describe("#setTags", function () { it("should save an array of tags in API JSON format", function* () { var tags = [