From b4522535e86dafa0648a932b428e899ec6b76ec3 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Fri, 19 Jun 2020 04:38:39 -0400 Subject: [PATCH 001/193] Add _hasFieldChanged()/_getChangedField/_getLatestField() to DataObject Make it easier to use the new `_changedData` approach to object modifications (currently used only for `tags` and `deleted`) where changed data is stored in a separate object rather than in the primary variables. _getLatestField() can be used to return either the new unsaved value or the current saved value. --- .../content/zotero/xpcom/data/dataObject.js | 20 ++++++++++++++++++- chrome/content/zotero/xpcom/data/item.js | 14 ++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/chrome/content/zotero/xpcom/data/dataObject.js b/chrome/content/zotero/xpcom/data/dataObject.js index ba9f7fe3e5..27db4e6e85 100644 --- a/chrome/content/zotero/xpcom/data/dataObject.js +++ b/chrome/content/zotero/xpcom/data/dataObject.js @@ -735,11 +735,26 @@ Zotero.DataObject.prototype._markAllDataTypeLoadStates = function (loaded) { } } +Zotero.DataObject.prototype._hasFieldChanged = function (field) { + return field in this._changedData; +}; + +Zotero.DataObject.prototype._getChangedField = function (field) { + return this._changedData[field]; +}; + /** * Get either the unsaved value of a field or the saved value if unchanged since the last save */ Zotero.DataObject.prototype._getLatestField = function (field) { - return this._changedData[field] !== undefined ? this._changedData[field] : this['_' + field]; + return this._changedData[field] || this['_' + field]; +}; + +/** + * Get either the unsaved value of a field or the saved value if unchanged since the last save + */ +Zotero.DataObject.prototype._getLatestField = function (field) { + return this._changedData[field] !== undefined ? this._changedData[field] : this['_' + field]; }; /** @@ -753,6 +768,9 @@ Zotero.DataObject.prototype._markFieldChange = function (field, value) { if (Array.isArray(value)) { this._changedData[field] = [...value]; } + else if (typeof value === 'object' && value !== null) { + this._changedData[field] = Object.assign({}, value); + } else { this._changedData[field] = value; } diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index 43b763a4c2..f55f6ba54d 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -1758,9 +1758,9 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) { } // Tags - if (this._changedData.tags) { + if (this._hasFieldChanged('tags')) { let oldTags = this._tags; - let newTags = this._changedData.tags; + let newTags = this._getChangedField('tags'); this._clearChanged('tags'); this._markForReload('tags'); @@ -3355,7 +3355,7 @@ Zotero.Item.prototype.clearBestAttachmentState = function () { Zotero.Item.prototype.getTags = function () { this._requireData('tags'); // BETTER DEEP COPY? - return JSON.parse(JSON.stringify(this._changedData.tags || this._tags)); + return JSON.parse(JSON.stringify(this._getLatestField('tags'))); }; @@ -3367,7 +3367,7 @@ Zotero.Item.prototype.getTags = function () { */ Zotero.Item.prototype.hasTag = function (tagName) { this._requireData('tags'); - var tags = this._changedData.tags || this._tags; + var tags = this._getLatestField('tags'); return tags.some(tagData => tagData.tag == tagName); } @@ -3377,7 +3377,7 @@ Zotero.Item.prototype.hasTag = function (tagName) { */ Zotero.Item.prototype.getTagType = function (tagName) { this._requireData('tags'); - var tags = this._changedData.tags || this._tags; + var tags = this._getLatestField('tags'); for (let tag of tags) { if (tag.tag === tagName) { return tag.type ? tag.type : 0; @@ -3397,7 +3397,7 @@ Zotero.Item.prototype.getTagType = function (tagName) { */ Zotero.Item.prototype.setTags = function (tags) { this._requireData('tags'); - var oldTags = this._changedData.tags || this._tags; + var oldTags = this._getLatestField('tags'); var newTags = tags.concat() // Allow array of strings .map(tag => typeof tag == 'string' ? { tag } : tag); @@ -3505,7 +3505,7 @@ Zotero.Item.prototype.replaceTag = function (oldTag, newTag) { */ Zotero.Item.prototype.removeTag = function(tagName) { this._requireData('tags'); - var oldTags = this._changedData.tags || this._tags; + var oldTags = this._getLatestField('tags'); var newTags = oldTags.filter(tagData => tagData.tag !== tagName); if (newTags.length == oldTags.length) { Zotero.debug('Cannot remove missing tag ' + tagName + ' from item ' + this.libraryKey); From 0941a14a6535b468edb616bc2dbe0dc12b06028b Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Sat, 20 Jun 2020 18:37:41 -0400 Subject: [PATCH 002/193] Remove 'lastsync' assignment from Zotero.Collection constructor --- chrome/content/zotero/xpcom/data/collection.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/chrome/content/zotero/xpcom/data/collection.js b/chrome/content/zotero/xpcom/data/collection.js index 83579a93b1..a6664bf5d0 100644 --- a/chrome/content/zotero/xpcom/data/collection.js +++ b/chrome/content/zotero/xpcom/data/collection.js @@ -31,8 +31,7 @@ Zotero.Collection = function(params = {}) { this._childCollections = new Set(); this._childItems = new Set(); - Zotero.Utilities.assignProps(this, params, ['name', 'libraryID', 'parentID', - 'parentKey', 'lastSync']); + Zotero.Utilities.assignProps(this, params, ['name', 'libraryID', 'parentID', 'parentKey']); } Zotero.extendClass(Zotero.DataObject, Zotero.Collection); From cca5dc03140224db8e4f8d1e4f529ead55c510cf Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Sun, 21 Jun 2020 02:29:54 -0400 Subject: [PATCH 003/193] Don't send unnecessary `format=json` in sync download requests --- .../zotero/xpcom/sync/syncAPIClient.js | 1 - test/tests/syncEngineTest.js | 59 +++++++++---------- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/chrome/content/zotero/xpcom/sync/syncAPIClient.js b/chrome/content/zotero/xpcom/sync/syncAPIClient.js index 3a499c7a86..5538dc9526 100644 --- a/chrome/content/zotero/xpcom/sync/syncAPIClient.js +++ b/chrome/content/zotero/xpcom/sync/syncAPIClient.js @@ -298,7 +298,6 @@ Zotero.Sync.APIClient.prototype = { target: objectTypePlural, libraryType: libraryType, libraryTypeID: libraryTypeID, - format: 'json' }; params[objectType + "Key"] = objectKeys.join(","); if (objectType == 'item') { diff --git a/test/tests/syncEngineTest.js b/test/tests/syncEngineTest.js index 542ba19398..00f8662805 100644 --- a/test/tests/syncEngineTest.js +++ b/test/tests/syncEngineTest.js @@ -244,7 +244,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/collections?format=json&collectionKey=AAAAAAAA", + url: "users/1/collections?collectionKey=AAAAAAAA", status: 200, headers: headers, json: [ @@ -257,7 +257,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/searches?format=json&searchKey=AAAAAAAA", + url: "users/1/searches?searchKey=AAAAAAAA", status: 200, headers: headers, json: [ @@ -270,7 +270,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/items?format=json&itemKey=AAAAAAAA&includeTrashed=1", + url: "users/1/items?itemKey=AAAAAAAA&includeTrashed=1", status: 200, headers: headers, json: [ @@ -284,7 +284,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/items?format=json&itemKey=BBBBBBBB&includeTrashed=1", + url: "users/1/items?itemKey=BBBBBBBB&includeTrashed=1", status: 200, headers: headers, json: [ @@ -418,7 +418,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `groups/${group.id}/collections?format=json&collectionKey=AAAAAAAA`, + url: `groups/${group.id}/collections?collectionKey=AAAAAAAA`, status: 200, headers: headers, json: [ @@ -431,7 +431,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `groups/${group.id}/searches?format=json&searchKey=AAAAAAAA`, + url: `groups/${group.id}/searches?searchKey=AAAAAAAA`, status: 200, headers: headers, json: [ @@ -444,7 +444,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `groups/${group.id}/items?format=json&itemKey=AAAAAAAA&includeTrashed=1`, + url: `groups/${group.id}/items?itemKey=AAAAAAAA&includeTrashed=1`, status: 200, headers: headers, json: [ @@ -458,7 +458,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `groups/${group.id}/items?format=json&itemKey=BBBBBBBB&includeTrashed=1`, + url: `groups/${group.id}/items?itemKey=BBBBBBBB&includeTrashed=1`, status: 200, headers: headers, json: [ @@ -1124,7 +1124,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `${target}/items?format=json&itemKey=${item.key}&includeTrashed=1`, + url: `${target}/items?itemKey=${item.key}&includeTrashed=1`, status: 200, headers, json: [itemResponseJSON] @@ -1590,7 +1590,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC", + url: "users/1/collections?collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC", status: 200, headers: headers, json: [ @@ -1617,7 +1617,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/searches?format=json&searchKey=DDDDDDDD%2CEEEEEEEE%2CFFFFFFFF", + url: "users/1/searches?searchKey=DDDDDDDD%2CEEEEEEEE%2CFFFFFFFF", status: 200, headers: headers, json: [ @@ -1663,7 +1663,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/items?format=json&itemKey=GGGGGGGG%2CHHHHHHHH&includeTrashed=1", + url: "users/1/items?itemKey=GGGGGGGG%2CHHHHHHHH&includeTrashed=1", status: 200, headers: headers, json: [ @@ -1686,7 +1686,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: "users/1/items?format=json&itemKey=JJJJJJJJ&includeTrashed=1", + url: "users/1/items?itemKey=JJJJJJJJ&includeTrashed=1", status: 200, headers: headers, json: [ @@ -2301,7 +2301,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`, + url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`, status: 200, headers, json: [ @@ -2356,7 +2356,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`, + url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`, status: 200, headers, json: [ @@ -2420,7 +2420,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${itemKey}&includeTrashed=1`, + url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`, status: 200, headers, json: [itemResponseJSON] @@ -2480,7 +2480,7 @@ describe("Zotero.Sync.Data.Engine", function () { }); setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=AAAAAAAA%2C${itemKey}&includeTrashed=1`, + url: `users/1/items?itemKey=AAAAAAAA%2C${itemKey}&includeTrashed=1`, status: 200, headers: headers, json: [ @@ -2754,7 +2754,7 @@ describe("Zotero.Sync.Data.Engine", function () { }; setResponse({ method: "GET", - url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC", + url: "users/1/collections?collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC", status: 200, headers, json: [ @@ -2795,7 +2795,7 @@ describe("Zotero.Sync.Data.Engine", function () { }; setResponse({ method: "GET", - url: `users/1/collections?format=json&collectionKey=${collectionKey}`, + url: `users/1/collections?collectionKey=${collectionKey}`, status: 200, headers, json: [ @@ -2839,7 +2839,7 @@ describe("Zotero.Sync.Data.Engine", function () { }; setResponse({ method: "GET", - url: `users/1/collections?format=json&collectionKey=${collectionKey}`, + url: `users/1/collections?collectionKey=${collectionKey}`, status: 200, headers, json: [ @@ -3386,7 +3386,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}` + url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}` + `&includeTrashed=1`, status: 200, headers: { @@ -3493,7 +3493,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}` + url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}` + `&includeTrashed=1`, status: 200, headers: { @@ -3609,7 +3609,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}` + url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}` + `&includeTrashed=1`, status: 200, headers: { @@ -3731,7 +3731,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}` + url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}` + `&includeTrashed=1`, status: 200, headers: { @@ -3817,7 +3817,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`, + url: `users/1/items?itemKey=${obj.key}&includeTrashed=1`, status: 200, headers: { "Last-Modified-Version": 15 @@ -3877,7 +3877,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`, + url: `users/1/items?itemKey=${obj.key}&includeTrashed=1`, status: 200, headers: { "Last-Modified-Version": 15 @@ -3932,7 +3932,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`, + url: `users/1/items?itemKey=${key}&includeTrashed=1`, status: 200, headers: { "Last-Modified-Version": 15 @@ -3987,7 +3987,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", - url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`, + url: `users/1/items?itemKey=${key}&includeTrashed=1`, status: 200, headers: { "Last-Modified-Version": 15 @@ -4241,8 +4241,7 @@ describe("Zotero.Sync.Data.Engine", function () { setResponse({ method: "GET", url: "users/1/" + plural - + "?format=json" - + "&" + type + "Key=" + objectJSON[type][0].key + "%2C" + objectJSON[type][1].key + + "?" + type + "Key=" + objectJSON[type][0].key + "%2C" + objectJSON[type][1].key + suffix, status: 200, headers: headers, From bb0a1dab1302106ed3750497d689b2a6953a337c Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Sun, 21 Jun 2020 02:18:06 -0400 Subject: [PATCH 004/193] Don't normalize primary data values in Item.setField() Just enforce proper data types --- chrome/content/zotero/xpcom/data/item.js | 32 +++++++++++++++--------- test/tests/dataObjectTest.js | 2 +- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index f55f6ba54d..601fe8b971 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -615,17 +615,6 @@ Zotero.Item.prototype.setField = function(field, value, loadIn) { throw new Error(`'${field}' value cannot be undefined`); } - // Normalize values - if (typeof value == 'number') { - value = "" + value; - } - else if (typeof value == 'string') { - value = value.trim().normalize(); - } - if (value === "" || value === null || value === false) { - value = false; - } - //Zotero.debug("Setting field '" + field + "' to '" + value + "' (loadIn: " + (loadIn ? 'true' : 'false') + ") for item " + this.id + " "); if (!field) { @@ -646,6 +635,9 @@ Zotero.Item.prototype.setField = function(field, value, loadIn) { switch (field) { case 'itemTypeID': + if (typeof value != 'number' || value != parseInt(value)) { + throw new Error(`${field} must be a number`); + } break; case 'dateAdded': @@ -664,10 +656,15 @@ Zotero.Item.prototype.setField = function(field, value, loadIn) { break; case 'version': - value = parseInt(value); + if (typeof value != 'number' || value != parseInt(value)) { + throw new Error(`${field} must be a number`); + } break; case 'synced': + if (typeof value != 'boolean') { + throw new Error(`${field} must be a boolean`); + } value = !!value; break; @@ -714,6 +711,17 @@ Zotero.Item.prototype.setField = function(field, value, loadIn) { return true; } + // Normalize values + if (typeof value == 'number') { + value = "" + value; + } + else if (typeof value == 'string') { + value = value.trim().normalize(); + } + if (value === "" || value === null || value === false) { + value = false; + } + if (!loadIn) { this._requireData('itemData'); } diff --git a/test/tests/dataObjectTest.js b/test/tests/dataObjectTest.js index 8b2c5a3a83..c6eb0f0582 100644 --- a/test/tests/dataObjectTest.js +++ b/test/tests/dataObjectTest.js @@ -141,7 +141,7 @@ describe("Zotero.DataObject", function() { var obj = createUnsavedDataObject(type); var id = yield obj.saveTx(); - obj.synced = 1; + obj.synced = true; yield obj.saveTx(); if (type == 'item') { From b54d4e78b718aa28ee8d3c6e2e9c7bb49fb497a0 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Sat, 20 Jun 2020 05:43:24 -0400 Subject: [PATCH 005/193] Save createdByUserID and lastModifiedByUserID for group items --- chrome/content/zotero/xpcom/data/item.js | 92 ++++++++++++------- chrome/content/zotero/xpcom/data/items.js | 6 +- chrome/content/zotero/xpcom/schema.js | 5 + .../content/zotero/xpcom/sync/syncEngine.js | 61 ++++++++++++ chrome/content/zotero/xpcom/sync/syncLocal.js | 19 +++- chrome/content/zotero/xpcom/users.js | 28 +++++- resource/schema/userdata.sql | 4 +- test/tests/syncEngineTest.js | 79 ++++++++++++++++ test/tests/syncLocalTest.js | 63 +++++++++++++ 9 files changed, 317 insertions(+), 40 deletions(-) diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index 601fe8b971..a7ff2ccc1b 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -38,6 +38,8 @@ Zotero.Item = function(itemTypeOrID) { // loadPrimaryData (additional properties in dataObject.js) this._itemTypeID = null; + this._createdByUserID = null; + this._lastModifiedByUserID = null; this._firstCreator = null; this._sortCreator = null; this._attachmentCharset = null; @@ -107,33 +109,19 @@ Zotero.defineProperty(Zotero.Item.prototype, 'itemID', { }, enumerable: false }); -Zotero.defineProperty(Zotero.Item.prototype, 'libraryID', { - get: function() { return this._libraryID; }, - set: function(val) { return this.setField('libraryID', val); } -}); -Zotero.defineProperty(Zotero.Item.prototype, 'key', { - get: function() { return this._key; }, - set: function(val) { return this.setField('key', val); } -}); + +for (let name of ['libraryID', 'key', 'dateAdded', 'dateModified', 'version', 'synced', + 'createdByUserID', 'lastModifiedByUserID']) { + let prop = '_' + name; + Zotero.defineProperty(Zotero.Item.prototype, name, { + get: function () { return this[prop]; }, + set: function (val) { return this.setField(name, val); } + }); +} + Zotero.defineProperty(Zotero.Item.prototype, 'itemTypeID', { get: function() { return this._itemTypeID; } }); -Zotero.defineProperty(Zotero.Item.prototype, 'dateAdded', { - get: function() { return this._dateAdded; }, - set: function(val) { return this.setField('dateAdded', val); } -}); -Zotero.defineProperty(Zotero.Item.prototype, 'dateModified', { - get: function() { return this._dateModified; }, - set: function(val) { return this.setField('dateModified', val); } -}); -Zotero.defineProperty(Zotero.Item.prototype, 'version', { - get: function() { return this._version; }, - set: function(val) { return this.setField('version', val); } -}); -Zotero.defineProperty(Zotero.Item.prototype, 'synced', { - get: function() { return this._synced; }, - set: function(val) { return this.setField('synced', val); } -}); // .parentKey and .parentID defined in dataObject.js, but create aliases Zotero.defineProperty(Zotero.Item.prototype, 'parentItemID', { @@ -335,6 +323,8 @@ Zotero.Item.prototype._parseRowData = function(row) { case 'attachmentSyncState': case 'attachmentSyncedHash': case 'attachmentSyncedModificationTime': + case 'createdByUserID': + case 'lastModifiedByUserID': break; case 'itemID': @@ -668,6 +658,19 @@ Zotero.Item.prototype.setField = function(field, value, loadIn) { value = !!value; break; + case 'createdByUserID': + case 'lastModifiedByUserID': + if (typeof value != 'number' || value != parseInt(value)) { + throw new Error(`${field} must be a number`); + } + if (!this._libraryID) { + throw new Error(`libraryID must be set before setting ${field}`); + } + if (Zotero.Libraries.get(this._libraryID).libraryType != 'group') { + throw new Error(`${field} is only valid for group library items`); + } + break; + default: throw new Error('Primary field ' + field + ' cannot be changed in Zotero.Item.setField()'); @@ -1297,6 +1300,12 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) { } } + if (this._changed.primaryData + && (this._changed.primaryData.createdByUserID || this._changed.primaryData.lastModifiedByUserID)) { + let sql = "REPLACE INTO groupItems VALUES (?, ?, ?)"; + yield Zotero.DB.queryAsync(sql, [itemID, this._createdByUserID || null, this._lastModifiedByUserID || null]); + } + // // ItemData // @@ -4783,13 +4792,6 @@ Zotero.Item.prototype.migrateExtraFields = function () { } -////////////////////////////////////////////////////////////////////////////// -// -// Asynchronous load methods -// -////////////////////////////////////////////////////////////////////////////// - - /** * Return an item in the specified library equivalent to this item * @@ -4816,6 +4818,34 @@ Zotero.Item.prototype.addLinkedItem = Zotero.Promise.coroutine(function* (item) }); + +/** + * Update createdByUserID/lastModifiedByUserID, efficiently + * + * Used by sync code + */ +Zotero.Item.prototype.updateCreatedByUser = async function (createdByUserID, lastModifiedByUserID) { + this._createdByUserID = createdByUserID || null; + this._lastModifiedByUserID = lastModifiedByUserID || null; + + var sql = "REPLACE INTO groupItems VALUES (?, ?, ?)"; + await Zotero.DB.queryAsync(sql, [this.id, this._createdByUserID, this._lastModifiedByUserID]); + + if (this._changed.primaryData) { + for (let x of ['createdByUserID', 'lastModifiedByUserID']) { + if (this._changed.primaryData[x]) { + if (Objects.keys(this._changed.primaryData).length == 1) { + delete this._changed.primaryData; + } + else { + delete this._changed.primaryData[x]; + } + } + } + } +}; + + ////////////////////////////////////////////////////////////////////////////// // // Private methods diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js index bfbf478dd1..be3d050c3b 100644 --- a/chrome/content/zotero/xpcom/data/items.js +++ b/chrome/content/zotero/xpcom/data/items.js @@ -49,6 +49,9 @@ Zotero.Items = function() { version: "O.version", synced: "O.synced", + createdByUserID: "createdByUserID", + lastModifiedByUserID: "lastModifiedByUserID", + firstCreator: _getFirstCreatorSQL(), sortCreator: _getSortCreatorSQL(), @@ -79,7 +82,8 @@ Zotero.Items = function() { + "LEFT JOIN items INoP ON (INo.parentItemID=INoP.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 charsets CS ON (IA.charsetID=CS.charsetID)" + + "LEFT JOIN groupItems GI ON (O.itemID=GI.itemID)"; this._relationsTable = "itemRelations"; diff --git a/chrome/content/zotero/xpcom/schema.js b/chrome/content/zotero/xpcom/schema.js index 54912f009e..84c3e2f578 100644 --- a/chrome/content/zotero/xpcom/schema.js +++ b/chrome/content/zotero/xpcom/schema.js @@ -3218,6 +3218,11 @@ Zotero.Schema = new function(){ yield Zotero.DB.queryAsync("CREATE INDEX deletedSearches_dateDeleted ON deletedSearches(dateDeleted)"); } + else if (i == 112) { + 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)"); + } + // If breaking compatibility or doing anything dangerous, clear minorUpdateFrom } diff --git a/chrome/content/zotero/xpcom/sync/syncEngine.js b/chrome/content/zotero/xpcom/sync/syncEngine.js index 80a24a8c3e..ed55c2f492 100644 --- a/chrome/content/zotero/xpcom/sync/syncEngine.js +++ b/chrome/content/zotero/xpcom/sync/syncEngine.js @@ -223,6 +223,15 @@ Zotero.Sync.Data.Engine.prototype.start = Zotero.Promise.coroutine(function* () skipNotifier: true }); + if (this.library.libraryType == 'group') { + try { + yield this._updateGroupItemUsers(); + } + catch (e) { + Zotero.logError(e); + } + } + Zotero.debug("Done syncing " + this.library.name); }); @@ -1428,6 +1437,58 @@ Zotero.Sync.Data.Engine.prototype._uploadDeletions = Zotero.Promise.coroutine(fu }); +/** + * Update createdByUserID/lastModifiedByUserID for previously downloaded group items + * + * TEMP: Currently only processes one batch of items, but before we start displaying the names, + * we'll need to update it to fetch all + */ +Zotero.Sync.Data.Engine.prototype._updateGroupItemUsers = async function () { + // TODO: Do more at once when we actually start showing these names + var max = this.apiClient.MAX_OBJECTS_PER_REQUEST; + + var sql = "SELECT key FROM items LEFT JOIN groupItems GI USING (itemID) " + + `WHERE libraryID=? AND GI.itemID IS NULL ORDER BY itemID LIMIT ${max}`; + var keys = await Zotero.DB.columnQueryAsync(sql, this.libraryID); + if (!keys.length) { + return; + } + + Zotero.debug(`Updating item users in ${this.library.name}`); + + var jsonItems = await this.apiClient.downloadObjects( + this.library.libraryType, this.libraryTypeID, 'item', keys + )[0]; + + if (!Array.isArray(jsonItems)) { + Zotero.logError(e); + return; + } + + for (let jsonItem of jsonItems) { + let item = Zotero.Items.getByLibraryAndKey(this.libraryID, jsonItem.key); + let params = [null, null]; + + // This should almost always exist, but maybe doesn't for some old items? + if (jsonItem.meta.createdByUser) { + let { id: userID, username, name } = jsonItem.meta.createdByUser; + await Zotero.Users.setName(userID, name !== '' ? name : username); + params[0] = userID; + } + + if (jsonItem.meta.lastModifiedByUser) { + let { id: userID, username, name } = jsonItem.meta.lastModifiedByUser; + await Zotero.Users.setName(userID, name !== '' ? name : username); + params[1] = userID; + } + + await item.updateCreatedByUser.apply(item, params); + } + + return; +}; + + Zotero.Sync.Data.Engine.prototype._getJSONForObject = function (objectType, id, options = {}) { return Zotero.DB.executeTransaction(function* () { var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); diff --git a/chrome/content/zotero/xpcom/sync/syncLocal.js b/chrome/content/zotero/xpcom/sync/syncLocal.js index 42eea1a61b..ccc3aa419a 100644 --- a/chrome/content/zotero/xpcom/sync/syncLocal.js +++ b/chrome/content/zotero/xpcom/sync/syncLocal.js @@ -1459,8 +1459,23 @@ Zotero.Sync.Data.Local = { if (!options.skipData) { obj.fromJSON(json.data, { strict: true }); } - if (obj.objectType == 'item' && obj.isImportedAttachment()) { - yield this._checkAttachmentForDownload(obj, json.data.mtime, options.isNewObject); + if (obj.objectType == 'item') { + // Update createdByUserID and lastModifiedByUserID + for (let p of ['createdByUser', 'lastModifiedByUser']) { + if (json.meta && json.meta[p]) { + let { id: userID, username, name } = json.meta[p]; + obj[p + 'ID'] = userID; + name = name !== '' ? name : username; + // Update stored name if it changed + if (Zotero.Users.getName(userID) != name) { + yield Zotero.Users.setName(userID, name); + } + } + } + + if (obj.isImportedAttachment()) { + yield this._checkAttachmentForDownload(obj, json.data.mtime, options.isNewObject); + } } obj.version = json.data.version; if (!options.saveAsUnsynced) { diff --git a/chrome/content/zotero/xpcom/users.js b/chrome/content/zotero/xpcom/users.js index 8eda0c91fc..4f3cb9dbb7 100644 --- a/chrome/content/zotero/xpcom/users.js +++ b/chrome/content/zotero/xpcom/users.js @@ -28,10 +28,11 @@ Zotero.Users = new function () { var _libraryID; var _username; var _localUserKey; + var _users = {}; - this.init = Zotero.Promise.coroutine(function* () { + this.init = async function () { let sql = "SELECT key, value FROM settings WHERE setting='account'"; - let rows = yield Zotero.DB.queryAsync(sql); + let rows = await Zotero.DB.queryAsync(sql); let settings = {}; for (let i=0; i Date: Sat, 20 Jun 2020 06:10:34 -0400 Subject: [PATCH 006/193] Remove old HTML snapshot annotation support Any data trapped in these tables hasn't been accessible in many years. --- chrome/content/zotero/xpcom/annotate.js | 1634 ----------------------- chrome/content/zotero/xpcom/schema.js | 3 + components/zotero-service.js | 1 - defaults/preferences/zotero.js | 3 - resource/schema/userdata.sql | 31 - 5 files changed, 3 insertions(+), 1669 deletions(-) delete mode 100644 chrome/content/zotero/xpcom/annotate.js diff --git a/chrome/content/zotero/xpcom/annotate.js b/chrome/content/zotero/xpcom/annotate.js deleted file mode 100644 index 638fc1b73c..0000000000 --- a/chrome/content/zotero/xpcom/annotate.js +++ /dev/null @@ -1,1634 +0,0 @@ -/* - ***** 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 . - - ***** END LICENSE BLOCK ***** -*/ - -const TEXT_TYPE = Components.interfaces.nsIDOMNode.TEXT_NODE; - -/** - * Globally accessible functions relating to annotations - * @namespace - */ -Zotero.Annotate = new function() { - var _annotated = {}; - - this.highlightColor = "#fff580"; - this.alternativeHighlightColor = "#555fa9"; - - /** - * Gets the pixel offset of an item from the top left of a page - * - * @param {Node} node DOM node to get the pixel offset of - * @param {Integer} offset Text offset - * @return {Integer[]} X and Y coordinates - */ - this.getPixelOffset = function(node, offset) { - var x = 0; - var y = 0; - - do { - x += node.offsetLeft; - y += node.offsetTop; - node = node.offsetParent; - } while(node); - - return [x, y]; - } - - /** - * Gets the annotation ID from a given URL - */ - this.getAnnotationIDFromURL = function(url) { - const attachmentRe = /^zotero:\/\/attachment\/([0-9]+)\/$/; - var m = attachmentRe.exec(url); - if (m) { - var id = m[1]; - var item = Zotero.Items.get(id); - var contentType = item.attachmentContentType; - var file = item.getFilePath(); - var ext = Zotero.File.getExtension(file); - if (contentType == 'text/plain' || !Zotero.MIME.hasNativeHandler(contentType, ext)) { - return false; - } - return id; - } - return false; - } - - /** - * Parses CSS/HTML color descriptions - * - * @return {Integer[]} An array of 3 values from 0 to 255 representing R, G, and B components - */ - this.parseColor = function(color) { - const rgbColorRe = /rgb\(([0-9]+), ?([0-9]+), ?([0-9]+)\)/i; - - var colorArray = rgbColorRe.exec(color); - if(colorArray) return [parseInt(colorArray[1]), parseInt(colorArray[2]), parseInt(colorArray[3])]; - - if(color[0] == "#") color = color.substr(1); - try { - colorArray = []; - for(var i=0; i<6; i+=2) { - colorArray.push(parseInt(color.substr(i, 2), 16)); - } - return colorArray; - } catch(e) { - throw new Error("Annotate: parseColor passed invalid color"); - } - } - - /** - * Gets the city block distance between two colors. Accepts colors in the format returned by - * Zotero.Annotate.parseColor() - * - * @param {Integer[]} color1 - * @param {Integer[]} color2 - * @return {Integer} The distance - */ - this.getColorDistance = function(color1, color2) { - color1 = this.parseColor(color1); - color2 = this.parseColor(color2); - - var distance = 0; - for(var i=0; i<3; i++) { - distance += Math.abs(color1[i] - color2[i]); - } - - return distance; - } - - /** - * Checks to see if a given item is already open for annotation - * - * @param {Integer} id An item ID - * @return {Boolean} - */ - this.isAnnotated = function(id) { - const XUL_NAMESPACE = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; - - var annotationURL = "zotero://attachment/"+id+"/"; - var haveBrowser = false; - - var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] - .getService(Components.interfaces.nsIWindowMediator); - var enumerator = wm.getEnumerator("navigator:browser"); - while(enumerator.hasMoreElements()) { - var win = enumerator.getNext(); - var tabbrowser = win.document.getElementsByTagNameNS(XUL_NAMESPACE, "tabbrowser"); - if(tabbrowser && tabbrowser.length) { - var browsers = tabbrowser[0].browsers; - } else { - var browsers = win.document.getElementsByTagNameNS(XUL_NAMESPACE, "browser"); - } - for (let browser of browsers) { - if(browser.currentURI) { - if(browser.currentURI.spec == annotationURL) { - if(haveBrowser) { - // require two with this URI - return true; - } else { - haveBrowser = true; - } - } - } - } - } - - return false; - } - - /** - * Sometimes, Firefox gives us a node offset inside another node, as opposed to a text offset - * This function replaces such offsets with references to the nodes themselves - * - * @param {Node} node DOM node - * @param {Integer} offset Node offset - * @return {Node} The DOM node after dereferencing has taken place - */ - this.dereferenceNodeOffset = function(node, offset) { - if(offset != 0) { - if(offset == node.childNodes.length) { - node = node.lastChild; - } else if(offset < node.childNodes.length) { - node = node.childNodes[offset]; - } else { - throw new Error("Annotate: dereferenceNodeOffset called with invalid offset "+offset); - } - if(!node) throw new Error("Annotate: dereferenceNodeOffset resolved to invalid node"); - } - - return node; - } - - /** - * Normalizes a DOM range, resolving it to a range that begins and ends at a text offset and - * remains unchanged when serialized to a Zotero.Annotate.Path object - * - * @param {Range} selectedRange The range to normalize - * @param {Function} nsResolver Namespace resolver function - * @return {Zotero.Annotate.Path[]} Start and end paths - */ - this.normalizeRange = function(selectedRange, nsResolver) { - var document = selectedRange.startContainer.ownerDocument; - - var container, offset; - if(selectedRange.startContainer.nodeType != TEXT_TYPE) { - [container, offset] = _getTextNode(selectedRange.startContainer, selectedRange.startOffset, true); - selectedRange.setStart(container, offset); - } - if(selectedRange.endContainer.nodeType != TEXT_TYPE) { - [container, offset] = _getTextNode(selectedRange.endContainer, selectedRange.endOffset); - selectedRange.setEnd(container, offset); - } - - var startPath = new Zotero.Annotate.Path(document, nsResolver); - var endPath = new Zotero.Annotate.Path(document, nsResolver); - startPath.fromNode(selectedRange.startContainer, selectedRange.startOffset); - endPath.fromNode(selectedRange.endContainer, selectedRange.endOffset); - - [container, offset] = startPath.toNode(); - selectedRange.setStart(container, offset); - [container, offset] = endPath.toNode(); - selectedRange.setEnd(container, offset); - - return [startPath, endPath]; - } - - /** - * Takes a node and finds the relevant text node inside of it - * - * @private - * @param {Node} container Node to get text node of - * @param {Integer} offset Node offset (see dereferenceNodeOffset) - * @param {Boolean} isStart Whether to treat this node as a start node. We look for the first - * text node from the start of start nodes, or the first from the end of end nodes - * @return {Array} The node and offset - */ - function _getTextNode(container, offset, isStart) { - var firstTarget = isStart ? "firstChild" : "lastChild"; - var secondTarget = isStart ? "nextSibling" : "previousSibling"; - - container = Zotero.Annotate.dereferenceNodeOffset(container, offset); - if(container.nodeType == TEXT_TYPE) return [container, 0]; - - var seenArray = new Array(); - var node = container; - while(node) { - if ( !node ) { - // uh-oh - break; - } - if(node.nodeType == TEXT_TYPE ) { - container = node; - break; - } - if( node[firstTarget] && ! _seen(node[firstTarget],seenArray)) { - var node = node[firstTarget]; - } else if( node[secondTarget] && ! _seen(node[secondTarget],seenArray)) { - var node = node[secondTarget]; - } else { - var node = node.parentNode; - } - } - return [container, (!isStart && container.nodeType == TEXT_TYPE ? container.nodeValue.length : 0)]; - } - - /** - * look for a node object in an array. return true if the node - * is found in the array. otherwise push the node onto the array - * and return false. used by _getTextNode. - */ - function _seen(node,array) { - var seen = false; - for (n in array) { - if (node === array[n]) { - var seen = true; - } - } - if ( !seen ) { - array.push(node); - } - return seen; - } -} - -/** - * Creates a new Zotero.Annotate.Path object from an XPath, text node index, and text offset - * - * @class A persistent descriptor for a point in the DOM, invariant to modifications of - * the DOM produced by highlights and annotations - * - * @property {String} parent XPath of parent node of referenced text node, or XPath of referenced - * element - * @property {Integer} textNode Index of referenced text node - * @property {Integer} offset Offset of referenced point inside text node - * - * @constructor - * @param {Document} document DOM document this path references - * @param {Function} nsResolver Namespace resolver (for XPaths) - * @param {String} parent (Optional) XPath of parent node - * @param {Integer} textNode (Optional) Text node number - * @param {Integer} offset (Optional) Text offset - */ -Zotero.Annotate.Path = function(document, nsResolver, parent, textNode, offset) { - if(parent !== undefined) { - this.parent = parent; - this.textNode = textNode; - this.offset = offset; - } - this._document = document; - this._nsResolver = nsResolver; -} - -/** - * Converts a DOM node/offset combination to a Zotero.Annotate.Path object - * - * @param {Node} node The DOM node to reference - * @param {Integer} offset The text offset, if the DOM node is a text node - */ -Zotero.Annotate.Path.prototype.fromNode = function(node, offset) { - if(!node) throw new Error("Annotate: Path() called with invalid node"); - Zotero.debug("Annotate: Path() called with node "+node.tagName+" offset "+offset); - - this.parent = ""; - this.textNode = null; - this.offset = (offset === 0 || offset ? offset : null); - - var lastWasTextNode = node.nodeType == TEXT_TYPE; - - if(!lastWasTextNode && offset) { - node = Zotero.Annotate.dereferenceNodeOffset(node, offset); - offset = 0; - lastWasTextNode = node.nodeType == TEXT_TYPE; - } - - if(node.parentNode.getAttribute && node.parentNode.getAttribute("zotero")) { - // if the selected point is inside a Zotero node node, add offsets of preceding - // text nodes - var first = false; - var sibling = node.previousSibling; - while(sibling) { - if(sibling.nodeType == TEXT_TYPE) this.offset += sibling.nodeValue.length; - sibling = sibling.previousSibling; - } - - // use parent node for future purposes - node = node.parentNode; - } else if(node.getAttribute && node.getAttribute("zotero")) { - // if selected point is a Zotero node, move it to last character of the previous node - node = node.previousSibling ? node.previousSibling : node.parentNode; - if(node.nodeType == TEXT_TYPE) { - this.offset = node.nodeValue.length; - lastWasTextNode = true; - } else { - this.offset = 0; - } - } - if(!node) throw new Error("Annotate: Path() handled Zotero inappropriately"); - - lastWasTextNode = lastWasTextNode || node.nodeType == TEXT_TYPE; - - if(lastWasTextNode) { - this.textNode = 1; - var first = true; - - var sibling = node.previousSibling; - while(sibling) { - var isZotero = (sibling.getAttribute ? sibling.getAttribute("zotero") : false); - - if(sibling.nodeType == TEXT_TYPE || - (isZotero == "highlight")) { - // is a text node - if(first == true) { - // is still part of the first text node - if(sibling.getAttribute) { - // get offset of all child nodes - for (let child of sibling.childNodes) { - if(child && child.nodeType == TEXT_TYPE) { - this.offset += child.nodeValue.length; - } - } - } else { - this.offset += sibling.nodeValue.length; - } - } else if(!lastWasTextNode) { - // is part of another text node - this.textNode++; - lastWasTextNode = true; - } - } else if(!isZotero) { // skip over annotation marker nodes - // is not a text node - lastWasTextNode = first = false; - } - - sibling = sibling.previousSibling; - } - - node = node.parentNode; - } - if(!node) throw new Error("Annotate: Path() resolved text offset inappropriately"); - - while(node && node !== this._document) { - var number = 1; - var sibling = node.previousSibling; - while(sibling) { - if(sibling.tagName) { - if(sibling.tagName == node.tagName && !sibling.hasAttribute("zotero")) number++; - } else { - if(sibling.nodeType == node.nodeType) number++; - } - sibling = sibling.previousSibling; - } - - // don't add highlight nodes - if(node.tagName) { - var tag = node.tagName.toLowerCase(); - if(tag == "span") { - tag += "[not(@zotero)]"; - } - this.parent = "/"+tag+"["+number+"]"+this.parent; - } else if(node.nodeType == Components.interfaces.nsIDOMNode.COMMENT_NODE) { - this.parent = "/comment()["+number+"]"; - } else if(node.nodeType == Components.interfaces.nsIDOMNode.TEXT_NODE) { - Zotero.debug("Annotate: Path() referenced a text node; this should never happen"); - this.parent = "/text()["+number+"]"; - } else { - Zotero.debug("Annotate: Path() encountered unrecognized node type"); - } - - node = node.parentNode; - } - - Zotero.debug("Annotate: got path "+this.parent+", "+this.textNode+", "+this.offset); -} - -/** - * Converts a Zotero.Annotate.Path object to a DOM/offset combination - * - * @return {Array} Node and offset - */ -Zotero.Annotate.Path.prototype.toNode = function() { - Zotero.debug("toNode on "+this.parent+" "+this.textNode+", "+this.offset); - - var offset = 0; - - // try to evaluate parent - try { - var node = this._document.evaluate(this.parent, this._document, this._nsResolver, - Components.interfaces.nsIDOMXPathResult.ANY_TYPE, null).iterateNext(); - } catch(e) { - Zotero.debug("Annotate: could not find XPath "+this.parent+" in Path.toNode()"); - return [false, false]; - } - - // don't do further processing if this path does not refer to a text node - if(!this.textNode) return [node, offset]; - - // parent node must have children if we have a text node index - if(!node.hasChildNodes()) { - Zotero.debug("Annotate: Parent node has no child nodes, but a text node was specified"); - return [false, false]; - } - - node = node.firstChild; - offset = this.offset; - var lastWasTextNode = false; - var number = 0; - - // find text node - while(true) { - var isZotero = undefined; - if(node.getAttribute) isZotero = node.getAttribute("zotero"); - - if(node.nodeType == TEXT_TYPE || - isZotero == "highlight") { - if(!lastWasTextNode) { - number++; - - // if we found the node we're looking for, break - if(number == this.textNode) break; - - lastWasTextNode = true; - } - } else if(!isZotero) { - lastWasTextNode = false; - } - - node = node.nextSibling; - // if there's no node, this point is invalid - if(!node) { - Zotero.debug("Annotate: reached end of node list while searching for text node "+this.textNode+" of "+this.parent); - return [false, false]; - } - } - - // find offset - while(true) { - // get length of enclosed text node - if(node.getAttribute) { - // this is a highlighted node; loop through and subtract all - // offsets, breaking if we reach the end - var parentNode = node; - node = node.firstChild; - while(node) { - if(node.nodeType == TEXT_TYPE) { - // break if end condition reached - if(node.nodeValue.length >= offset) return [node, offset]; - // otherwise, continue subtracting offsets - offset -= node.nodeValue.length; - } - node = node.nextSibling; - } - // restore parent node - node = parentNode; - } else { - // this is not a highlighted node; use simple node length - if(node.nodeValue.length >= offset) return [node, offset]; - offset -= node.nodeValue.length; - } - - // get next node - node = node.nextSibling; - // if next node does not exist or is not a text node, this - // point is invalid - if(!node || (node.nodeType != TEXT_TYPE && (!node.getAttribute || !node.getAttribute("zotero")))) { - Zotero.debug("Annotate: could not find offset "+this.offset+" for text node "+this.textNode+" of "+this.parent); - return [false, false]; - } - } -} - -/** - * Creates a new Zotero.Annotations object - * @class Manages all annotations and highlights for a given item - * - * @constructor - * @param {Zotero_Browser} Zotero_Browser object for the tab in which this item is loaded - * @param {Browser} Mozilla Browser object - * @param {Integer} itemID ID of the item to be annotated/highlighted - */ -Zotero.Annotations = function(Zotero_Browser, browser, itemID) { - this.Zotero_Browser = Zotero_Browser; - this.browser = browser; - this.document = browser.contentDocument; - this.window = browser.contentWindow; - this.nsResolver = this.document.createNSResolver(this.document.documentElement); - - this.itemID = itemID; - - this.annotations = new Array(); - this.highlights = new Array(); - - this.zIndex = 9999; -} - -/** - * Creates a new annotation at the cursor position - * @return {Zotero.Annotation} - */ -Zotero.Annotations.prototype.createAnnotation = function() { - var annotation = new Zotero.Annotation(this); - this.annotations.push(annotation); - return annotation; -} - -/** - * Highlights text - * - * @param {Range} selectedRange Range to highlight - * @return {Zotero.Highlight} - */ -Zotero.Annotations.prototype.highlight = function(selectedRange) { - var startPath, endPath; - [startPath, endPath] = Zotero.Annotate.normalizeRange(selectedRange, this.nsResolver); - - var deleteHighlights = new Array(); - var startIn = false, endIn = false; - - // first, see if part of this range is already - for(var i in this.highlights) { - var compareHighlight = this.highlights[i]; - var compareRange = compareHighlight.getRange(); - - var startToStart = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.START_TO_START, selectedRange); - var endToEnd = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.END_TO_END, selectedRange); - if(startToStart != 1 && endToEnd != -1) { - // if the selected range is inside this one - return compareHighlight; - } else if(startToStart != -1 && endToEnd != 1) { - // if this range is inside selected range, delete - delete this.highlights[i]; - } else { - var endToStart = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.END_TO_START, selectedRange); - if(endToStart != 1 && endToEnd != -1) { - // if the end of the selected range is between the start and - // end of this range - var endIn = i; - } else { - var startToEnd = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.START_TO_END, selectedRange); - if(startToEnd != -1 && startToStart != 1) { - // if the start of the selected range is between the - // start and end of this range - var startIn = i; - } - } - } - } - - if(startIn !== false || endIn !== false) { - // starts in and ends in existing highlights - if(startIn !== false) { - var highlight = this.highlights[startIn]; - startRange = highlight.getRange(); - selectedRange.setStart(startRange.startContainer, startRange.startOffset); - startPath = highlight.startPath; - } else { - var highlight = this.highlights[endIn]; - } - - if(endIn !== false) { - endRange = this.highlights[endIn].getRange(); - selectedRange.setEnd(endRange.endContainer, endRange.endOffset); - endPath = this.highlights[endIn].endPath; - } - - // if bridging ranges, delete end range - if(startIn !== false && endIn !== false) { - delete this.highlights[endIn]; - } - } else { - // need to create a new highlight - var highlight = new Zotero.Highlight(this); - this.highlights.push(highlight); - } - - // actually generate ranges - highlight.initWithRange(selectedRange, startPath, endPath); - - //for(var i in this.highlights) Zotero.debug(i+" = "+this.highlights[i].startPath.offset+" to "+this.highlights[i].endPath.offset+" ("+this.highlights[i].startPath.parent+" to "+this.highlights[i].endPath.parent+")"); - return highlight; -} - -/** - * Unhighlights text - * - * @param {Range} selectedRange Range to unhighlight - */ -Zotero.Annotations.prototype.unhighlight = function(selectedRange) { - var startPath, endPath, node, offset; - [startPath, endPath] = Zotero.Annotate.normalizeRange(selectedRange, this.nsResolver); - - // first, see if part of this range is already highlighted - for(var i in this.highlights) { - var updateStart = false; - var updateEnd = false; - - var compareHighlight = this.highlights[i]; - var compareRange = compareHighlight.getRange(); - - var startToStart = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.START_TO_START, selectedRange); - var endToEnd = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.END_TO_END, selectedRange); - - if(startToStart == -1 && endToEnd == 1) { - // need to split range into two highlights - var compareEndPath = compareHighlight.endPath; - - // this will unhighlight the entire end - compareHighlight.unhighlight(selectedRange.startContainer, selectedRange.startOffset, - startPath, Zotero.Highlight.UNHIGHLIGHT_FROM_POINT); - var newRange = this.document.createRange(); - - // need to use point references because they disregard highlights - [node, offset] = endPath.toNode(); - newRange.setStart(node, offset); - [node, offset] = compareEndPath.toNode(); - newRange.setEnd(node, offset); - - // create new node - var highlight = new Zotero.Highlight(this); - highlight.initWithRange(newRange, endPath, compareEndPath); - this.highlights.push(highlight); - break; - } else if(startToStart != -1 && endToEnd != 1) { - // if this range is inside selected range, delete - compareHighlight.unhighlight(null, null, null, Zotero.Highlight.UNHIGHLIGHT_ALL); - delete this.highlights[i]; - updateEnd = updateStart = true; - } else if(startToStart == -1) { - var startToEnd = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.START_TO_END, selectedRange); - if(startToEnd != -1) { - // if the start of the selected range is between the start and end of this range - compareHighlight.unhighlight(selectedRange.startContainer, selectedRange.startOffset, - startPath, Zotero.Highlight.UNHIGHLIGHT_FROM_POINT); - updateEnd = true; - } - } else { - var endToStart = compareRange.compareBoundaryPoints(Components.interfaces.nsIDOMRange.END_TO_START, selectedRange); - if(endToStart != 1) { - // if the end of the selected range is between the start and end of this range - compareHighlight.unhighlight(selectedRange.endContainer, selectedRange.endOffset, - endPath, Zotero.Highlight.UNHIGHLIGHT_TO_POINT); - updateStart = true; - } - } - - // need to update start and end parts of ranges if spans have shifted around - if(updateStart) { - [node, offset] = startPath.toNode(); - selectedRange.setStart(node, offset); - } - if(updateEnd) { - [node, offset] = endPath.toNode(); - selectedRange.setEnd(node, offset); - } - } - - //for(var i in this.highlights) Zotero.debug(i+" = "+this.highlights[i].startPath.offset+" to "+this.highlights[i].endPath.offset+" ("+this.highlights[i].startPath.parent+" to "+this.highlights[i].endPath.parent+")"); -} - -/** - * Refereshes display of annotations (useful if page is reloaded) - */ -Zotero.Annotations.prototype.refresh = function() { - for (let annotation of this.annotations) { - annotation.display(); - } -} - -/** - * Saves annotations to DB - */ -Zotero.Annotations.prototype.save = function() { - Zotero.DB.beginTransaction(); - try { - Zotero.DB.query("DELETE FROM highlights WHERE itemID = ?", [this.itemID]); - - // save highlights - for (let highlight of this.highlights) { - if(highlight) highlight.save(); - } - - // save annotations - for (let annotation of this.annotations) { - // Don't drop all annotations if one is broken (due to ~3.0 glitch) - try { - annotation.save(); - } - catch(e) { - Zotero.debug(e); - continue; - } - } - Zotero.DB.commitTransaction(); - } catch(e) { - Zotero.debug(e); - Zotero.DB.rollbackTransaction(); - throw(e); - } -} - -/** - * Loads annotations from DB - */ -Zotero.Annotations.prototype.load = Zotero.Promise.coroutine(function* () { - // load annotations - var rows = yield Zotero.DB.queryAsync("SELECT * FROM annotations WHERE itemID = ?", [this.itemID]); - for (let row of rows) { - var annotation = this.createAnnotation(); - annotation.initWithDBRow(row); - } - - // load highlights - var rows = yield Zotero.DB.queryAsync("SELECT * FROM highlights WHERE itemID = ?", [this.itemID]); - for (let row of rows) { - try { - var highlight = new Zotero.Highlight(this); - highlight.initWithDBRow(row); - this.highlights.push(highlight); - } catch(e) { - Zotero.debug("Annotate: could not load highlight"); - } - } -}); - -/** - * Expands annotations if any are collapsed, or collapses highlights if all are expanded - */ -Zotero.Annotations.prototype.toggleCollapsed = function() { - // look to see if there are any collapsed annotations - var status = true; - for (let annotation of this.annotations) { - if(annotation.collapsed) { - status = false; - break; - } - } - - // set status on all annotations - for (let annotation of this.annotations) { - annotation.setCollapsed(status); - } -} - -/** - * @class Represents an individual annotation - * - * @constructor - * @property {Boolean} collapsed Whether this annotation is collapsed (minimized) - * @param {Zotero.Annotations} annotationsObj The Zotero.Annotations object corresponding to the - * page this annotation is on - */ -Zotero.Annotation = function(annotationsObj) { - this.annotationsObj = annotationsObj; - this.window = annotationsObj.browser.contentWindow; - this.document = annotationsObj.browser.contentDocument; - this.nsResolver = annotationsObj.nsResolver; - this.cols = 30; - this.rows = 5; -} - -/** - * Generates annotation from a click event - * - * @param {Event} e The DOM click event - */ -Zotero.Annotation.prototype.initWithEvent = function(e) { - var maxOffset = false; - - try { - var range = this.window.getSelection().getRangeAt(0); - this.node = range.startContainer; - var offset = range.startOffset; - if(this.node.nodeValue) maxOffset = this.node.nodeValue.length; - } catch(err) { - this.node = e.target; - var offset = 0; - } - - var clickX = this.window.pageXOffset + e.clientX; - var clickY = this.window.pageYOffset + e.clientY; - - var isTextNode = (this.node.nodeType == Components.interfaces.nsIDOMNode.TEXT_NODE); - - if(offset == 0 || !isTextNode) { - // tag by this.offset from parent this.node, rather than text - if(isTextNode) this.node = this.node.parentNode; - offset = 0; - } - - if(offset) this._generateMarker(offset); - - var pixelOffset = Zotero.Annotate.getPixelOffset(this.node); - this.x = clickX - pixelOffset[0]; - this.y = clickY - pixelOffset[1]; - this.collapsed = false; - - Zotero.debug("Annotate: added new annotation"); - - this.displayWithAbsoluteCoordinates(clickX, clickY, true); -} - -/** - * Generates annotation from a DB row - * - * @param {Object} row The DB row - */ -Zotero.Annotation.prototype.initWithDBRow = function(row) { - var path = new Zotero.Annotate.Path(this.document, this.nsResolver, row.parent, row.textNode, row.offset); - [node, offset] = path.toNode(); - if(!node) { - Zotero.debug("Annotate: could not load annotation "+row.annotationID+" from DB"); - return; - } - this.node = node; - if(offset) this._generateMarker(offset); - - this.x = row.x; - this.y = row.y; - this.cols = row.cols; - this.rows = row.rows; - this.annotationID = row.annotationID; - this.collapsed = !!row.collapsed; - - this.display(); - - var me = this; - this.iframe.addEventListener("load", function() { me.textarea.value = row.text }, false); -} - -/** - * Saves annotation to DB - */ -Zotero.Annotation.prototype.save = function() { - var text = this.textarea.value; - - // fetch marker location - if(this.node.getAttribute && this.node.getAttribute("zotero") == "annotation-marker") { - var node = this.node.previousSibling; - - if(node.nodeType != Components.interfaces.nsIDOMNode.TEXT_NODE) { - // someone added a highlight around this annotation - node = node.lastChild; - } - var offset = node.nodeValue.length; - } else { - var node = this.node; - var offset = 0; - } - - // fetch path to node - var path = new Zotero.Annotate.Path(this.document, this.nsResolver); - path.fromNode(node, offset); - - var parameters = [ - this.annotationsObj.itemID, // itemID - path.parent, // parent - path.textNode, // textNode - path.offset, // offset - this.x, // x - this.y, // y - this.cols, // cols - this.rows, // rows - text, // text - (this.collapsed ? 1 : 0) // collapsed - ]; - - if(this.annotationID) { - var query = "INSERT OR REPLACE INTO annotations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATETIME('now'))"; - parameters.unshift(this.annotationID); - } else { - var query = "INSERT INTO annotations VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATETIME('now'))"; - } - - Zotero.DB.query(query, parameters); -} - -/** - * Displays annotation - */ -Zotero.Annotation.prototype.display = function() { - if(!this.node) throw new Error("Annotation not initialized!"); - - var x = 0, y = 0; - - // first fetch the coordinates - var pixelOffset = Zotero.Annotate.getPixelOffset(this.node); - - var x = pixelOffset[0] + this.x; - var y = pixelOffset[1] + this.y; - - // then display - this.displayWithAbsoluteCoordinates(x, y); -} - -/** - * Displays annotation given absolute coordinates for its position - */ -Zotero.Annotation.prototype.displayWithAbsoluteCoordinates = function(absX, absY, select) { - if(!this.node) throw new Error("Annotation not initialized!"); - - var startScroll = this.window.scrollMaxX; - - if(!this.iframe) { - var me = this; - var body = this.document.getElementsByTagName("body")[0]; - - const style = "position: absolute; margin: 0; padding: 0; border: none; overflow: hidden; "; - - // generate regular div - this.iframe = this.document.createElement("iframe"); - this.iframe.setAttribute("zotero", "annotation"); - this.iframe.setAttribute("style", style+" -moz-opacity: 0.9;"); - this.iframe.setAttribute("src", "zotero://attachment/annotation.html"); - body.appendChild(this.iframe); - this.iframe.addEventListener("load", function() { - me._addChildElements(select); - me.iframe.style.display = (me.collapsed ? "none" : "block"); - }, false); - - // generate pushpin image - this.pushpinDiv = this.document.createElement("img"); - this.pushpinDiv.setAttribute("style", style+" cursor: pointer;"); - this.pushpinDiv.setAttribute("src", "zotero://attachment/annotation-hidden.gif"); - this.pushpinDiv.setAttribute("title", Zotero.getString("annotations.expand.tooltip")); - body.appendChild(this.pushpinDiv); - this.pushpinDiv.style.display = (this.collapsed ? "block" : "none"); - this.pushpinDiv.addEventListener("click", function() { me.setCollapsed(false) }, false); - } - this.iframe.style.left = this.pushpinDiv.style.left = absX+"px"; - this.iframeX = absX; - this.iframe.style.top = this.pushpinDiv.style.top = absY+"px"; - this.iframeY = absY; - this.pushpinDiv.style.zIndex = this.iframe.style.zIndex = this.annotationsObj.zIndex; - - // move to the left if we're making things scroll - if(absX + this.iframe.scrollWidth > this.window.innerWidth) { - this.iframe.style.left = (absX-this.iframe.scrollWidth)+"px"; - this.iframeX = absX-this.iframe.scrollWidth; - } -} - -/** - * Collapses or uncollapses annotation - * - * @param {Boolean} status True to collapse, false to uncollapse - */ -Zotero.Annotation.prototype.setCollapsed = function(status) { - if(status == true) { // hide iframe - this.iframe.style.display = "none"; - this.pushpinDiv.style.display = "block"; - this.collapsed = true; - } else { // hide pushpin div - this.pushpinDiv.style.display = "none"; - this.iframe.style.display = "block"; - this.collapsed = false; - } -} - -/** - * Generates a marker within a paragraph for this annotation. Such markers will remain in place - * even if the DOM is changed, e.g., by highlighting - * - * @param {Integer} offset Text offset within parent node - * @private - */ -Zotero.Annotation.prototype._generateMarker = function(offset) { - // first, we create a new span at the correct offset in the node - var range = this.document.createRange(); - range.setStart(this.node, offset); - range.setEnd(this.node, offset); - - // next, we delete the old node, if there is one - if(this.node && this.node.getAttribute && this.node.getAttribute("zotero") == "annotation-marker") { - this.node.parentNode.removeChild(this.node); - this.node = undefined; - } - - // next, we insert a span - this.node = this.document.createElement("span"); - this.node.setAttribute("zotero", "annotation-marker"); - range.insertNode(this.node); -} - -/** - * Prepare iframe representing this annotation - * - * @param {Boolean} select Whether to select the textarea once iframe is prepared - * @private - */ -Zotero.Annotation.prototype._addChildElements = function(select) { - var me = this; - this.iframeDoc = this.iframe.contentDocument; - - // close - var img = this.iframeDoc.getElementById("close"); - img.title = Zotero.getString("annotations.close.tooltip"); - img.addEventListener("click", function(e) { me._confirmDelete(e) }, false); - - // move - this.moveImg = this.iframeDoc.getElementById("move"); - this.moveImg.title = Zotero.getString("annotations.move.tooltip"); - this.moveImg.addEventListener("click", function(e) { me._startMove(e) }, false); - - // hide - img = this.iframeDoc.getElementById("collapse"); - img.title = Zotero.getString("annotations.collapse.tooltip"); - img.addEventListener("click", function(e) { me.setCollapsed(true) }, false); - - // collapse - this.grippyDiv = this.iframeDoc.getElementById("grippy"); - this.grippyDiv.addEventListener("mousedown", function(e) { me._startDrag(e) }, false); - - // text area - this.textarea = this.iframeDoc.getElementById("text"); - this.textarea.setAttribute("zotero", "annotation"); - this.textarea.cols = this.cols; - this.textarea.rows = this.rows; - - this.iframe.style.width = (6+this.textarea.offsetWidth)+"px"; - this.iframe.style.height = this.iframeDoc.body.offsetHeight+"px"; - this.iframeDoc.addEventListener("click", function() { me._click() }, false); - - if(select) this.textarea.select(); -} - -/** - * Brings annotation to the foreground - * @private - */ -Zotero.Annotation.prototype._click = function() { - // clear current action - this.annotationsObj.Zotero_Browser.toggleMode(null); - - // alter z-index - this.annotationsObj.zIndex++ - this.iframe.style.zIndex = this.pushpinDiv.style.zIndex = this.annotationsObj.zIndex; -} - -/** - * Asks user to confirm deletion of this annotation - * @private - */ -Zotero.Annotation.prototype._confirmDelete = function(event) { - if (this.textarea.value == '' || !Zotero.Prefs.get('annotations.warnOnClose')) { - var del = true; - } else { - var promptService = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] - .getService(Components.interfaces.nsIPromptService); - - var dontShowAgain = { value: false }; - var del = promptService.confirmCheck( - this.window, - Zotero.getString('annotations.confirmClose.title'), - Zotero.getString('annotations.confirmClose.body'), - Zotero.getString('general.dontShowWarningAgain'), - dontShowAgain - ); - - if (dontShowAgain.value) { - Zotero.Prefs.set('annotations.warnOnClose', false); - } - } - - if(del) this._delete(); -} - -/** - * Deletes this annotation - * @private - */ -Zotero.Annotation.prototype._delete = function() { - if(this.annotationID) { - Zotero.DB.query("DELETE FROM annotations WHERE annotationID = ?", [this.annotationID]); - } - - // hide div - this.iframe.parentNode.removeChild(this.iframe); - // delete from list - for(var i in this.annotationsObj.annotations) { - if(this.annotationsObj.annotations[i] == this) { - this.annotationsObj.annotations.splice(i, 1); - } - } -} - -/** - * Called to begin resizing the annotation - * - * @param {Event} e DOM event corresponding to click on the grippy - * @private - */ -Zotero.Annotation.prototype._startDrag = function(e) { - var me = this; - - this.clickStartX = e.screenX; - this.clickStartY = e.screenY; - this.clickStartCols = this.textarea.cols; - this.clickStartRows = this.textarea.rows; - - /** - * Listener to handle mouse moves - * @inner - */ - var handleDrag = function(e) { me._doDrag(e); }; - this.iframeDoc.addEventListener("mousemove", handleDrag, false); - this.document.addEventListener("mousemove", handleDrag, false); - - /** - * Listener to call when mouse is let up - * @inner - */ - var endDrag = function() { - me.iframeDoc.removeEventListener("mousemove", handleDrag, false); - me.document.removeEventListener("mousemove", handleDrag, false); - me.iframeDoc.removeEventListener("mouseup", endDrag, false); - me.document.removeEventListener("mouseup", endDrag, false); - me.dragging = false; - } - this.iframeDoc.addEventListener("mouseup", endDrag, false); - this.document.addEventListener("mouseup", endDrag, false); - - // stop propagation - e.stopPropagation(); - e.preventDefault(); -} - -/** - * Called when mouse is moved while annotation is being resized - * - * @param {Event} e DOM event corresponding to mouse move - * @private - */ -Zotero.Annotation.prototype._doDrag = function(e) { - var x = e.screenX - this.clickStartX; - var y = e.screenY - this.clickStartY; - - // update sizes - var colSize = this.textarea.clientWidth/this.textarea.cols; - var rowSize = this.textarea.clientHeight/this.textarea.rows; - - // update cols and rows - var cols = this.clickStartCols+Math.floor(x/colSize); - cols = (cols > 5 ? cols : 5); - this.textarea.cols = this.cols = cols; - - var rows = this.clickStartRows+Math.floor(y/rowSize); - rows = (rows > 2 ? rows : 2); - this.textarea.rows = this.rows = rows; - - this.iframe.style.width = (6+this.textarea.offsetWidth)+"px"; - this.iframe.style.height = this.iframe.contentDocument.body.offsetHeight+"px"; -} - -/** - * Called to begin moving the annotation - * - * @param {Event} e DOM event corresponding to click on the grippy - * @private - */ -Zotero.Annotation.prototype._startMove = function(e) { - // stop propagation - e.stopPropagation(); - e.preventDefault(); - - var body = this.document.getElementsByTagName("body")[0]; - - // deactivate current action - this.annotationsObj.Zotero_Browser.toggleMode(null); - - var me = this; - // set the handler required to deactivate - - /** - * Callback to end move action - * @inner - */ - this.annotationsObj.clearAction = function() { - me.document.removeEventListener("click", me._handleMove, false); - body.style.cursor = "auto"; - me.moveImg.src = "zotero://attachment/annotation-move.png"; - me.annotationsObj.clearAction = undefined; - } - - /** - * Listener to handle mouse moves on main page - * @inner - */ - var handleMoveMouse1 = function(e) { - me.displayWithAbsoluteCoordinates(e.pageX + 1, e.pageY + 1); - }; - /** - * Listener to handle mouse moves in iframe - * @inner - */ - var handleMoveMouse2 = function(e) { - me.displayWithAbsoluteCoordinates(e.pageX + me.iframeX + 1, e.pageY + me.iframeY + 1); - }; - this.document.addEventListener("mousemove", handleMoveMouse1, false); - this.iframeDoc.addEventListener("mousemove", handleMoveMouse2, false); - - /** - * Listener to finish off move when a click is made - * @inner - */ - var handleMove = function(e) { - me.document.removeEventListener("mousemove", handleMoveMouse1, false); - me.iframeDoc.removeEventListener("mousemove", handleMoveMouse2, false); - me.document.removeEventListener("click", handleMove, false); - - me.initWithEvent(e); - me.annotationsObj.clearAction(); - - // stop propagation - e.stopPropagation(); - e.preventDefault(); - }; - this.document.addEventListener("click", handleMove, false); - - body.style.cursor = "pointer"; - this.moveImg.src = "zotero://attachment/annotation-move-selected.png"; -} - -/** - * @class Represents an individual highlighted range - * - * @constructor - * @param {Zotero.Annotations} annotationsObj The Zotero.Annotations object corresponding to the - * page this highlight is on - */ -Zotero.Highlight = function(annotationsObj) { - this.annotationsObj = annotationsObj; - this.window = annotationsObj.browser.contentWindow; - this.document = annotationsObj.browser.contentDocument; - this.nsResolver = annotationsObj.nsResolver; - - this.spans = new Array(); -} - -/** - * Gets the highlighted DOM range - * @return {Range} DOM range - */ -Zotero.Highlight.prototype.getRange = function() { - this.range = this.document.createRange(); - var startContainer, startOffset, endContainer, endOffset; - [startContainer, startOffset] = this.startPath.toNode(); - [endContainer, endOffset] = this.endPath.toNode(); - - if(!startContainer || !endContainer) { - throw("Annotate: PATH ERROR in highlight module!"); - } - - this.range.setStart(startContainer, startOffset); - this.range.setEnd(endContainer, endOffset); - return this.range; -} - -/** - * Generates a highlight representing the given DB row - */ -Zotero.Highlight.prototype.initWithDBRow = function(row) { - this.startPath = new Zotero.Annotate.Path(this.document, this.nsResolver, row.startParent, - row.startTextNode, row.startOffset); - this.endPath = new Zotero.Annotate.Path(this.document, this.nsResolver, row.endParent, - row.endTextNode, row.endOffset); - this.getRange(); - this._highlight(); -} - -/** - * Generates a highlight representing given a DOM range - * - * @param {Range} range DOM range - * @param {Zotero.Annotate.Path} startPath Path representing start of range - * @param {Zotero.Annotate.Path} endPath Path representing end of range - */ -Zotero.Highlight.prototype.initWithRange = function(range, startPath, endPath) { - this.startPath = startPath; - this.endPath = endPath; - this.range = range; - this._highlight(); -} - -/** - * Saves this highlight to the DB - */ -Zotero.Highlight.prototype.save = function() { - // don't save defective highlights - if(this.startPath.parent == this.endPath.parent - && this.startPath.textNode == this.endPath.textNode - && this.startPath.offset == this.endPath.offset) { - return false; - } - - var query = "INSERT INTO highlights VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, DATETIME('now'))"; - var parameters = [ - this.annotationsObj.itemID, // itemID - this.startPath.parent, // startParent - (this.startPath.textNode ? this.startPath.textNode : null), // startTextNode - (this.startPath.offset || this.startPath.offset === 0 ? this.startPath.offset : null), // startOffset - this.endPath.parent, // endParent - (this.endPath.textNode ? this.endPath.textNode : null), // endTextNode - (this.endPath.offset || this.endPath.offset === 0 ? this.endPath.offset: null) // endOffset - ]; - - Zotero.DB.query(query, parameters); -} - -Zotero.Highlight.UNHIGHLIGHT_ALL = 0; -Zotero.Highlight.UNHIGHLIGHT_TO_POINT = 1; -Zotero.Highlight.UNHIGHLIGHT_FROM_POINT = 2; - -/** - * Un-highlights a range - * - * @param {Node} container Node to highlight/unhighlight from, or null if mode == UNHIGHLIGHT_ALL - * @param {Integer} offset Text offset, or null if mode == UNHIGHLIGHT_ALL - * @param {Zotero.Annotate.Path} path Path representing node, offset combination, or null - * if mode == UNHIGHLIGHT_ALL - * @param {Integer} mode Unhighlight mode - */ -Zotero.Highlight.prototype.unhighlight = function(container, offset, path, mode) { - this.getRange(); - - if(mode == 1) { - this.range.setStart(container, offset); - this.startPath = path; - } else if(mode == 2) { - this.range.setEnd(container, offset); - this.endPath = path; - } - - var length = this.spans.length; - for(var i=0; i distance1) { - span.style.backgroundColor = Zotero.Annotate.alternativeHighlightColor; - } - } - } - - span.appendChild(parent.removeChild(textNode)); - parent.insertBefore(span, (nextSibling ? nextSibling : null)); - } - - if(span && saveSpan) this.spans.push(span); - return span; -} - -/** - * Highlights the space between two nodes at the same level - * - * @param {Node} start - * @param {Node} end - * @return {Node} Span containing the first block of highlighted text - * @private - */ -Zotero.Highlight.prototype._highlightSpaceBetween = function(start, end) { - var firstSpan = false; - var node = start; - var text; - - while(node) { - // process nodes - if(node.nodeType == Components.interfaces.nsIDOMNode.TEXT_NODE) { - var textArray = [node]; - } else { - var texts = this.document.evaluate('.//text()', node, this.nsResolver, - Components.interfaces.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null); - var textArray = new Array() - while(text = texts.iterateNext()) textArray.push(text); - } - - // do this in the middle, after we're finished with node but before we add any spans - if(node === end) { - node = false; - } else { - node = node.nextSibling; - } - - for (let textNode of textArray) { - if(firstSpan) { - this._highlightTextNode(textNode); - } else { - firstSpan = this._highlightTextNode(textNode); - } - } - } - - return firstSpan; -} \ No newline at end of file diff --git a/chrome/content/zotero/xpcom/schema.js b/chrome/content/zotero/xpcom/schema.js index 84c3e2f578..2369c02211 100644 --- a/chrome/content/zotero/xpcom/schema.js +++ b/chrome/content/zotero/xpcom/schema.js @@ -3219,6 +3219,9 @@ Zotero.Schema = new function(){ } else if (i == 112) { + yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS annotations"); + yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS highlights"); + 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)"); } diff --git a/components/zotero-service.js b/components/zotero-service.js index edd25cc01a..83b54b2d09 100644 --- a/components/zotero-service.js +++ b/components/zotero-service.js @@ -64,7 +64,6 @@ const xpcomFilesLocal = [ 'libraryTreeView', 'collectionTreeView', 'collectionTreeRow', - 'annotate', 'api', 'attachments', 'cite', diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index a27c77383a..c0b95467ef 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -139,9 +139,6 @@ pref("extensions.zotero.httpServer.port", 23119); // ascii "ZO" // Zeroconf pref("extensions.zotero.zeroconf.server.enabled", false); -// Annotation settings -pref("extensions.zotero.annotations.warnOnClose", true); - // Streaming server pref("extensions.zotero.streaming.enabled", true); diff --git a/resource/schema/userdata.sql b/resource/schema/userdata.sql index c0887e5b38..1454624333 100644 --- a/resource/schema/userdata.sql +++ b/resource/schema/userdata.sql @@ -374,37 +374,6 @@ CREATE TABLE storageDeleteLog ( FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE ); -CREATE TABLE annotations ( - annotationID INTEGER PRIMARY KEY, - itemID INT NOT NULL, - parent TEXT, - textNode INT, - offset INT, - x INT, - y INT, - cols INT, - rows INT, - text TEXT, - collapsed BOOL, - dateModified DATE, - FOREIGN KEY (itemID) REFERENCES itemAttachments(itemID) ON DELETE CASCADE -); -CREATE INDEX annotations_itemID ON annotations(itemID); - -CREATE TABLE highlights ( - highlightID INTEGER PRIMARY KEY, - itemID INT NOT NULL, - startParent TEXT, - startTextNode INT, - startOffset INT, - endParent TEXT, - endTextNode INT, - endOffset INT, - dateModified DATE, - FOREIGN KEY (itemID) REFERENCES itemAttachments(itemID) ON DELETE CASCADE -); -CREATE INDEX highlights_itemID ON highlights(itemID); - CREATE TABLE proxies ( proxyID INTEGER PRIMARY KEY, multiHost INT, From 92ba393488090c2bece12e5d329bf0f49f01b262 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Tue, 23 Jun 2020 04:59:10 -0400 Subject: [PATCH 007/193] Don't clear item type set in constructor when loading primary data Generally, don't clear the change state for primaryData when calling loadPrimaryData() on an item that doesn't exist. Specifically, this fixes an issue where calling loadPrimaryData() on a nonexistent item after setting a libraryID and key would also clear any item type set in the constructor. The same would apply for props set in the Collection/Search constructors. --- chrome/content/zotero/xpcom/data/dataObject.js | 1 - test/tests/dataObjectTest.js | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/chrome/content/zotero/xpcom/data/dataObject.js b/chrome/content/zotero/xpcom/data/dataObject.js index 27db4e6e85..6d62f91976 100644 --- a/chrome/content/zotero/xpcom/data/dataObject.js +++ b/chrome/content/zotero/xpcom/data/dataObject.js @@ -639,7 +639,6 @@ Zotero.DataObject.prototype.loadPrimaryData = Zotero.Promise.coroutine(function* throw new Error(this._ObjectType + " " + (id ? id : libraryID + "/" + key) + " not found in Zotero." + this._ObjectType + ".loadPrimaryData()"); } - this._clearChanged('primaryData'); // If object doesn't exist, mark all data types as loaded this._markAllDataTypeLoadStates(true); diff --git a/test/tests/dataObjectTest.js b/test/tests/dataObjectTest.js index c6eb0f0582..bf5f63445b 100644 --- a/test/tests/dataObjectTest.js +++ b/test/tests/dataObjectTest.js @@ -210,7 +210,16 @@ describe("Zotero.DataObject", function() { yield obj.loadPrimaryData(); assert.equal(obj.version, objs[type].version); } - }) + }); + + it("shouldn't overwrite item type set in constructor", async function () { + var item = new Zotero.Item('book'); + item.libraryID = Zotero.Libraries.userLibraryID; + item.key = Zotero.DataObjectUtilities.generateKey(); + await item.loadPrimaryData(); + var saved = await item.saveTx(); + assert.ok(saved); + }); }) describe("#loadAllData()", function () { From 1c366de5463284db6c996e657b02bdbcd8876553 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Sat, 20 Jun 2020 01:29:32 -0400 Subject: [PATCH 008/193] 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 = [ From cbeb5881e946298e2eabe35ec7fbdb35e5e090f4 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Tue, 23 Jun 2020 07:33:55 -0400 Subject: [PATCH 009/193] Hide annotations from items list --- chrome/content/zotero/xpcom/itemTreeView.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/chrome/content/zotero/xpcom/itemTreeView.js b/chrome/content/zotero/xpcom/itemTreeView.js index 84348d90c1..2d3ceb6565 100644 --- a/chrome/content/zotero/xpcom/itemTreeView.js +++ b/chrome/content/zotero/xpcom/itemTreeView.js @@ -328,6 +328,8 @@ Zotero.ItemTreeView.prototype.refresh = Zotero.serial(Zotero.Promise.coroutine(f Zotero.CollectionTreeCache.clear(); // Get the full set of items we want to show let newSearchItems = yield this.collectionTreeRow.getItems(); + // TEMP: Hide annotations + newSearchItems = newSearchItems.filter(item => !item.isAnnotation()); // Remove notes and attachments if necessary if (this.regularOnly) { newSearchItems = newSearchItems.filter(item => item.isRegularItem()); From 007bc315cc1763618b4fe6c1d0d17ed6188bbd8f Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Wed, 24 Jun 2020 03:38:31 -0400 Subject: [PATCH 010/193] Fix deprecation warning when using AsyncChannel in protocol handler --- components/zotero-protocol-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/zotero-protocol-handler.js b/components/zotero-protocol-handler.js index 5ec33ea5fc..b2356283ec 100644 --- a/components/zotero-protocol-handler.js +++ b/components/zotero-protocol-handler.js @@ -1298,7 +1298,7 @@ AsyncChannel.prototype = { } Components.utils.import("resource://gre/modules/NetUtil.jsm"); - NetUtil.asyncFetch(data, function (inputStream, status) { + NetUtil.asyncFetch({ uri: data, loadUsingSystemPrincipal: true }, function (inputStream, status) { if (!Components.isSuccessCode(status)) { reject(); return; From 8e1a15f6d05a947a7bf6bd0a6cb2305716dc8bb2 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Wed, 24 Jun 2020 03:38:07 -0400 Subject: [PATCH 011/193] Restore zotero://attachment protocol handler for area annotation images --- components/zotero-protocol-handler.js | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/components/zotero-protocol-handler.js b/components/zotero-protocol-handler.js index b2356283ec..f68e3f7a7e 100644 --- a/components/zotero-protocol-handler.js +++ b/components/zotero-protocol-handler.js @@ -53,6 +53,75 @@ function ZoteroProtocolHandler() { this._extensions = {}; + + /** + * zotero://attachment/library/[itemKey] + * zotero://attachment/groups/[groupID]/[itemKey] + */ + var AttachmentExtension = { + loadAsChrome: false, + + newChannel: function (uri) { + return new AsyncChannel(uri, function* () { + try { + var uriPath = uri.pathQueryRef; + if (!uriPath) { + return this._errorChannel('Invalid URL'); + } + uriPath = uriPath.substr('//attachment/'.length); + + var params = {}; + var router = new Zotero.Router(params); + router.add('library/items/:itemKey', function () { + params.libraryID = Zotero.Libraries.userLibraryID; + }); + router.add('groups/:groupID/items/:itemKey'); + router.run(uriPath); + + if (params.groupID) { + params.libraryID = Zotero.Groups.getLibraryIDFromGroupID(params.groupID); + } + if (!params.itemKey) { + return this._errorChannel("Item key not provided"); + } + var item = yield Zotero.Items.getByLibraryAndKeyAsync(params.libraryID, params.itemKey); + + if (!item) { + return this._errorChannel(`No item found for ${uriPath}`); + } + if (!item.isFileAttachment()) { + return this._errorChannel(`Item for ${uriPath} is not a file attachment`); + } + + var path = yield item.getFilePathAsync(); + if (!path) { + return this._errorChannel(`${path} not found`); + } + + // Set originalURI so that it seems like we're serving from zotero:// protocol. + // This is necessary to allow url() links to work from within CSS files. + // Otherwise they try to link to files on the file:// protocol, which isn't allowed. + this.originalURI = uri; + + return Zotero.File.pathToFile(path); + } + catch (e) { + return this._errorChannel(e.message); + } + }.bind(this)); + }, + + + _errorChannel: function (msg) { + Zotero.logError(msg); + this.status = Components.results.NS_ERROR_FAILURE; + this.contentType = 'text/plain'; + return msg; + } + }; + + + /** * zotero://data/library/collection/ABCD1234/items?sort=itemType&direction=desc * zotero://data/groups/12345/collection/ABCD1234/items?sort=title&direction=asc @@ -1072,6 +1141,7 @@ function ZoteroProtocolHandler() { } }; + this._extensions[ZOTERO_SCHEME + "://attachment"] = AttachmentExtension; this._extensions[ZOTERO_SCHEME + "://data"] = DataExtension; this._extensions[ZOTERO_SCHEME + "://report"] = ReportExtension; this._extensions[ZOTERO_SCHEME + "://timeline"] = TimelineExtension; From c3ff6eb66e812c82abb41069972ad2be99764c68 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Tue, 7 May 2019 05:36:26 -0400 Subject: [PATCH 012/193] Protocol handler extension to proxy PDF.js and PDFs --- components/zotero-protocol-handler.js | 69 +++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/components/zotero-protocol-handler.js b/components/zotero-protocol-handler.js index f68e3f7a7e..6098d72b30 100644 --- a/components/zotero-protocol-handler.js +++ b/components/zotero-protocol-handler.js @@ -34,6 +34,8 @@ const ZOTERO_PROTOCOL_NAME = "Zotero Chrome Extension Protocol"; Components.utils.import("resource://gre/modules/Services.jsm"); Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/NetUtil.jsm"); +Components.utils.import("resource://gre/modules/osfile.jsm") const Cc = Components.classes; const Ci = Components.interfaces; @@ -1035,6 +1037,72 @@ function ZoteroProtocolHandler() { }; + /* + zotero://pdf.js/web/viewer.html + zotero://pdf.js/pdf/1/ABCD5678 + */ + var PDFJSExtension = { + loadAsChrome: false, + + newChannel: function (uri) { + return new AsyncChannel(uri, function* () { + try { + uri = uri.spec; + // Proxy PDF.js files + if (uri.startsWith('zotero://pdf.js/web/') + || uri.startsWith('zotero://pdf.js/build/')) { + uri = uri.replace(/zotero:\/\/pdf.js\//, 'resource://pdf.js/'); + let newURI = Services.io.newURI(uri, null, null); + return this.getURIInputStream(newURI); + } + + // Proxy attachment PDFs + var pdfPrefix = 'zotero://pdf.js/pdf/'; + if (!uri.startsWith(pdfPrefix)) { + return this._errorChannel("File not found"); + } + var [libraryID, key] = uri.substr(pdfPrefix.length).split('/'); + libraryID = parseInt(libraryID); + + var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key); + if (!item) { + return self._errorChannel("Item not found"); + } + var path = yield item.getFilePathAsync(); + if (!path) { + return this._errorChannel("File not found"); + } + return this.getURIInputStream(OS.Path.toFileURI(path)); + } + catch (e) { + Zotero.debug(e, 1); + throw e; + } + }.bind(this)); + }, + + + getURIInputStream: function (uri) { + return new Zotero.Promise((resolve, reject) => { + NetUtil.asyncFetch(uri, function (inputStream, result) { + if (!Components.isSuccessCode(result)) { + // TODO: Handle error + return; + } + resolve(inputStream); + }); + }); + }, + + + _errorChannel: function (msg) { + this.status = Components.results.NS_ERROR_FAILURE; + this.contentType = 'text/plain'; + return msg; + } + }; + + /** * Open a PDF at a given page (or try to) * @@ -1148,6 +1216,7 @@ function ZoteroProtocolHandler() { this._extensions[ZOTERO_SCHEME + "://select"] = SelectExtension; this._extensions[ZOTERO_SCHEME + "://debug"] = DebugExtension; this._extensions[ZOTERO_SCHEME + "://connector"] = ConnectorExtension; + this._extensions[ZOTERO_SCHEME + "://pdf.js"] = PDFJSExtension; this._extensions[ZOTERO_SCHEME + "://open-pdf"] = OpenPDFExtension; } From 2543a695e84b485851e16fa10efc4215deb0b9c5 Mon Sep 17 00:00:00 2001 From: Martynas Bagdonas Date: Thu, 28 Mar 2019 20:52:22 +0200 Subject: [PATCH 013/193] Introduce PDF reader and note editor --- .gitmodules | 12 + chrome/content/zotero/bindings/noteeditor.xml | 713 ++++++++---------- .../content/zotero/containers/containers.xul | 2 + .../content/zotero/containers/noteEditor.xul | 35 + .../zotero/containers/noteEditorContainer.js | 594 +++++++++++++++ .../content/zotero/integration/quickFormat.js | 8 + chrome/content/zotero/itemPane.js | 18 +- chrome/content/zotero/itemPane.xul | 2 +- chrome/content/zotero/note.js | 11 +- chrome/content/zotero/note.xul | 6 +- chrome/content/zotero/viewer.js | 66 ++ chrome/content/zotero/viewer.xul | 98 +++ chrome/content/zotero/xpcom/attachments.js | 15 +- chrome/content/zotero/xpcom/data/item.js | 2 +- chrome/content/zotero/xpcom/data/notes.js | 43 ++ chrome/content/zotero/xpcom/itemTreeView.js | 2 + chrome/content/zotero/xpcom/pdfExport.js | 168 +++++ chrome/content/zotero/xpcom/pdfImport.js | 167 ++++ .../zotero/xpcom/pdfWorker/transport.js | 106 +++ chrome/content/zotero/xpcom/viewer.js | 523 +++++++++++++ chrome/content/zotero/zoteroPane.js | 95 ++- chrome/content/zotero/zoteroPane.xul | 4 + chrome/locale/en-US/zotero/zotero.dtd | 2 + chrome/locale/en-US/zotero/zotero.properties | 5 + chrome/skin/default/zotero/overlay.css | 18 + chrome/skin/default/zotero/zotero.css | 5 + components/zotero-protocol-handler.js | 21 +- components/zotero-service.js | 4 + pdf-reader | 1 + pdf-worker | 1 + scripts/build.js | 8 +- scripts/clean.js | 3 + scripts/pdf-reader.js | 61 ++ scripts/pdf-worker.js | 61 ++ scripts/zotero-note-editor.js | 50 ++ zotero-note-editor | 1 + 36 files changed, 2512 insertions(+), 419 deletions(-) create mode 100644 chrome/content/zotero/containers/noteEditor.xul create mode 100644 chrome/content/zotero/containers/noteEditorContainer.js create mode 100644 chrome/content/zotero/viewer.js create mode 100644 chrome/content/zotero/viewer.xul create mode 100644 chrome/content/zotero/xpcom/pdfExport.js create mode 100644 chrome/content/zotero/xpcom/pdfImport.js create mode 100644 chrome/content/zotero/xpcom/pdfWorker/transport.js create mode 100644 chrome/content/zotero/xpcom/viewer.js create mode 160000 pdf-reader create mode 160000 pdf-worker create mode 100644 scripts/pdf-reader.js create mode 100644 scripts/pdf-worker.js create mode 100644 scripts/zotero-note-editor.js create mode 160000 zotero-note-editor diff --git a/.gitmodules b/.gitmodules index f70921805c..96573462c7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -29,3 +29,15 @@ [submodule "resource/SingleFile"] path = resource/SingleFile url = https://github.com/gildas-lormeau/SingleFile.git +[submodule "pdf-reader"] + path = pdf-reader + url = https://github.com/zotero/pdf-reader.git + branch = master +[submodule "pdf-worker"] + path = pdf-worker + url = https://github.com/zotero/pdf-worker.git + branch = master +[submodule "zotero-note-editor"] + path = zotero-note-editor + url = https://github.com/zotero/zotero-note-editor.git + branch = master diff --git a/chrome/content/zotero/bindings/noteeditor.xml b/chrome/content/zotero/bindings/noteeditor.xml index f5f769c58c..efc309c138 100644 --- a/chrome/content/zotero/bindings/noteeditor.xml +++ b/chrome/content/zotero/bindings/noteeditor.xml @@ -1,39 +1,39 @@ - - + + - + "view" ed.setMode('readonly')); - } - break; - - case 'edit': - if (this.noteField) { - this.noteField.onInit(ed => ed.setMode('design')); - } - this.editable = true; - this.saveOnEdit = true; - this.parentClickHandler = this.selectParent; - this.keyDownHandler = this.handleKeyDown; - this.commandHandler = this.save; - this.displayTags = true; - this.displayRelated = true; - break; - - default: - throw ("Invalid mode '" + val + "' in noteeditor.xml"); - } - - this._mode = val; - document.getAnonymousNodes(this)[0].setAttribute('mode', val); - this._id('links-box').mode = val; - ]]> + // Duplicate default property settings here + this.editable = false; + this.saveOnEdit = false; + this.displayTags = false; + this.displayRelated = false; + this.displayButton = false; + + switch (val) { + case 'view': + case 'merge': + this.editable = false; + break; + + case 'edit': + this.editable = true; + this.saveOnEdit = true; + this.parentClickHandler = this.selectParent; + this.keyDownHandler = this.handleKeyDown; + this.commandHandler = this.save; + this.displayTags = true; + this.displayRelated = true; + break; + + default: + throw ("Invalid mode '" + val + "' in noteeditor.xml"); + } + + this._mode = val; + document.getAnonymousNodes(this)[0].setAttribute('mode', val); + this._id('links-box').mode = val; + this._id('links-container').hidden = !(this.displayTags && this.displayRelated); + this._id('links-box').refresh(); + ]]> - + @@ -102,31 +166,59 @@ ]]> - - - + + return (async () => { + // `item` field can be set before the constructor is called + // (which happens in the merge dialog i.e.), therefore we wait for + // the initialization + let n = 0; + while (!this._initialized && !this._destroyed) { + if (n >= 1000) { + throw new Error('Waiting for noteeditor initialization failed'); + } + await Zotero.Promise.delay(10); + n++; + } + + // The binding can also be immediately destrcutred + // (which also happens in the marge dialog) + if (this._destroyed) { + return; + } + + if (!val) this._item = null; + if (this._item && this._item.id === val.id) return; + + this._lastHtmlValue = val.getNote(); + + this._editor = new Zotero.NoteEditor(); + this._editor.init({ + item: val, + window: document.getAnonymousElementByAttribute(this, "anonid", "rt-view1").contentWindow, + readOnly: !this.editable, + onNavigate: this._navigateHandler + }); + + this._item = val; + + var parentKey = this._item.parentKey; + if (parentKey) { + this.parentItem = Zotero.Items.getByLibraryAndKey(this._item.libraryID, parentKey); + } + + this._id('links-box').item = this._item; + })(); + ]]> - + - - - - + + + + + + + - - - - - - - - + - - - - - - - - - - - - - - + + return (async () => { + + })(); + ]]> - + { - if (!noteField.hasFocus()) { - document.getElementById(id).focus(); - } - }, 0); - } - return; - } - - break; - } - ]]> - - - - - - { + // if (!noteField.hasFocus()) { + // document.getElementById(id).focus(); + // } + // }, 0); + // } + // return; + // } + // + // break; + // } ]]> - - + + - + { + if (this._iframe && this._iframe.contentWindow) { + this._iframe.focus(); + this._editor.focus(); + } + + }, 500); + ]]> - + - + - + - - + + - + + this.id('related').mode = val; + this.id('tags').mode = val; + ]]> + this._parentItem = val; + + var parentText = this.id('parentText'); + if (parentText.firstChild) { + parentText.removeChild(parentText.firstChild); + } + + if (this._parentItem && this.getAttribute('notitle') != '1') { + this.id('parent-row').hidden = undefined; + this.id('parentLabel').value = Zotero.getString('pane.item.parentItem'); + parentText.appendChild(document.createTextNode(this._parentItem.getDisplayTitle(true))); + } + ]]> + this.id('tags').reload(); + var x = this.boxObject.screenX; + var y = this.boxObject.screenY; + this.id('tagsPopup').openPopupAtScreen(x, y, false); + + // If editable and no existing tags, open new empty row + var tagsBox = this.id('tags'); + if (tagsBox.mode == 'edit' && tagsBox.count == 0) { + this.id('tags').newTag(); + } + ]]> - + + this.updateTagsSummary(); + this.updateRelatedSummary(); + ]]> - + + var v = this.id('tags').summary; + + if (!v || v == "") { + v = "[" + Zotero.getString('pane.item.noteEditor.clickHere') + "]"; + } + + this.id('tagsLabel').value = Zotero.getString('itemFields.tags') + + Zotero.getString('punctuation.colon'); + this.id('tagsClick').value = v; + ]]> 0) { - var x = this.boxObject.screenX; - var y = this.boxObject.screenY; - this.id('relatedPopup').openPopupAtScreen(x, y, false); - } - else { - this.id('related').add(); - } - ]]> + var relatedList = this.item.relatedItems; + if (relatedList.length > 0) { + var x = this.boxObject.screenX; + var y = this.boxObject.screenY; + this.id('relatedPopup').openPopupAtScreen(x, y, false); + } + else { + this.id('related').add(); + } + ]]> + var v = this.id('related').summary; + + if (!v || v == "") { + v = "[" + Zotero.getString('pane.item.noteEditor.clickHere') + "]"; + } + + this.id('relatedLabel').value = Zotero.getString('itemFields.related') + + Zotero.getString('punctuation.colon'); + this.id('relatedClick').value = v; + ]]> + + if (document.getElementById('zotero-pane')) { + var zp = ZoteroPane; + } + else { + var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"] + .getService(Components.interfaces.nsIWindowMediator); + + var lastWin = wm.getMostRecentWindow("navigator:browser"); + + if (!lastWin) { + var lastWin = window.open(); + } + + if (lastWin.ZoteroOverlay && !lastWin.ZoteroPane.isShowing()) { + lastWin.ZoteroOverlay.toggleDisplay(true); + } + + var zp = lastWin.ZoteroPane; + } + + Zotero.spawn(function* () { + var parentID = this.item.parentID; + yield zp.clearQuicksearch(); + zp.selectItem(parentID); + }, this); + ]]> @@ -541,15 +487,18 @@ - + - + @@ -562,17 +511,17 @@ seems to get triggered by these events for reasons that are less than clear) so that we can manually refresh the popup if it's open after autocomplete is used to prevent it from becoming unresponsive - + Note: Code in tagsbox.xml is dependent on the DOM path between the tagsbox and tagsLabel above, so be sure to update fixPopup() if it changes --> + onpopupshown="if (!document.commandDispatcher.focusedElement || document.commandDispatcher.focusedElement.tagName=='xul:label'){ /* DEBUG: it would be nice to make this work -- if (this.firstChild.count==0){ this.firstChild.newTag(); } */ this.setAttribute('showing', 'true'); }" + onpopuphidden="if (!document.commandDispatcher.focusedElement || document.commandDispatcher.focusedElement.tagName=='xul:label'){ this.setAttribute('showing', 'false'); }"> - \ No newline at end of file + diff --git a/chrome/content/zotero/containers/containers.xul b/chrome/content/zotero/containers/containers.xul index bb5b6f9d54..1ed3952f12 100644 --- a/chrome/content/zotero/containers/containers.xul +++ b/chrome/content/zotero/containers/containers.xul @@ -26,9 +26,11 @@ + + \ No newline at end of file diff --git a/chrome/content/zotero/containers/noteEditor.xul b/chrome/content/zotero/containers/noteEditor.xul new file mode 100644 index 0000000000..e0c0d45b35 --- /dev/null +++ b/chrome/content/zotero/containers/noteEditor.xul @@ -0,0 +1,35 @@ + + + %globalDTD; + %zoteroDTD; + ]> + + + + + + \ No newline at end of file diff --git a/chrome/content/zotero/containers/noteEditorContainer.js b/chrome/content/zotero/containers/noteEditorContainer.js new file mode 100644 index 0000000000..6411442214 --- /dev/null +++ b/chrome/content/zotero/containers/noteEditorContainer.js @@ -0,0 +1,594 @@ +class NoteEditor { + constructor() { + this.instanceID = Zotero.Utilities.randomString(); + Zotero.Notes.editorInstances.push(this); + Zotero.debug('Creating a new editor instance'); + } + + async init(options) { + this.id = options.item.id; + + this.item = options.item; + // this._onNavigate = options.onNavigate; + this.saveOnEdit = true; + this.state = options.state; + this.citations = []; + this.disableSaving = false; + this._readOnly = options.readOnly; + this.window = options.window; + + await this.waitForEditor(); + // Zotero.Notes.updateURIs(h1); + + // Run Cut/Copy/Paste with chrome privileges + this.window.wrappedJSObject.zoteroExecCommand = function (doc, command, ui, value) { + // Is that safe enough? + if (!['cut', 'copy', 'paste'].includes(command)) { + return; + } + return doc.execCommand(command, ui, value); + } + + this.window.addEventListener('message', this.listener); + this.quickFormatWindow = null; + let data = this.state ? { state: this.state } : { html: this.item.getNote() }; + this.postMessage({ + op: 'init', ...data, + libraryId: this.item.libraryID, + key: this.item.key, + readOnly: this._readOnly + }); + } + + uninit() { + this.window.removeEventListener('message', this.listener); + let index = Zotero.Notes.editorInstances.indexOf(this); + if (index >= 0) { + Zotero.Notes.editorInstances.splice(index, 1); + } + } + + async waitForEditor() { + let n = 0; + while (!this.window) { + if (n >= 1000) { + throw new Error('Waiting for editor failed '); + } + await Zotero.Promise.delay(10); + n++; + } + } + + postMessage(message) { + this.window.postMessage({ instanceId: this.instanceID, message }, '*'); + } + + listener = async (e) => { + if (e.data.instanceId !== this.instanceID) { + return; + } + // Zotero.debug('Message received from editor ' + e.data.instanceId + ' ' + this.instanceID + ' ' + e.data.message.op); + + let message = e.data.message; + + if (message.op === 'getItemData') { + let parent = message.parent; + let item = await Zotero.Items.getAsync(message.itemId); + if (parent && item && item.parentID) { + item = await Zotero.Items.getAsync(item.parentID); + } + if (item) { + let data = { + uri: Zotero.URI.getItemURI(item), + backupText: this.getBackupStr(item) + }; + } + } + else if (message.op === 'insertObject') { + let { type, data, pos } = message; + + if (type === 'zotero/item') { + let ids = data.split(',').map(id => parseInt(id)); + let citations = []; + for (let id of ids) { + let item = await Zotero.Items.getAsync(id); + if (!item) { + continue; + } + citations.push({ + citationItems: [{ + uri: Zotero.URI.getItemURI(item), + backupText: this.getBackupStr(item) + }], + properties: {} + }); + } + this.postMessage({ op: 'insertCitations', citations, pos }); + } + else if (type === 'zotero/annotation') { + let annotations = JSON.parse(data); + let list = []; + for (let annotation of annotations) { + let attachmentItem = await Zotero.Items.getAsync(annotation.itemId); + if (!attachmentItem) { + continue; + } + let citationItem = attachmentItem.parentID && await Zotero.Items.getAsync(attachmentItem.parentID) || attachmentItem; + annotation.uri = Zotero.URI.getItemURI(attachmentItem); + let citation = { + citationItems: [{ + uri: Zotero.URI.getItemURI(citationItem), + backupText: this.getBackupStr(citationItem), + locator: annotation.pageLabel + }], + properties: {} + }; + list.push({ annotation, citation }); + } + this.postMessage({ op: 'insertAnnotationsAndCitations', list, pos }); + } + } + else if (message.op === 'navigate') { + if (this._onNavigate) { + this._onNavigate(message.uri, { position: message.position }); + } + else { + await Zotero.Viewer.openURI(message.uri, { position: message.position }); + } + } + else if (message.op === 'openURL') { + var zp = typeof ZoteroPane !== 'undefined' ? ZoteroPane : window.opener.ZoteroPane; + zp.loadURI(message.url); + } + else if (message.op === 'showInLibrary') { + let zp = Zotero.getActiveZoteroPane(); + if (zp) { + let item = await Zotero.URI.getURIItem(message.itemURI); + if (item) { + zp.selectItems([item.id]); + let win = Zotero.getMainWindow(); + if (win) { + win.focus(); + } + } + } + } + else if (message.op === 'update') { + this.save(message.noteData); + } + else if (message.op === 'getFormattedCitations') { + let formattedCitations = await this.getFormattedCitations(message.citations); + for (let newCitation of message.citations) { + if (!this.citations.find(citation => citation.id === newCitation.id)) { + this.citations.push(newCitation); + } + } + this.postMessage({ + op: 'setFormattedCitations', + formattedCitations + }); + } + else if (message.op === 'quickFormat') { + let id = message.id; + let citation = message.citation; + citation = JSON.parse(JSON.stringify(citation)); + let availableCitationItems = []; + for (let citationItem of citation.citationItems) { + let item = await Zotero.URI.getURIItem(citationItem.uri); + if (item) { + availableCitationItems.push({ ...citationItem, id: item.id }); + } + } + citation.citationItems = availableCitationItems; + let libraryID = this.item.libraryID; + this.quickFormatDialog(id, citation, [libraryID]); + } + else if (message.op === 'updateImages') { + for (let image of message.added) { + let blob = this.dataURLtoBlob(image.dataUrl); + let imageAttachment = await Zotero.Attachments.importEmbeddedImage({ + blob, + parentItemID: this.item.id, + itemKey: image.attachmentKey, + saveOptions: { + notifierData: { + noteEditorID: this.instanceID + } + } + }); + } + let attachmentItems = this.item.getAttachments().map(id => Zotero.Items.get(id)); + let abandonedItems = attachmentItems.filter(item => !message.all.includes(item.key)); + for (let item of abandonedItems) { + await item.eraseTx(); + } + } + else if (message.op === 'requestImage') { + let { attachmentKey } = message; + var item = Zotero.Items.getByLibraryAndKey(this.item.libraryID, attachmentKey); + if (!item) return; + let path = await item.getFilePathAsync(); + let buf = await OS.File.read(path, {}); + buf = new Uint8Array(buf).buffer; + let dataURL = 'data:' + item.attachmentContentType + ';base64,' + this.arrayBufferToBase64(buf); + this.postMessage({ + op: 'updateImage', + attachmentKey, + dataUrl: dataURL + }); + } + else if (message.op === 'popup') { + this.openPopup(message.x, message.y, message.items); + } + } + + openPopup(x, y, items) { + let popup = document.getElementById('editor-menu'); + popup.hidePopup(); + + while (popup.firstChild) { + popup.removeChild(popup.firstChild); + } + + for (let item of items) { + let menuitem = document.createElement('menuitem'); + menuitem.setAttribute('value', item[0]); + menuitem.setAttribute('label', item[1]); + menuitem.addEventListener('command', () => { + this.postMessage({ + op: 'contextMenuAction', + ctxAction: item[0], + payload: item.payload + }); + }); + popup.appendChild(menuitem); + } + + popup.openPopupAtScreen(x, y, true); + } + + async save(noteData) { + if (!noteData) return; + let { state, html } = noteData; + if (html === undefined) return; + try { + if (this.disableSaving) { + Zotero.debug('Saving is disabled'); + return; + } + + if (this._readOnly) { + Zotero.debug('Not saving read-only note'); + return; + } + if (html === null) { + Zotero.debug('Note value not available -- not saving', 2); + return; + } + // Update note + if (this.item) { + let changed = this.item.setNote(html); + if (changed && this.saveOnEdit) { + // this.noteField.changed = false; + await this.item.saveTx({ + notifierData: { + noteEditorID: this.instanceID, + state + } + }); + } + } + else { + // Create a new note + var item = new Zotero.Item('note'); + if (this.parentItem) { + item.libraryID = this.parentItem.libraryID; + } + item.setNote(html); + if (this.parentItem) { + item.parentKey = this.parentItem.key; + } + if (this.saveOnEdit) { + var id = await item.saveTx(); + + if (!this.parentItem && this.collection) { + this.collection.addItem(id); + } + } + } + } + catch (e) { + Zotero.logError(e); + if (this.hasAttribute('onerror')) { + let fn = new Function('', this.getAttribute('onerror')); + fn.call(this) + } + if (this.onError) { + this.onError(e); + } + } + } + + focus = () => { + + } + + getNoteDataSync = () => { + if (!this._readOnly && !this.disableSaving && this.window) { + return this.window.wrappedJSObject.getDataSync(); + } + return null; + }; + + /** + * Builds the string to go inside a bubble + */ + _buildBubbleString(citationItem, str) { + // Locator + if (citationItem.locator) { + if (citationItem.label) { + // TODO localize and use short forms + var label = citationItem.label; + } + else if (/[\-–,]/.test(citationItem.locator)) { + var label = 'pp.'; + } + else { + var label = 'p.'; + } + + str += ', ' + label + ' ' + citationItem.locator; + } + + // Prefix + if (citationItem.prefix && Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP) { + str = citationItem.prefix + + (Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP.test(citationItem.prefix) ? ' ' : '') + + str; + } + + // Suffix + if (citationItem.suffix && Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP) { + str += (Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP.test(citationItem.suffix) ? ' ' : '') + + citationItem.suffix; + } + + return str; + } + + async updateCitationsForURIs(uris) { + let citations = this.citations + .filter(citation => citation.citationItems + .some(citationItem => uris.includes(citationItem.uri))); + + if (citations.length) { + let formattedCitations = await this.getFormattedCitations(citations); + this.postMessage({ + op: 'setFormattedCitations', + formattedCitations + }); + } + } + + getFormattedCitations = async (citations) => { + let formattedCitations = {}; + for (let citation of citations) { + formattedCitations[citation.id] = await this.getFormattedCitation(citation); + } + return formattedCitations; + } + + getFormattedCitation = async (citation) => { + let formattedItems = []; + for (let citationItem of citation.citationItems) { + let item = await Zotero.URI.getURIItem(citationItem.uri); + if (item && !item.deleted) { + formattedItems.push(this._buildBubbleString(citationItem, this.getBackupStr(item))); + } + else { + let formattedItem = this._buildBubbleString(citationItem, citationItem.backupText); + formattedItem = `${formattedItem}`; + formattedItems.push(formattedItem); + } + } + return formattedItems.join(';'); + } + + getBackupStr(item) { + var str = item.getField('firstCreator'); + + // Title, if no creator (getDisplayTitle in order to get case, e-mail, statute which don't have a title field) + if (!str) { + str = Zotero.getString('punctuation.openingQMark') + item.getDisplayTitle() + Zotero.getString('punctuation.closingQMark'); + } + + // Date + var date = item.getField('date', true, true); + if (date && (date = date.substr(0, 4)) !== '0000') { + str += ', ' + date; + } + return str; + } + + arrayBufferToBase64(buffer) { + var binary = ''; + var bytes = new Uint8Array(buffer); + var len = bytes.byteLength; + for (var i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return self.btoa(binary); + } + + dataURLtoBlob(dataurl) { + let parts = dataurl.split(','); + let mime = parts[0].match(/:(.*?);/)[1]; + if (parts[0].indexOf('base64') !== -1) { + let bstr = atob(parts[1]); + let n = bstr.length; + let u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + + return new self.Blob([u8arr], { type: mime }); + } + return null; + } + + quickFormatDialog(id, citationData, filterLibraryIDs) { + let that = this; + let win; + /** + * Citation editing functions and propertiesaccessible to quickFormat.js and addCitationDialog.js + */ + let CI = function (citation, sortable, fieldIndexPromise, citationsByItemIDPromise, previewFn) { + this.citation = citation; + this.sortable = sortable; + this.filterLibraryIDs = filterLibraryIDs; + this.disableClassicDialog = true; + } + + CI.prototype = { + /** + * Execute a callback with a preview of the given citation + * @return {Promise} A promise resolved with the previewed citation string + */ + preview: function () { + Zotero.debug('CI: preview') + }, + + /** + * Sort the citationItems within citation (depends on this.citation.properties.unsorted) + * @return {Promise} A promise resolved with the previewed citation string + */ + sort: function () { + Zotero.debug('CI: sort') + return async function () { + }; + }, + + /** + * Accept changes to the citation + * @param {Function} [progressCallback] A callback to be run when progress has changed. + * Receives a number from 0 to 100 indicating current status. + */ + accept: async function (progressCallback) { + Zotero.debug('CI: accept'); + if (progressCallback) progressCallback(100); + + if (win) { + win.close(); + } + + let citation = { + citationItems: this.citation.citationItems, + properties: this.citation.properties + } + + for (let citationItem of citation.citationItems) { + let itm = await Zotero.Items.getAsync(citationItem.id); + delete citationItem.id; + citationItem.uri = Zotero.URI.getItemURI(itm); + citationItem.backupText = that.getBackupStr(itm); + } + + let formattedCitation = await that.getFormattedCitation(citation); + + if (this.citation.citationItems.length) { + that.postMessage({ + op: 'setCitation', + id, citation, formattedCitation + }); + } + }, + + /** + * Get a list of items used in the current document + * @return {Promise} A promise resolved by the items + */ + getItems: async function () { + Zotero.debug('CI: getItems') + return []; + } + } + + + let Citation = class { + constructor(citationField, data, noteIndex) { + if (!data) { + data = { citationItems: [], properties: {} }; + } + this.citationID = data.citationID; + this.citationItems = data.citationItems; + this.properties = data.properties; + this.properties.noteIndex = noteIndex; + + this._field = citationField; + } + + /** + * Load citation item data + * @param {Boolean} [promptToReselect=true] - will throw a MissingItemException if false + * @returns {Promise{Number}} + * - Zotero.Integration.NO_ACTION + * - Zotero.Integration.UPDATE + * - Zotero.Integration.REMOVE_CODE + * - Zotero.Integration.DELETE + */ + loadItemData() { + Zotero.debug('Citation: loadItemData'); + } + + async handleMissingItem(idx) { + Zotero.debug('Citation: handleMissingItem'); + } + + async prepareForEditing() { + Zotero.debug('Citation: prepareForEditing'); + } + + toJSON() { + Zotero.debug('Citation: toJSON'); + } + + /** + * Serializes the citation into CSL code representation + * @returns {string} + */ + serialize() { + Zotero.debug('Citation: serialize'); + } + }; + + if (that.quickFormatWindow) { + that.quickFormatWindow.close(); + that.quickFormatWindow = null; + } + + let citation = new Citation(); + citation.citationItems = citationData.citationItems; + citation.properties = citationData.properties; + let styleID = Zotero.Prefs.get('export.lastStyle'); + let locale = Zotero.Prefs.get('export.lastLocale'); + let csl = Zotero.Styles.get(styleID).getCiteProc(locale); + var io = new CI(citation, csl.opt.sort_citations); + + + var allOptions = 'chrome,centerscreen'; + // without this, Firefox gets raised with our windows under Compiz + if (Zotero.isLinux) allOptions += ',dialog=no'; + // if(options) allOptions += ','+options; + + var mode = (!Zotero.isMac && Zotero.Prefs.get('integration.keepAddCitationDialogRaised') + ? 'popup' : 'alwaysRaised') + ',resizable=false,centerscreen'; + + win = that.quickFormatWindow = Components.classes['@mozilla.org/embedcomp/window-watcher;1'] + .getService(Components.interfaces.nsIWindowWatcher) + .openWindow(null, 'chrome://zotero/content/integration/quickFormat.xul', '', mode, { + wrappedJSObject: io + }); + } +} + +Zotero.NoteEditor = NoteEditor; diff --git a/chrome/content/zotero/integration/quickFormat.js b/chrome/content/zotero/integration/quickFormat.js index bf32e7cd4f..fd3bffbab8 100644 --- a/chrome/content/zotero/integration/quickFormat.js +++ b/chrome/content/zotero/integration/quickFormat.js @@ -56,6 +56,10 @@ var Zotero_QuickFormat = new function () { Zotero.debug(`Quick Format received citation:`); Zotero.debug(JSON.stringify(io.citation.toJSON())); + if (io.disableClassicDialog) { + document.getElementById('classic-view').hidden = true; + } + // Only hide chrome on Windows or Mac if(Zotero.isMac) { document.documentElement.setAttribute("drawintitlebar", true); @@ -309,6 +313,10 @@ var Zotero_QuickFormat = new function () { .forEach(feed => s.addCondition("libraryID", "isNot", feed.libraryID)); s.addCondition("quicksearch-titleCreatorYear", "contains", str); s.addCondition("itemType", "isNot", "attachment"); + if (io.filterLibraryIDs) { + io.filterLibraryIDs.forEach(id => s.addCondition("libraryID", "is", id)); + } + haveConditions = true; } } diff --git a/chrome/content/zotero/itemPane.js b/chrome/content/zotero/itemPane.js index 904da3bfc9..33a7f11d88 100644 --- a/chrome/content/zotero/itemPane.js +++ b/chrome/content/zotero/itemPane.js @@ -259,25 +259,25 @@ var ZoteroItemPane = new function() { _selectedNoteID = item.id; // If an external note window is open for this item, don't show the editor - if (ZoteroPane.findNoteWindow(item.id)) { - this.showNoteWindowMessage(); - return; - } + // if (ZoteroPane.findNoteWindow(item.id)) { + // this.showNoteWindowMessage(); + // return; + // } var noteEditor = document.getElementById('zotero-note-editor'); // If loading new or different note, disable undo while we repopulate the text field // so Undo doesn't end up clearing the field. This also ensures that Undo doesn't // undo content from another note into the current one. - var clearUndo = noteEditor.item ? noteEditor.item.id != item.id : false; + // var clearUndo = noteEditor.item ? noteEditor.item.id != item.id : false; noteEditor.mode = editable ? 'edit' : 'view'; noteEditor.parent = null; noteEditor.item = item; - if (clearUndo) { - noteEditor.clearUndo(); - } + // if (clearUndo) { + // noteEditor.clearUndo(); + // } document.getElementById('zotero-view-note-button').hidden = !editable; document.getElementById('zotero-item-pane-content').selectedIndex = 2; @@ -285,7 +285,7 @@ var ZoteroItemPane = new function() { this.showNoteWindowMessage = function () { - ZoteroPane.setItemPaneMessage(Zotero.getString('pane.item.notes.editingInWindow')); + // ZoteroPane.setItemPaneMessage(Zotero.getString('pane.item.notes.editingInWindow')); }; diff --git a/chrome/content/zotero/itemPane.xul b/chrome/content/zotero/itemPane.xul index 81796b2649..a2d61c11b6 100644 --- a/chrome/content/zotero/itemPane.xul +++ b/chrome/content/zotero/itemPane.xul @@ -115,7 +115,7 @@ --> + onerror="return;ZoteroPane.displayErrorMessage(); /*this.mode = 'view'*/"/> - - - - + + + + + + + + + @@ -576,12 +577,9 @@ oncommand="ZoteroPane.updateToolbarPosition(); ZoteroPane.updateTagsBoxSize()"> - - - - - - + + + diff --git a/pdf-reader b/pdf-reader index acd34e852d..3d949e7c83 160000 --- a/pdf-reader +++ b/pdf-reader @@ -1 +1 @@ -Subproject commit acd34e852d38768c8f31e59aa7238d4d934081eb +Subproject commit 3d949e7c83a19af8afdd1de0fdb3f95dde4f4dea diff --git a/pdf-worker b/pdf-worker index 9067fc6a92..886e714d33 160000 --- a/pdf-worker +++ b/pdf-worker @@ -1 +1 @@ -Subproject commit 9067fc6a9245019b0a4670f8a2b5d81f9f36ad0f +Subproject commit 886e714d3344534b66370010e855ea857f460627 diff --git a/zotero-note-editor b/zotero-note-editor index 3e8ec22246..156459aaf4 160000 --- a/zotero-note-editor +++ b/zotero-note-editor @@ -1 +1 @@ -Subproject commit 3e8ec222463b2eb05c4fecd4c43b8b629311e583 +Subproject commit 156459aaf4dbba9dc91b10a4e8ecb1142e56484b From 51db52a5ea44d6783e2baf3270ea4d2a4d81e532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Mon, 12 Oct 2020 17:01:24 +0300 Subject: [PATCH 064/193] Changes for note insertion in non-GoogleDocs --- chrome/content/zotero/xpcom/integration.js | 15 +++++---------- test/tests/integrationTest.js | 4 +++- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/chrome/content/zotero/xpcom/integration.js b/chrome/content/zotero/xpcom/integration.js index a77909a3e3..1f977fe323 100644 --- a/chrome/content/zotero/xpcom/integration.js +++ b/chrome/content/zotero/xpcom/integration.js @@ -586,10 +586,7 @@ Zotero.Integration.Interface.prototype.addEditCitation = async function (docFiel await this._session.init(false, false); docField = docField || await this._doc.cursorInField(this._session.data.prefs['fieldType']); - let citations = await this._session.cite(docField); - for (let citation of citations) { - await this._session.addCitation(citation._fieldIndex, await citation._field.getNoteIndex(), citation); - } + await this._session.cite(docField); if (this._session.data.prefs.delayCitationUpdates) { for (let citation of citations) { await this._session.writeDelayedCitation(citation._field, citation); @@ -1447,11 +1444,9 @@ Zotero.Integration.Session.prototype._processNote = function (item) { Zotero.logError(e); } } - // TODO: Later we'll need to convert note HTML to RDF. - // if (Zotero.Integration.currentSession._app.outputFormat == 'rtf') { - // text = return Zotero.RTFConverter.HTMLToRTF(text); - // }); - // } + text = text.replace(/[\u00A0-\u9999\&]/gim, function(i) { + return '&#'+i.charCodeAt(0)+';'; + }); return [text, citations, placeholderIDs]; }; @@ -1465,7 +1460,7 @@ Zotero.Integration.Session.prototype._insertNoteIntoDocument = async function (f citations.reverse(); placeholderIDs.reverse(); let fields = await this._doc.convertPlaceholdersToFields(citations.map(() => 'TEMP'), - placeholderIDs, this.data.prefs.noteType); + placeholderIDs, this.data.prefs.noteType, this.data.prefs.fieldType); let insertedCitations = await Promise.all(fields.map(async (field, index) => { let citation = new Zotero.Integration.Citation(new Zotero.Integration.CitationField(field, 'TEMP'), diff --git a/test/tests/integrationTest.js b/test/tests/integrationTest.js index 8638ec22e2..70064e1e26 100644 --- a/test/tests/integrationTest.js +++ b/test/tests/integrationTest.js @@ -110,10 +110,12 @@ describe("Zotero.Integration", function () { * Converts placeholders (which are text with links to https://www.zotero.org/?[placeholderID]) * to fields and sets their field codes to strings in `codes` in the reverse order of their appearance * @param {String[]} codes + * @param {String[]} placeholderIDs - the order of placeholders to be replaced * @param {Number} noteType - controls whether citations should be in-text or in footnotes/endnotes + * @param {Number} fieldType * @return {Field[]} */ - convertPlaceholdersToFields: function (codes, noteType) { + convertPlaceholdersToFields: function (codes, placeholderIDs, noteType, fieldType) { return codes.map(code => { let field = new DocumentPluginDummy.Field(this); field.code = code; From 8e3dc610706e3432e8c4b72789631716beee137c Mon Sep 17 00:00:00 2001 From: Martynas Bagdonas Date: Mon, 26 Oct 2020 14:09:56 +0200 Subject: [PATCH 065/193] Improve menubar for reader tab and window --- chrome/content/zotero/reader.xul | 61 +++++++++++++--- .../content/zotero/standalone/standalone.js | 25 ++++++- .../content/zotero/standalone/standalone.xul | 71 ++++++++++++++----- chrome/content/zotero/xpcom/reader.js | 17 +++++ 4 files changed, 147 insertions(+), 27 deletions(-) diff --git a/chrome/content/zotero/reader.xul b/chrome/content/zotero/reader.xul index fd166d36d0..7e468c4ca9 100644 --- a/chrome/content/zotero/reader.xul +++ b/chrome/content/zotero/reader.xul @@ -3,10 +3,12 @@ + %globalDTD; + %standaloneDTD; %zoteroDTD; ]> @@ -22,19 +24,54 @@ + - + diff --git a/chrome/content/zotero/zoteroPane.xul b/chrome/content/zotero/zoteroPane.xul index f7d7f23c13..6982774fd3 100644 --- a/chrome/content/zotero/zoteroPane.xul +++ b/chrome/content/zotero/zoteroPane.xul @@ -214,7 +214,7 @@
- + From 36cc18e8cf349eb2e35ae5e5eefb57fb94b51d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Tue, 27 Oct 2020 15:00:23 +0200 Subject: [PATCH 067/193] Add root html tags if not present for note insertion --- chrome/content/zotero/xpcom/integration.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chrome/content/zotero/xpcom/integration.js b/chrome/content/zotero/xpcom/integration.js index 1f977fe323..20e5684f47 100644 --- a/chrome/content/zotero/xpcom/integration.js +++ b/chrome/content/zotero/xpcom/integration.js @@ -1444,9 +1444,13 @@ Zotero.Integration.Session.prototype._processNote = function (item) { Zotero.logError(e); } } - text = text.replace(/[\u00A0-\u9999\&]/gim, function(i) { + // Encode unicode chars + text = text.replace(/[\u00A0-\u9999\&]/gim, function (i) { return '&#'+i.charCodeAt(0)+';'; }); + if (!text.startsWith('')) { + text = `${text}`; + } return [text, citations, placeholderIDs]; }; From bb5075e8b8486902cea6244562b3130ebb60fc8c Mon Sep 17 00:00:00 2001 From: Martynas Bagdonas Date: Tue, 3 Nov 2020 18:29:51 +0200 Subject: [PATCH 068/193] Experiment with the new item pane UI --- .../zotero-platform/mac-big-sur/style.css | 4 +- .../content/zotero-platform/mac/overlay.css | 20 +- .../content/zotero-platform/unix/itemPane.css | 8 +- .../content/zotero-platform/win/overlay.css | 16 +- .../zotero/components/itemPane/notesList.jsx | 54 ++ chrome/content/zotero/itemPane.js | 620 +++++++++++------- chrome/content/zotero/itemPane.xul | 22 +- .../content/zotero/standalone/standalone.xul | 4 +- chrome/content/zotero/tabs.js | 2 +- chrome/content/zotero/xpcom/editorInstance.js | 44 +- chrome/content/zotero/xpcom/reader.js | 8 +- chrome/content/zotero/zoteroPane.js | 36 +- chrome/content/zotero/zoteroPane.xul | 266 ++++---- chrome/skin/default/zotero/itemPane.css | 16 +- pdf-reader | 2 +- scss/_zotero-react-client.scss | 1 + scss/components/_notesList.scss | 53 ++ scss/components/_tagsBox.scss | 12 +- zotero-note-editor | 2 +- 19 files changed, 742 insertions(+), 448 deletions(-) create mode 100644 chrome/content/zotero/components/itemPane/notesList.jsx create mode 100644 scss/components/_notesList.scss diff --git a/chrome/content/zotero-platform/mac-big-sur/style.css b/chrome/content/zotero-platform/mac-big-sur/style.css index e206c02b41..5dd52c6e8d 100644 --- a/chrome/content/zotero-platform/mac-big-sur/style.css +++ b/chrome/content/zotero-platform/mac-big-sur/style.css @@ -1,10 +1,10 @@ /* Use standard tab appearance for item pane tabs */ -#zotero-pane #zotero-view-tabbox > tabs > tab { +#zotero-pane .zotero-view-tabbox > tabs > tab { -moz-appearance: tab; } /* Active tab label color in item pane and elsewhere */ -#zotero-pane tabs#zotero-editpane-tabs > tab[visuallyselected="true"][selected="true"] hbox > .tab-text, +#zotero-pane tabs.zotero-editpane-tabs > tab[visuallyselected="true"][selected="true"] hbox > .tab-text, #zotero-prefs tab[visuallyselected="true"]:not(:-moz-window-inactive), tabs > tab[visuallyselected="true"] hbox > .tab-text { color: black !important; diff --git a/chrome/content/zotero-platform/mac/overlay.css b/chrome/content/zotero-platform/mac/overlay.css index 6b3ac0bd92..c6b04b809a 100644 --- a/chrome/content/zotero-platform/mac/overlay.css +++ b/chrome/content/zotero-platform/mac/overlay.css @@ -107,12 +107,12 @@ input { margin: 0; } -#zotero-view-tabbox { +.zotero-view-tabbox { background-color: #fff; padding: 0; } -#zotero-item-pane-content .groupbox-body { +.zotero-item-pane-content .groupbox-body { -moz-appearance: none; background-color: #ffffff; } @@ -121,13 +121,13 @@ input { color: #7f7f7f; } -#zotero-view-tabbox > tabpanels { +.zotero-view-tabbox > tabpanels { margin: 12px 0 0 0; padding: 0; -moz-appearance: none; } -#zotero-editpane-tabs { +.zotero-editpane-tabs { -moz-appearance: none; background: -moz-linear-gradient(top, #ededed, #cccccc); border-style: solid; @@ -136,15 +136,15 @@ input { padding: 2px 0 2px 0; } -#zotero-editpane-tabs > tab > hbox { +.zotero-editpane-tabs > tab > hbox { padding: 0; } -#zotero-editpane-tabs > tab > hbox > .tab-icon { +.zotero-editpane-tabs > tab > hbox > .tab-icon { display: none; } -#zotero-editpane-tabs > tab { +.zotero-editpane-tabs > tab { -moz-box-orient: vertical; -moz-box-align: center; -moz-appearance: toolbarbutton; @@ -153,7 +153,7 @@ input { padding: 3px 1px 3px 1px; } -#zotero-editpane-tabs > tab > hbox .tab-text { +.zotero-editpane-tabs > tab > hbox .tab-text { font-size: 11px; font-weight: bold; margin: 2px 7px 2px 9px !important; @@ -161,11 +161,11 @@ input { } /* This seems to be necessary to center the tabs. Not sure why. */ -#zotero-editpane-tabs > tab:last-of-type > hbox .tab-text { +.zotero-editpane-tabs > tab:last-of-type > hbox .tab-text { margin: 2px 9px 2px 9px !important; } -#zotero-editpane-tabs > tab[selected=true] > hbox .tab-text { +.zotero-editpane-tabs > tab[selected=true] > hbox .tab-text { color: #FFF !important; text-shadow: rgba(0, 0, 0, 0.4) 0 1px; } diff --git a/chrome/content/zotero-platform/unix/itemPane.css b/chrome/content/zotero-platform/unix/itemPane.css index 999e699e3d..82e8f78040 100644 --- a/chrome/content/zotero-platform/unix/itemPane.css +++ b/chrome/content/zotero-platform/unix/itemPane.css @@ -11,20 +11,20 @@ visibility: visible; } -#zotero-item-pane-content { +.zotero-item-pane-content { margin-right: 6px; } /* Make the item pane appear white (same colour as treeview), making the UI more consistent */ -#zotero-item-pane-content tab, #zotero-item-pane-content tabpanels { +.zotero-item-pane-content tab, .zotero-item-pane-content tabpanels { background-color: -moz-Field; /* Same as background colour for treeview */ } /* Possibly irrelevant if mozilla fixes https://bugzilla.mozilla.org/show_bug.cgi?id=1306425 */ -#zotero-view-tabbox tabs tab[visuallyselected=true] { +.zotero-view-tabbox tabs tab[visuallyselected=true] { margin-top: 0px !important; /* Importanter than ./itemPane.css:20 */ margin-bottom: -2px !important; /* Importanter than skin/itemPane.css:12 */ } -#zotero-view-tabbox tabs tab { +.zotero-view-tabbox tabs tab { margin-top: 2px !important; /* Importanter than skin/itemPane.css:11 */ } diff --git a/chrome/content/zotero-platform/win/overlay.css b/chrome/content/zotero-platform/win/overlay.css index 2bd81b6819..e41d0fd08d 100644 --- a/chrome/content/zotero-platform/win/overlay.css +++ b/chrome/content/zotero-platform/win/overlay.css @@ -49,7 +49,7 @@ padding-top: 4px; } -#zotero-view-tabbox tab { +.zotero-view-tabbox tab { padding-left: .7em; padding-right: .7em; } @@ -99,7 +99,7 @@ display: none; } -#zotero-collections-tree, #zotero-items-tree, #zotero-view-item { +#zotero-collections-tree, #zotero-items-tree, .zotero-view-item { -moz-appearance: none; border-style: solid; border-color: #818790; @@ -142,11 +142,11 @@ tree { margin: .04em 0 0 .15em !important; } -#zotero-editpane-tabs spacer { +.zotero-editpane-tabs spacer { border: 0; } -#zotero-view-item { +.zotero-view-item { padding: 0 !important; -moz-appearance: none; background-color: -moz-field; @@ -154,7 +154,7 @@ tree { border-color: var(--theme-border-color); } -#zotero-editpane-tabs { +.zotero-editpane-tabs { margin-top: 2px; } @@ -163,8 +163,8 @@ tree { border-width: 0; } -#zotero-editpane-item-box > scrollbox, #zotero-view-item > tabpanel > vbox, -#zotero-editpane-tags > scrollbox, #zotero-editpane-related { +.zotero-editpane-item-box > scrollbox, .zotero-view-item > tabpanel > vbox, +#zotero-editpane-tags > scrollbox, .zotero-editpane-related { padding-top: 5px; } @@ -172,6 +172,6 @@ tree { padding-left: 5px; } -#zotero-view-item > tabpanel > vbox { +.zotero-view-item > tabpanel > vbox { padding-left: 5px; } \ No newline at end of file diff --git a/chrome/content/zotero/components/itemPane/notesList.jsx b/chrome/content/zotero/components/itemPane/notesList.jsx new file mode 100644 index 0000000000..3496d888d1 --- /dev/null +++ b/chrome/content/zotero/components/itemPane/notesList.jsx @@ -0,0 +1,54 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2020 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://www.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 . + + ***** END LICENSE BLOCK ***** +*/ + +import React, { forwardRef, useImperativeHandle, useState } from 'react'; + +const NoteRow = ({ title, body, date, onClick }) => { + return ( +
+
+
+
{title}
+
+
+
{date}
+
{body}
+
+
+
+ ); +}; + +const NotesList = forwardRef(({ onClick }, ref) => { + const [notes, setNotes] = useState([]); + useImperativeHandle(ref, () => ({ setNotes })); + return ( +
+ {notes.map(note => onClick(note.id)}/>)} +
+ ); +}); + +export default NotesList; diff --git a/chrome/content/zotero/itemPane.js b/chrome/content/zotero/itemPane.js index 36d8350f67..0b0494bb51 100644 --- a/chrome/content/zotero/itemPane.js +++ b/chrome/content/zotero/itemPane.js @@ -26,13 +26,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; import TagsBoxContainer from 'containers/tagsBoxContainer'; +import NotesList from 'components/itemPane/notesList'; var ZoteroItemPane = new function() { var _lastItem, _itemBox, _notesLabel, _notesButton, _notesList, _tagsBox, _relatedBox; var _selectedNoteID; var _translationTarget; var _noteIDs; - var _recentlyPinned = []; + var _contextNoteUpdaters = []; + let _pdfTabHidden = false; this.onLoad = function () { if (!Zotero) { @@ -64,7 +66,7 @@ var ZoteroItemPane = new function() { document.getElementById('temp-toggle-2').addEventListener('click', () => { this.togglePane(); }); - this.initPinnedView(); + this.initStandaloneNotesView(); } @@ -234,6 +236,10 @@ var ZoteroItemPane = new function() { yield this.viewItem(_lastItem, null, 1); } } + this.updateStandaloneNotesList(); + for (let updater of _contextNoteUpdaters) { + updater.callback(); + } } else if (type == 'tab') { if (action == 'add') { @@ -301,6 +307,7 @@ var ZoteroItemPane = new function() { // noteEditor.clearUndo(); // } + document.getElementById('zotero-view-note-sidebar-button').hidden = !!item.parentID; document.getElementById('zotero-view-note-button').hidden = !editable; document.getElementById('zotero-item-pane-content').selectedIndex = 2; }; @@ -310,6 +317,17 @@ var ZoteroItemPane = new function() { // ZoteroPane.setItemPaneMessage(Zotero.getString('pane.item.notes.editingInWindow')); }; + this.openNoteSidebar = async function () { + var selectedNote = Zotero.Items.get(_selectedNoteID); + + if (!selectedNote.parentID) { + let editor = document.getElementById('zotero-item-pane-pinned-note'); + editor.mode = 'edit'; + editor.item = selectedNote; + document.getElementById('zotero-item-pane-pin-deck2').setAttribute('selectedIndex', 1); + this.togglePane(false); + } + } /** * Select the parent item and open the note editor @@ -457,7 +475,27 @@ var ZoteroItemPane = new function() { elem.setAttribute('tooltiptext', tooltip); }; - this.getActiveNote = function() { + + function _updateNoteCount() { + var c = _notesList.childNodes.length; + + var str = 'pane.item.notes.count.'; + switch (c){ + case 0: + str += 'zero'; + break; + case 1: + str += 'singular'; + break; + default: + str += 'plural'; + break; + } + + _notesLabel.value = Zotero.getString(str, [c]); + } + + this.getActiveNote = function () { let mainDeck = document.getElementById('zotero-item-pane-main-deck'); if (mainDeck.selectedIndex == 0) { let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); @@ -476,20 +514,8 @@ var ZoteroItemPane = new function() { } return null; } - - window.addEventListener('mousedown', () => { - Zotero.debug('active note') - Zotero.debug(this.getActiveNote()) - }); - - this.pinNote = function(itemID) { - _recentlyPinned.unshift(itemID); - _recentlyPinned = _recentlyPinned.slice(0, 10); - this._setPinnedNote(itemID); - this._updatePinnedList(); - } - - this.togglePane = function(forceItem) { + + this.togglePane = function (forceItem) { let mainDeck = document.getElementById('zotero-item-pane-main-deck'); let value; if (forceItem !== undefined) { @@ -509,15 +535,16 @@ var ZoteroItemPane = new function() { document.getElementById('temp-toggle-1').append('□ ■'); document.getElementById('temp-toggle-2').append('□ ■'); } - - mainDeck.selectedIndex = value; - } - - this.initPinnedView = async function () { - + mainDeck.selectedIndex = value; + + let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); + contextualDeck.children[contextualDeck.selectedIndex].setAttribute('state', value); + } + + this.initStandaloneNotesView = async function () { let container = document.getElementById('zotero-item-pane-pin-deck'); - + let bar = document.createElement('hbox'); container.appendChild(bar); let inner = document.createElement('deck'); @@ -525,109 +552,145 @@ var ZoteroItemPane = new function() { inner.style.backgroundColor = 'white'; inner.setAttribute('flex', 1); container.appendChild(inner); - + let returnButton = document.createElement('toolbarbutton'); returnButton.className = 'zotero-tb-button'; returnButton.id = 'zotero-tb-return'; - returnButton.style.listStyleImage = "url('chrome://zotero/skin/citation-delete.png')" + returnButton.style.listStyleImage = 'url(\'chrome://zotero/skin/citation-delete.png\')' returnButton.addEventListener('click', () => { inner.setAttribute('selectedIndex', 0); }); - - bar.append(returnButton, 'Pinned note'); + + bar.append(returnButton, 'Standalone Notes'); bar.style.overflowX = 'hidden'; bar.style.textOverflow = 'ellipsis'; bar.style.fontWeight = 'bold'; - - - + + let list = document.createElement('vbox'); list.setAttribute('flex', 1); list.className = 'zotero-box'; - - - - + + let note = document.createElement('zoteronoteeditor'); note.id = 'zotero-item-pane-pinned-note'; note.setAttribute('flex', 1); inner.appendChild(list); inner.appendChild(note); - + inner.setAttribute('selectedIndex', 0); - - + + let head = document.createElement('hbox'); - head.setAttribute('align', 'center'); let label = document.createElement('label'); let button = document.createElement('button'); - button.setAttribute('label', 'Add'); + button.setAttribute('label', Zotero.Intl.strings['zotero.item.add']); button.addEventListener('click', async () => { inner.setAttribute('selectedIndex', 1); let item = new Zotero.Item('note'); - item.libraryID = parentItem.libraryID; - item.parentKey = parentItem.key; + item.libraryID = ZoteroPane_Local.getSelectedLibraryID(); + // item.parentKey = parentItem.key; note.mode = 'edit'; note.item = item; note.parentItem = null; + note.focus(); }); - head.append(label, button); - let grid = document.createElement('grid'); - grid.setAttribute('flex', 1); - grid.style.overflowY = 'scroll'; - let columns = document.createElement('columns'); - let column1 = document.createElement('column'); - column1.setAttribute('flex', 1); - let column2 = document.createElement('column'); - columns.append(column1, column2); - grid.append(columns); - let rows = document.createElement('rows'); - rows.setAttribute('flex', 1); - rows.id = 'zotero-item-pane-pinned-list'; - grid.append(rows); + head.style.paddingRight = '10px'; - list.append(head, grid); - this._updatePinnedList(); - } - - this._updatePinnedList = async function() { - let rows = document.getElementById('zotero-item-pane-pinned-list'); - while (rows.hasChildNodes()) { - rows.removeChild(rows.firstChild); + let input = document.createElement('textbox'); + input.setAttribute('type', 'search'); + input.setAttribute('timeout', '250'); + + input.addEventListener('command', (event) => { + _updateStandaloneNotesList(); + }) + + + let vbox1 = document.createElement('vbox'); + vbox1.append(label, button); + + let vbox2 = document.createElement('vbox'); + vbox2.append(input); + + head.append(vbox2, vbox1); + + head.style.display = 'flex'; + vbox2.style.flex = '1'; + + + let listBox = document.createElement('vbox'); + listBox.style.display = 'flex'; + listBox.setAttribute('flex', '1') + + const HTML_NS = 'http://www.w3.org/1999/xhtml'; + var listInner = document.createElementNS(HTML_NS, 'div'); + listInner.className = 'notes-list-container'; + list.append(head, listBox); + + listBox.append(listInner); + + let noteListRef = React.createRef(); + + let _updateStandaloneNotesList = async (reset) => { + if (reset) { + input.value = ''; + inner.setAttribute('selectedIndex', 0); + } + let text = input.value; + + await Zotero.Schema.schemaUpdatePromise; + var s = new Zotero.Search(); + s.addCondition('libraryID', 'is', ZoteroPane_Local.getSelectedLibraryID()); + s.addCondition('itemType', 'is', 'note'); + s.addCondition('noChildren', 'true'); + if (text) { + s.addCondition('note', 'contains', text, true); + } + let notes = await s.search(); + notes = Zotero.Items.get(notes); + notes.sort((a, b) => { + a = a.getField('dateModified'); + b = b.getField('dateModified'); + return b.localeCompare(a); + }); + + noteListRef.current.setNotes(notes.map(note => { + let text2 = note.note; + text2 = text2.trim(); + // TODO: Fix a potential performance issuse + text2 = Zotero.Utilities.unescapeHTML(text2); + let parts = text2.split('\n').map(x => x.trim()).filter(x => x.length); + return { + id: note.id, + title: parts[0] || Zotero.getString('pane.item.notes.untitled'), + body: parts[1] || '', + date: (new Date(note.dateModified).toLocaleDateString(Zotero.locale)) + }; + })); + + var c = notes.length; + var str = 'pane.item.notes.count.' + (c == 0 && 'zero' || c == 1 && 'singular' || 'plural'); + label.value = Zotero.getString(str, [c]); } - - await Zotero.Schema.schemaUpdatePromise; - var s = new Zotero.Search(); - s.addCondition('libraryID', 'is', 1); - s.addCondition('itemType', 'is', 'note'); - s.addCondition('noChildren', 'true'); - let notes = await s.search(); - notes = Zotero.Items.get(notes); - notes.sort((a, b) => { - a = a.getField('dateModified'); - b = b.getField('dateModified'); - return b.localeCompare(a); - }); - - var row = document.createElement('row'); - row.style.fontWeight = 'bold'; - row.appendChild(document.createTextNode('Recently pinned notes')); - rows.append(row); - this._appendNoteRows(Zotero.Items.get(_recentlyPinned), rows, false, (id) => { - this._setPinnedNote(id); - }); - - row = document.createElement('row'); - row.style.fontWeight = 'bold'; - row.appendChild(document.createTextNode('Recently edited standalone notes')); - rows.append(row); - this._appendNoteRows(notes, rows, false, (id) => { - this._setPinnedNote(id); - }); + ReactDOM.render( + { + this._setPinnedNote(id); + }} + />, + listInner, + () => { + _updateStandaloneNotesList(); + } + ); + + + this.updateStandaloneNotesList = _updateStandaloneNotesList; } this._setPinnedNote = function (itemID) { @@ -683,132 +746,25 @@ var ZoteroItemPane = new function() { list.appendChild(row); } } - - this.addPDFTabContext = function(tabID, itemID) { - let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); - - let container = document.createElement('vbox'); - container.id = tabID + '-context'; - - let bar = document.createElement('hbox'); - container.appendChild(bar); - let inner = document.createElement('deck'); - inner.style.backgroundColor = 'white'; - inner.setAttribute('flex', 1); - container.appendChild(inner); - - contextualDeck.appendChild(container); - - let item = Zotero.Items.get(itemID); - if (!item.parentID) { - inner.append("The PDF doesn't have a parent"); - return; - } - - let parentItem = Zotero.Items.get(item.parentID); - let returnButton = document.createElement('toolbarbutton'); - returnButton.className = 'zotero-tb-button'; - returnButton.id = 'zotero-tb-return'; - returnButton.style.listStyleImage = "url('chrome://zotero/skin/citation-delete.png')" - returnButton.addEventListener('click', () => { - inner.setAttribute('selectedIndex', 0); - }); - - bar.append(returnButton, parentItem.getField('title')); - bar.style.overflowX = 'hidden'; - bar.style.textOverflow = 'ellipsis'; - bar.style.fontWeight = 'bold'; - - let list = document.createElement('vbox'); - list.setAttribute('flex', 1); - list.className = 'zotero-box'; - - let note = document.createElement('zoteronoteeditor'); - note.setAttribute('flex', 1); - inner.appendChild(list); - inner.appendChild(note); - inner.setAttribute('selectedIndex', 0); - - note.placeholder = 'Drag annotations and write item-specific notes'; - - let head = document.createElement('hbox'); - head.setAttribute('align', 'center'); - let label = document.createElement('label'); - let button = document.createElement('button'); - button.setAttribute('label', 'Add'); - button.addEventListener('click', async () => { - inner.setAttribute('selectedIndex', 1); - let item = new Zotero.Item('note'); - item.libraryID = parentItem.libraryID; - item.parentKey = parentItem.key; - await item.saveTx(); - note.mode = 'edit'; - note.item = item; - note.parentItem = null; - _updateList(); - }); - head.append(label, button); - - let grid = document.createElement('grid'); - grid.setAttribute('flex', 1); - let columns = document.createElement('columns'); - let column1 = document.createElement('column'); - column1.setAttribute('flex', 1); - let column2 = document.createElement('column'); - columns.append(column1, column2); - grid.append(columns); - let rows = document.createElement('rows'); - rows.setAttribute('flex', 1); - grid.append(rows); - - list.append(head, grid); - - let parentNotes = parentItem.getNotes(); - if (parentNotes.length == 0) { - inner.setAttribute('selectedIndex', 1); - let item = new Zotero.Item('note'); - item.libraryID = parentItem.libraryID; - item.parentKey = parentItem.key; - note.mode = 'edit'; - note.item = item; - note.parentItem = null; - } - else if (parentNotes.length == 1) { - inner.setAttribute('selectedIndex', 1); - note.mode = 'edit'; - note.item = Zotero.Items.get(parentNotes[0]); - note.parentItem = null; - } - - let _updateList = () => { - while (rows.hasChildNodes()) { - rows.removeChild(rows.firstChild); - } - let parentNotes = Zotero.Items.get(parentItem.getNotes()); - this._appendNoteRows(parentNotes, rows, true, (id) => { - inner.setAttribute('selectedIndex', 1); - note.mode = 'edit'; - note.item = Zotero.Items.get(id); - note.parentItem = null; - }, (id) => { - ZoteroItemPane.removeNote(id); - }); - } - - _updateList(); - } - - this.removeTabContext = function(tabID) { + + this.removeTabContext = function (tabID) { document.getElementById(tabID + '-context').remove(); + _contextNoteUpdaters = _contextNoteUpdaters.filter(x => x.tabID != tabID); }; - this.selectTabContext = function(tabID, type) { + this.selectTabContext = function (tabID, type) { let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); + let prevIndex = contextualDeck.selectedIndex; contextualDeck.selectedIndex = Array.from(contextualDeck.children).findIndex(x => x.id == tabID + '-context'); let toolbar = document.getElementById('zotero-pane-horizontal-space'); let extendedToolbar = document.getElementById('zotero-item-pane-padding-top'); let itemPane = document.getElementById('zotero-item-pane'); + + if (prevIndex != 0) { + _pdfTabHidden = itemPane.hidden; + } + if (type == 'library') { toolbar.hidden = false; extendedToolbar.hidden = true; @@ -817,27 +773,235 @@ var ZoteroItemPane = new function() { else { toolbar.hidden = true; extendedToolbar.hidden = false; + itemPane.hidden = _pdfTabHidden; + } + + let state = contextualDeck.children[contextualDeck.selectedIndex].getAttribute('state'); + let mainDeck = document.getElementById('zotero-item-pane-main-deck'); + document.getElementById('temp-toggle-1').firstChild.remove(); + document.getElementById('temp-toggle-2').firstChild.remove(); + if (state == 0) { + document.getElementById('temp-toggle-1').append('■ □'); + document.getElementById('temp-toggle-2').append('■ □'); + mainDeck.selectedIndex = state; + } + else if (state == 1) { + document.getElementById('temp-toggle-1').append('□ ■'); + document.getElementById('temp-toggle-2').append('□ ■'); + mainDeck.selectedIndex = state; } }; - - - function _updateNoteCount() { - var c = _notesList.childNodes.length; - - var str = 'pane.item.notes.count.'; - switch (c){ - case 0: - str += 'zero'; - break; - case 1: - str += 'singular'; - break; - default: - str += 'plural'; - break; + + + this.addPDFTabContext = function (tabID, itemID) { + let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); + + let container = document.createElement('vbox'); + container.id = tabID + '-context'; + container.className = 'zotero-item-pane-content'; + contextualDeck.appendChild(container); + + var item = Zotero.Items.get(itemID); + if (!item.parentID) { + container.append('The PDF doesn\'t have a parent'); + return; } - - _notesLabel.value = Zotero.getString(str, [c]); + + let parentID = item.parentID; + + let mainDeck = document.getElementById('zotero-item-pane-main-deck'); + let pinDeck = document.getElementById('zotero-item-pane-pin-deck2'); + container.setAttribute('state', (mainDeck.selectedIndex == 1 && pinDeck.selectedIndex == 1) ? 1 : 0) + + + let parentItem = Zotero.Items.get(parentID); + + let tabbox = document.createElement('tabbox'); + tabbox.setAttribute('flex', '1'); + tabbox.className = 'zotero-view-tabbox'; + let tabs = document.createElement('tabs'); + tabs.className = 'zotero-editpane-tabs'; + + container.append(tabbox); + + + let tabInfo = document.createElement('tab'); + tabInfo.setAttribute('label', Zotero.Intl.strings['zotero.tabs.info.label']); + let tabNotes = document.createElement('tab'); + tabNotes.setAttribute('label', Zotero.Intl.strings['zotero.tabs.notes.label']); + let tabTags = document.createElement('tab'); + tabTags.setAttribute('label', Zotero.Intl.strings['zotero.tabs.tags.label']); + let tabRelated = document.createElement('tab'); + tabRelated.setAttribute('label', Zotero.Intl.strings['zotero.tabs.related.label']); + tabs.append(tabInfo, tabNotes, tabTags, tabRelated); + + let tabpanels = document.createElement('tabpanels'); + tabpanels.setAttribute('flex', '1'); + tabpanels.className = 'zotero-view-item'; + + tabbox.append(tabs, tabpanels); + + let panelInfo = document.createElement('tabpanel'); + panelInfo.setAttribute('flex', '1') + panelInfo.className = 'zotero-editpane-item-box'; + let itemBox = document.createElement('zoteroitembox'); + itemBox.setAttribute('flex', '1'); + panelInfo.append(itemBox); + + let panelNotes = document.createElement('tabpanel'); + panelNotes.setAttribute('flex', '1'); + panelNotes.setAttribute('orient', 'vertical'); + + var deck = document.createElement('deck'); + deck.setAttribute('flex', '1'); + + panelNotes.append(deck); + + var vbox2 = document.createElement('vbox'); + + + let returnButton = document.createElement('toolbarbutton'); + returnButton.className = 'zotero-tb-button'; + returnButton.id = 'zotero-tb-return'; + returnButton.style.listStyleImage = 'url(\'chrome://zotero/skin/citation-delete.png\')' + returnButton.addEventListener('click', () => { + deck.setAttribute('selectedIndex', 0); + }); + + var bar = document.createElement('hbox') + bar.append(returnButton, 'Child Notes'); + bar.style.overflowX = 'hidden'; + bar.style.textOverflow = 'ellipsis'; + bar.style.fontWeight = 'bold'; + + let note = document.createElement('zoteronoteeditor'); + + note.setAttribute('flex', 1); + + vbox2.append(bar, note); + + + var vbox = document.createElement('vbox'); + vbox.setAttribute('flex', '1'); + vbox.setAttribute('class', 'zotero-box'); + panelNotes.append(vbox); + + var hbox = document.createElement('hbox'); + hbox.setAttribute('align', 'center'); + + var label = document.createElement('label'); + var button = document.createElement('button'); + button.setAttribute('label', Zotero.Intl.strings['zotero.item.add']); + button.addEventListener('click', () => { + deck.setAttribute('selectedIndex', 1); + let item = new Zotero.Item('note'); + item.libraryID = parentItem.libraryID; + item.parentItemID = parentItem.id; + note.mode = 'edit'; + note.item = item; + note.focus(); + }); + hbox.append(label, button); + + var grid = document.createElement('grid'); + grid.setAttribute('flex', 1); + var columns = document.createElement('columns'); + var column = document.createElement('column'); + column.setAttribute('flex', 1); + columns.append(column); + var column = document.createElement('column'); + columns.append(column); + grid.append(columns); + var rows = document.createElement('rows'); + rows.setAttribute('flex', 1); + grid.append(rows); + + vbox.append(hbox, grid); + + deck.append(vbox, vbox2); + + deck.setAttribute('selectedIndex', 0); + deck.className = 'zotero-item-pane-content'; + + + let panelTags = document.createElement('tabpanel'); + panelTags.setAttribute('orient', 'vertical'); + panelTags.setAttribute('context', 'tags-context-menu'); + panelTags.className = 'tags-pane'; + panelTags.style.display = 'flex'; + const HTML_NS = 'http://www.w3.org/1999/xhtml'; + var div = document.createElementNS(HTML_NS, 'div'); + div.className = 'tags-box-container'; + div.style.display = 'flex'; + div.style.flexGrow = '1'; + panelTags.append(div); + + let panelRelated = document.createElement('tabpanel'); + let relatedBox = document.createElement('relatedbox'); + relatedBox.setAttribute('flex', '1'); + relatedBox.className = 'zotero-editpane-related'; + panelRelated.append(relatedBox); + + tabpanels.append(panelInfo, panelNotes, panelTags, panelRelated); + + itemBox.mode = 'edit'; + itemBox.item = Zotero.Items.get(parentID); + + relatedBox.mode = 'edit'; + relatedBox.item = parentItem; + + panelRelated.addEventListener('click', (event) => { + if (event.originalTarget.closest('.zotero-clicky')) { + Zotero_Tabs.select('zotero-pane'); + } + }); + + let _renderNotesPanel = () => { + while (rows.firstChild) { + rows.firstChild.remove(); + } + + let parentNotes = Zotero.Items.get(parentItem.getNotes()); + this._appendNoteRows(parentNotes, rows, true, (id) => { + deck.setAttribute('selectedIndex', 1); + note.mode = 'edit'; + note.item = Zotero.Items.get(id); + note.parentItem = null; + }, (id) => { + ZoteroItemPane.removeNote(id); + }); + + var c = parentNotes.length; + var str = 'pane.item.notes.count.' + (c == 0 && 'zero' || c == 1 && 'singular' || 'plural'); + label.value = Zotero.getString(str, [c]); + } + + _contextNoteUpdaters.push({ + tabID, + callback: _renderNotesPanel + }); + + _renderNotesPanel(); + + let mode = 'edit'; + + let _tagsBox = { current: null }; + let focusItemsList = false; + + let _renderTagsPanel = () => { + ReactDOM.render( + , + div + ); + } + + _renderTagsPanel(); } } diff --git a/chrome/content/zotero/itemPane.xul b/chrome/content/zotero/itemPane.xul index 138e8aabe1..c311ae4442 100644 --- a/chrome/content/zotero/itemPane.xul +++ b/chrome/content/zotero/itemPane.xul @@ -69,23 +69,23 @@ + - + + + diff --git a/chrome/content/zotero/zoteroPane.xul b/chrome/content/zotero/zoteroPane.xul index ed772aa7bb..32d1c6adc4 100644 --- a/chrome/content/zotero/zoteroPane.xul +++ b/chrome/content/zotero/zoteroPane.xul @@ -214,7 +214,7 @@
- + diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index 05253425f0..26c88b7f80 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -77,6 +77,7 @@ general.describeProblem = Briefly describe the problem: general.nMegabytes = %S MB general.item = Item general.pdf = PDF +general.back = Back general.operationInProgress = A Zotero operation is currently in progress. general.operationInProgress.waitUntilFinished = Please wait until it has finished. @@ -364,6 +365,7 @@ pane.item.switchFieldMode.two = Switch to two fields pane.item.creator.moveToTop = Move to Top pane.item.creator.moveUp = Move Up pane.item.creator.moveDown = Move Down +pane.item.notes.allNotes = All Notes pane.item.notes.untitled = Untitled Note pane.item.notes.delete.confirm = Are you sure you want to delete this note? pane.item.notes.count.zero = %S notes: diff --git a/chrome/skin/default/zotero/itemPane.css b/chrome/skin/default/zotero/itemPane.css index f3459a881d..9a0b79d5f3 100644 --- a/chrome/skin/default/zotero/itemPane.css +++ b/chrome/skin/default/zotero/itemPane.css @@ -45,6 +45,52 @@ margin-left: 5px; } +#zotero-item-pane[data-type='library'] #item-pane-title, +#zotero-item-pane[data-mode='notes'] #item-pane-title { + display: none; +} + +#item-pane-title { + font-size: 16px; + padding: 0 10px 6px; +} + +#zotero-item-pane-contextual-deck .item-pane-back-button { + font-size: 12px; + margin: 4px 5px 5px; +} + +#zotero-item-pane-pin-deck .item-pane-back-button { + font-size: 12px; + margin: 7px 6px; +} + +#zotero-item-pane-padding-top { + /*border-bottom: 1px solid lightgray;*/ +} + +#zotero-item-pane-pinned-note { + border-top: 1px solid #d9d9d9; +} + +.zotero-editpane-tabs { + background: #ececec; +} + +#temp-toggle-1.mode-item, #temp-toggle-2.mode-item { + list-style-image: url(chrome://zotero/skin/treeitem.png); +} + +#temp-toggle-1.mode-notes, #temp-toggle-2.mode-notes { + list-style-image: url(chrome://zotero/skin/treeitem-note.png); +} + +.notes-list-label { + font-size: 16px; + font-weight: bold; + padding: 8px 10px 5px; +} + #retraction-box { cursor: default; } diff --git a/chrome/skin/default/zotero/overlay.css b/chrome/skin/default/zotero/overlay.css index 0a5835f484..d3cbb84ef6 100644 --- a/chrome/skin/default/zotero/overlay.css +++ b/chrome/skin/default/zotero/overlay.css @@ -710,7 +710,7 @@ .zotero-box { - margin-left: 5px; + /*margin-left: 5px;*/ } .zotero-box-icon { diff --git a/scss/components/_notesList.scss b/scss/components/_notesList.scss index 28d1523ab1..2bddfcda46 100644 --- a/scss/components/_notesList.scss +++ b/scss/components/_notesList.scss @@ -13,12 +13,16 @@ } .note-row { - padding: 8px 0; + padding: 8px 12px; &:not(:last-child) { border-bottom: 1px solid $shade-3; } - + + &:active { + background: #e2e2e2; + } + .inner { .first-line { display: flex; From 45a3e96e68c369b42208d5245c8a366a56886bca Mon Sep 17 00:00:00 2001 From: Martynas Bagdonas Date: Tue, 10 Nov 2020 10:59:34 +0200 Subject: [PATCH 071/193] Improve the new UI: - Rollback all redundant changes made in the last few months - Introduce `contextPane` - Show child notes in the notes pane - Fix splitter styling - Various bug fixes - Fix contextPane switching and states persistence - Persist reader sidebar open/close state - Fix bottom pane placeholder updating concurrency issues - Fix toolbar placeholder width updating - Display titles for split button - Fix toolbar position when switching tabs - Add PDF tab loading cover - Improve notes and citations insertion - Clean up and refactor code - Fixes and cleanups to PDF reader --- .gitmodules | 6 +- .../content/zotero-platform/mac/overlay.css | 46 +- .../content/zotero-platform/unix/overlay.css | 6 + chrome/content/zotero/bindings/noteeditor.xml | 37 +- .../zotero/components/itemPane/notesList.jsx | 10 +- chrome/content/zotero/contextPane.js | 791 +++++++++++++++ chrome/content/zotero/itemPane.js | 569 +---------- chrome/content/zotero/itemPane.xul | 234 ++--- chrome/content/zotero/reader.xul | 25 +- .../content/zotero/standalone/standalone.js | 3 +- chrome/content/zotero/tabs.js | 6 +- chrome/content/zotero/xpcom/editorInstance.js | 91 +- chrome/content/zotero/xpcom/reader.js | 311 +++--- chrome/content/zotero/zoteroPane.js | 5 +- chrome/content/zotero/zoteroPane.xul | 933 +++++++++--------- chrome/locale/en-US/zotero/zotero.dtd | 2 + chrome/locale/en-US/zotero/zotero.properties | 2 + chrome/skin/default/zotero/contextPane.css | 71 ++ chrome/skin/default/zotero/itemPane.css | 46 +- chrome/skin/default/zotero/mac/item-white.png | Bin 0 -> 1446 bytes .../skin/default/zotero/mac/item-white@2x.png | Bin 0 -> 1484 bytes chrome/skin/default/zotero/mac/item.png | Bin 0 -> 166 bytes chrome/skin/default/zotero/mac/item@2x.png | Bin 0 -> 211 bytes .../mac/menubutton-end-active-pressed.png | Bin 0 -> 1505 bytes .../mac/menubutton-end-active-pressed@2x.png | Bin 0 -> 1679 bytes .../zotero/mac/menubutton-end-active.png | Bin 0 -> 233 bytes .../zotero/mac/menubutton-end-active@2x.png | Bin 0 -> 399 bytes .../mac/menubutton-start-active-pressed.png | Bin 0 -> 1558 bytes .../menubutton-start-active-pressed@2x.png | Bin 0 -> 1819 bytes .../zotero/mac/menubutton-start-active.png | Bin 0 -> 282 bytes .../zotero/mac/menubutton-start-active@2x.png | Bin 0 -> 543 bytes .../skin/default/zotero/mac/notes-white.png | Bin 0 -> 204 bytes .../default/zotero/mac/notes-white@2x.png | Bin 0 -> 286 bytes chrome/skin/default/zotero/mac/notes.png | Bin 0 -> 1531 bytes chrome/skin/default/zotero/mac/notes@2x.png | Bin 0 -> 1677 bytes chrome/skin/default/zotero/overlay.css | 16 +- pdf-reader | 2 +- pdf-worker | 2 +- scss/_zotero-react-client.scss | 1 + scss/abstracts/_split-button.scss | 489 +++++++++ scss/components/_notesList.scss | 25 +- scss/components/_tagsBox.scss | 1 - zotero-note-editor | 2 +- 43 files changed, 2313 insertions(+), 1419 deletions(-) create mode 100644 chrome/content/zotero/contextPane.js create mode 100644 chrome/skin/default/zotero/contextPane.css create mode 100644 chrome/skin/default/zotero/mac/item-white.png create mode 100644 chrome/skin/default/zotero/mac/item-white@2x.png create mode 100644 chrome/skin/default/zotero/mac/item.png create mode 100644 chrome/skin/default/zotero/mac/item@2x.png create mode 100644 chrome/skin/default/zotero/mac/menubutton-end-active-pressed.png create mode 100644 chrome/skin/default/zotero/mac/menubutton-end-active-pressed@2x.png create mode 100644 chrome/skin/default/zotero/mac/menubutton-end-active.png create mode 100644 chrome/skin/default/zotero/mac/menubutton-end-active@2x.png create mode 100644 chrome/skin/default/zotero/mac/menubutton-start-active-pressed.png create mode 100644 chrome/skin/default/zotero/mac/menubutton-start-active-pressed@2x.png create mode 100644 chrome/skin/default/zotero/mac/menubutton-start-active.png create mode 100644 chrome/skin/default/zotero/mac/menubutton-start-active@2x.png create mode 100644 chrome/skin/default/zotero/mac/notes-white.png create mode 100644 chrome/skin/default/zotero/mac/notes-white@2x.png create mode 100644 chrome/skin/default/zotero/mac/notes.png create mode 100644 chrome/skin/default/zotero/mac/notes@2x.png create mode 100644 scss/abstracts/_split-button.scss diff --git a/.gitmodules b/.gitmodules index 96573462c7..4aca5c8a9d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -31,13 +31,13 @@ url = https://github.com/gildas-lormeau/SingleFile.git [submodule "pdf-reader"] path = pdf-reader - url = https://github.com/zotero/pdf-reader.git + url = ssh://git@github.com/zotero/pdf-reader.git branch = master [submodule "pdf-worker"] path = pdf-worker - url = https://github.com/zotero/pdf-worker.git + url = ssh://git@github.com/zotero/pdf-worker.git branch = master [submodule "zotero-note-editor"] path = zotero-note-editor - url = https://github.com/zotero/zotero-note-editor.git + url = ssh://git@github.com/zotero/zotero-note-editor.git branch = master diff --git a/chrome/content/zotero-platform/mac/overlay.css b/chrome/content/zotero-platform/mac/overlay.css index e646d5863a..dcf0418343 100644 --- a/chrome/content/zotero-platform/mac/overlay.css +++ b/chrome/content/zotero-platform/mac/overlay.css @@ -129,7 +129,7 @@ input { .zotero-editpane-tabs { -moz-appearance: none; - /*background: -moz-linear-gradient(top, #ededed, #cccccc);*/ + background: -moz-linear-gradient(top, #ededed, #cccccc); border-style: solid; border-width: 0 0 1px 0; border-color: #bdbdbd; @@ -217,7 +217,8 @@ input { } #zotero-collections-splitter:not([state=collapsed]), -#zotero-items-splitter:not([state=collapsed])[orient=horizontal] +#zotero-items-splitter:not([state=collapsed])[orient=horizontal], +#zotero-context-splitter:not([state=collapsed])[orient=horizontal] { -moz-appearance: none; border-inline-start: 1px solid #bdbdbd; @@ -228,7 +229,8 @@ input { background-image: none; } -#zotero-items-splitter[orient=vertical] +#zotero-items-splitter[orient=vertical], +#zotero-context-splitter-stacked { -moz-border-start: none !important; -moz-border-end: none !important; @@ -239,17 +241,25 @@ input { } #zotero-collections-splitter:not([state=collapsed]) > grippy, -#zotero-items-splitter:not([state=collapsed]) > grippy +#zotero-items-splitter:not([state=collapsed]) > grippy, +#zotero-context-splitter:not([state=collapsed]) > grippy, +#zotero-context-splitter-stacked:not([state=collapsed]) > grippy { display: none; } -#zotero-collections-splitter[state=collapsed], #zotero-items-splitter[state=collapsed] { +#zotero-collections-splitter[state=collapsed], +#zotero-items-splitter[state=collapsed], +#zotero-context-splitter[state=collapsed], +#zotero-context-splitter-stacked[state=collapsed] +{ border: 0 solid #d6d6d6 !important; padding: 0; } -#zotero-collections-splitter[state=collapsed], #zotero-items-splitter[state=collapsed][orient=horizontal] +#zotero-collections-splitter[state=collapsed], +#zotero-items-splitter[state=collapsed][orient=horizontal], +#zotero-context-splitter[state=collapsed][orient=horizontal] { background-image: url("chrome://zotero/skin/mac/vsplitter.png"); background-repeat: repeat-y; @@ -258,7 +268,8 @@ input { width: 8px !important; } -#zotero-items-splitter[state=collapsed][orient=vertical] +#zotero-items-splitter[state=collapsed][orient=vertical], +#zotero-context-splitter-stacked[state=collapsed][orient=vertical] { background-image: url("chrome://zotero/skin/mac/hsplitter.png"); background-repeat: repeat-x; @@ -271,11 +282,17 @@ input { border-right-width: 1px !important; } -#zotero-items-splitter[state=collapsed] { +#zotero-items-splitter[state=collapsed], +#zotero-context-splitter[state=collapsed], +#zotero-context-splitter-stacked[state=collapsed] +{ border-left-width: 1px !important; } -#zotero-collections-splitter[state=collapsed] > grippy, #zotero-items-splitter[state=collapsed] > grippy +#zotero-collections-splitter[state=collapsed] > grippy, +#zotero-items-splitter[state=collapsed] > grippy, +#zotero-context-splitter[state=collapsed] > grippy, +#zotero-context-splitter-stacked[state=collapsed] > grippy { -moz-appearance: none; background: url(chrome://zotero/skin/mac/vgrippy.png) center/auto 8px no-repeat; @@ -301,11 +318,20 @@ input { height: 8px; } -#zotero-tags-splitter > grippy:hover, #zotero-collections-splitter > grippy:hover, #zotero-items-splitter > grippy:hover +#zotero-tags-splitter > grippy:hover, +#zotero-collections-splitter > grippy:hover, +#zotero-items-splitter > grippy:hover, +#zotero-context-splitter > grippy:hover, +#zotero-context-splitter-stacked > grippy:hover { background-color:transparent; } +#zotero-context-toolbar-extension { + /* To cover #zotero-context-splitter 1px border */ + margin-inline-start: -1px; +} + #zotero-items-tree { -moz-appearance: none; diff --git a/chrome/content/zotero-platform/unix/overlay.css b/chrome/content/zotero-platform/unix/overlay.css index cc19958220..50aee11669 100644 --- a/chrome/content/zotero-platform/unix/overlay.css +++ b/chrome/content/zotero-platform/unix/overlay.css @@ -76,3 +76,9 @@ tab { background-color: transparent; background-image: none; } + +#zotero-context-splitter-stacked { + -moz-appearance: none; + background-color: #ececec; + border-top: 1px solid hsla(0, 0%, 0%, 0.2); +} diff --git a/chrome/content/zotero/bindings/noteeditor.xml b/chrome/content/zotero/bindings/noteeditor.xml index f6ca7f0d74..838358e0c1 100644 --- a/chrome/content/zotero/bindings/noteeditor.xml +++ b/chrome/content/zotero/bindings/noteeditor.xml @@ -42,15 +42,19 @@ false false false - + + + + { @@ -99,6 +103,7 @@ popup: document.getAnonymousElementByAttribute(this, 'anonid', 'editor-menu'), onNavigate: this._navigateHandler, readOnly: !this.editable, + onReturn: this._returnHandler, placeholder: this.placeholder }); } @@ -137,7 +142,7 @@ this._lastHtmlValue = this.item.note; } - this._id('links-container').hidden = !(this.displayTags && this.displayRelated); + this._id('links-container').hidden = !(this.displayTags && this.displayRelated) || this._hideLinksContainer; this._id('links-box').refresh(); } @@ -179,12 +184,21 @@ this._mode = val; document.getAnonymousNodes(this)[0].setAttribute('mode', val); this._id('links-box').mode = val; - this._id('links-container').hidden = !(this.displayTags && this.displayRelated); + this._id('links-container').hidden = !(this.displayTags && this.displayRelated) || this._hideLinksContainer; this._id('links-box').refresh(); ]]> + + + + + + + @@ -261,6 +275,15 @@ ]]> + + + + + + @@ -271,6 +294,8 @@ this._editorInstance.uninit(); } this._destroyed = true; + this._initialized = false; + this._editorInstance = null; ]]> @@ -307,8 +332,8 @@ - - + diff --git a/chrome/content/zotero/components/itemPane/notesList.jsx b/chrome/content/zotero/components/itemPane/notesList.jsx index 3496d888d1..6425d4e9cc 100644 --- a/chrome/content/zotero/components/itemPane/notesList.jsx +++ b/chrome/content/zotero/components/itemPane/notesList.jsx @@ -25,14 +25,18 @@ import React, { forwardRef, useImperativeHandle, useState } from 'react'; -const NoteRow = ({ title, body, date, onClick }) => { +const NoteRow = ({ parentTitle, parentImageSrc, title, body, date, onClick }) => { return (
-
+ {parentTitle !== null &&
+
+
{parentTitle}
+
} +
{title}
-
+
{date}
{body}
diff --git a/chrome/content/zotero/contextPane.js b/chrome/content/zotero/contextPane.js new file mode 100644 index 0000000000..b39a773159 --- /dev/null +++ b/chrome/content/zotero/contextPane.js @@ -0,0 +1,791 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2020 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.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 . + + ***** END LICENSE BLOCK ***** +*/ + +// TODO: Fix import/require related isues that might be +// related with `require` not reusing the context +var React = require('react'); +var ReactDOM = require('react-dom'); +var TagsBoxContainer = require('containers/tagsBoxContainer').default; +var NotesList = require('components/itemPane/notesList').default; + +var ZoteroContextPane = new function () { + const HTML_NS = 'http://www.w3.org/1999/xhtml'; + + var _tabCover; + var _contextPane; + var _contextPaneInner; + var _contextPaneSplitter; + var _contextPaneSplitterStacked; + var _itemToggle; + var _notesToggle; + var _panesDeck; + var _itemPaneDeck; + var _notesPaneDeck; + + var _itemToolbar; + var _splitButton; + var _itemPaneToggle; + var _notesPaneToggle; + var _toolbar; + var _tabToolbarContainer; + + var _itemContexts = []; + var _notesContexts = []; + + // Using attribute instead of propery to set 'selectedIndex' + // is more reliable + + this.update = _update; + this.getActiveEditor = _getActiveEditor; + + this.onLoad = function () { + if (!Zotero) { + return; + } + + _tabCover = document.getElementById('zotero-tab-cover'); + _itemToggle = document.getElementById('zotero-tb-toggle-item-pane'); + _notesToggle = document.getElementById('zotero-tb-toggle-notes-pane'); + _contextPane = document.getElementById('zotero-context-pane'); + _contextPaneInner = document.getElementById('zotero-context-pane-inner'); + _contextPaneSplitter = document.getElementById('zotero-context-splitter'); + _contextPaneSplitterStacked = document.getElementById('zotero-context-splitter-stacked'); + + _itemToolbar = document.getElementById('zotero-item-toolbar'); + _splitButton = document.getElementById('zotero-tb-split'); + _itemPaneToggle = document.getElementById('zotero-tb-toggle-item-pane'); + _notesPaneToggle = document.getElementById('zotero-tb-toggle-notes-pane'); + _toolbar = document.getElementById('zotero-toolbar'); + _tabToolbarContainer = document.getElementById('zotero-tab-toolbar-container'); + + _init(); + + this._unregisterID = Zotero.Notifier.registerObserver(this, ['item', 'tab'], 'contextPane'); + window.addEventListener('resize', _update); + _itemToggle.addEventListener('click', () => { + _togglePane(0); + }); + _notesToggle.addEventListener('click', () => { + _togglePane(1); + }); + Zotero.Reader.onChangeSidebarWidth = _updatePaneWidth; + Zotero.Reader.onChangeSidebarOpen = _updatePaneWidth; + + const observer = new MutationObserver(() => { + _updateToolbarWidth(); + // Sometimes XUL is late to reflow + setTimeout(_updateToolbarWidth, 100); + }); + observer.observe(_tabToolbarContainer, { attributes: true, childList: true, subtree: true }); + }; + + this.onUnload = function () { + Zotero.Notifier.unregisterObserver(this._unregisterID); + }; + + this.notify = Zotero.Promise.coroutine(function* (action, type, ids, extraData) { + if (type == 'item') { + // TODO: Filter library and seriously thing about the performance of this part + for (let context of _itemContexts) { + let item = Zotero.Items.get(context.itemID); + if (item && item.parentID != context.parentID) { + _removeItemContext(context.tabID); + _addItemContext(context.tabID, context.itemID); + } + context.update(); + } + + for (let context of _notesContexts) { + context.updateNotesList(); + } + } + else if (type == 'tab') { + if (action == 'add') { + _addItemContext(ids[0], extraData[ids[0]].itemID); + } + else if (action == 'close') { + _removeItemContext(ids[0]); + } + else if (action == 'select') { + if (Zotero_Tabs.selectedIndex == 0) { + _contextPaneSplitter.setAttribute('hidden', true); + _contextPane.setAttribute('collapsed', true); + _toolbar.append(_itemToolbar); + _itemToolbar.classList.remove('tab-mode'); + _splitButton.classList.add('hidden'); + } + else { + var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + if (reader) { + _tabCover.hidden = false; + (async () => { + await reader._initPromise; + _tabCover.hidden = true; + })(); + } + + _contextPaneSplitter.setAttribute('hidden', false); + _contextPane.setAttribute('collapsed', !(_contextPaneSplitter.getAttribute('state') != 'collapsed')); + _tabToolbarContainer.append(_itemToolbar); + _itemToolbar.classList.add('tab-mode'); + _splitButton.classList.remove('hidden'); + } + + var context = _itemContexts.find(x => x.tabID == ids[0]); + if (context) { + _selectNotesContext(context.libraryID); + } + _selectItemContext(ids[0], extraData[ids[0]].type); + _update(); + } + } + }); + + function _removeNote(id) { + var ps = Components.classes['@mozilla.org/embedcomp/prompt-service;1'] + .getService(Components.interfaces.nsIPromptService); + if (ps.confirm(null, '', Zotero.getString('pane.item.notes.delete.confirm'))) { + Zotero.Items.trashTx(id); + } + } + + function _getActiveEditor() { + var splitter; + if (Zotero.Prefs.get('layout') == 'stacked') { + splitter = _contextPaneSplitterStacked; + } + else { + splitter = _contextPaneSplitter; + } + + if (splitter.getAttribute('state') != 'collapsed') { + if (_panesDeck.selectedIndex == 0) { + let child = _itemPaneDeck.selectedPanel; + var tabPanels = child.querySelector('tabpanels'); + if (tabPanels && tabPanels.selectedIndex == 1) { + var notesDeck = child.querySelector('.notes-deck'); + if (notesDeck.selectedIndex == 1) { + return child.querySelector('zoteronoteeditor'); + } + } + } + else { + var node = _notesPaneDeck.selectedPanel; + if (node.selectedIndex == 1) { + return node.querySelector('zoteronoteeditor'); + } + } + } + } + + function _updateAddToNote() { + var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + if (reader) { + var editor = _getActiveEditor(); + reader.enableAddToNote(!!editor); + } + } + + function _updatePaneWidth() { + var stacked = Zotero.Prefs.get('layout') == 'stacked'; + var width = Zotero.Reader.getSidebarWidth() + 'px'; + if (!Zotero.Reader.getSidebarOpen()) { + width = 0; + } + _contextPane.style.left = stacked ? width : 'unset'; + } + + function _updateToolbarWidth() { + var stacked = Zotero.Prefs.get('layout') == 'stacked'; + var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + if (reader) { + if ((stacked || _contextPaneSplitter.getAttribute('state') == 'collapsed')) { + reader.setToolbarPlaceholderWidth(_tabToolbarContainer.boxObject.width); + } + else { + reader.setToolbarPlaceholderWidth(0); + } + } + } + + function _update() { + if (Zotero_Tabs.selectedIndex == 0) { + return; + } + + var splitter; + var stacked = Zotero.Prefs.get('layout') == 'stacked'; + if (stacked) { + _contextPaneSplitterStacked.setAttribute('hidden', false); + _contextPaneSplitter.setAttribute('state', 'open'); + _contextPaneSplitter.setAttribute('hidden', true); + _contextPane.classList.add('stacked'); + _contextPane.classList.remove('standard'); + splitter = _contextPaneSplitterStacked; + } + else { + _contextPaneSplitter.setAttribute('hidden', false); + _contextPaneSplitterStacked.setAttribute('hidden', true); + _contextPaneSplitterStacked.setAttribute('state', 'open'); + _contextPane.classList.add('standard'); + _contextPane.classList.remove('stacked'); + splitter = _contextPaneSplitter; + } + + var collapsed = splitter.getAttribute('state') == 'collapsed'; + + var selectedIndex = _panesDeck.selectedIndex; + if (!collapsed && selectedIndex == 0) { + _itemPaneToggle.classList.add('toggled'); + } + else { + _itemPaneToggle.classList.remove('toggled'); + } + + if (!collapsed && selectedIndex == 1) { + _notesPaneToggle.classList.add('toggled'); + } + else { + _notesPaneToggle.classList.remove('toggled'); + } + + if (Zotero_Tabs.selectedIndex > 0) { + var height = 0; + if (Zotero.Prefs.get('layout') == 'stacked' + && _contextPane.getAttribute('collapsed') != 'true') { + height = _contextPaneInner.boxObject.height; + } + Zotero.Reader.setBottomPlaceholderHeight(height); + } + + _updatePaneWidth(); + _updateToolbarWidth(); + _updateAddToNote(); + } + + function _togglePane(paneIndex) { + var splitter; + var stacked = Zotero.Prefs.get('layout') == 'stacked'; + if (stacked) { + splitter = _contextPaneSplitterStacked; + } + else { + splitter = _contextPaneSplitter; + } + + var isOpen = splitter.getAttribute('state') != 'collapsed'; + var hide = false; + + var currentPane = _panesDeck.selectedIndex; + + if (isOpen && currentPane == paneIndex) { + hide = true; + } + else { + _panesDeck.setAttribute('selectedIndex', paneIndex); + } + + var context = _itemContexts.find(x => x.tabID == Zotero_Tabs.selectedID); + context.selectedIndex = paneIndex; + + splitter.setAttribute('state', hide ? 'collapsed' : 'open'); + _update(); + } + + function _init() { + // vbox + var vbox = document.createElement('vbox'); + vbox.setAttribute('flex', '1'); + + _contextPaneInner.append(vbox); + + // Toolbar extension + var toolbarExtension = document.createElement('box'); + toolbarExtension.style.height = '32px'; + toolbarExtension.id = 'zotero-context-toolbar-extension'; + + _panesDeck = document.createElement('deck'); + _panesDeck.setAttribute('flex', 1); + _panesDeck.setAttribute('selectedIndex', 0); + + vbox.append(toolbarExtension, _panesDeck); + + // Item pane deck + _itemPaneDeck = document.createElement('deck'); + // Notes pane deck + _notesPaneDeck = document.createElement('deck'); + _notesPaneDeck.style.backgroundColor = 'white'; + _notesPaneDeck.setAttribute('flex', 1); + _notesPaneDeck.className = 'notes-pane-deck'; + + _panesDeck.append(_itemPaneDeck, _notesPaneDeck); + } + + function _addNotesContext(libraryID) { + var list = document.createElement('vbox'); + list.setAttribute('flex', 1); + list.className = 'zotero-context-notes-list'; + + var noteContainer = document.createElement('vbox'); + var editor = document.createElement('zoteronoteeditor'); + editor.className = 'zotero-context-pane-pinned-note'; + editor.setAttribute('flex', 1); + noteContainer.appendChild(editor); + + let contextNode = document.createElement('deck'); + contextNode.append(list, noteContainer); + _notesPaneDeck.append(contextNode); + + contextNode.className = 'context-node'; + contextNode.setAttribute('selectedIndex', 0); + + editor.returnHandler = () => { + contextNode.setAttribute('selectedIndex', 0); + _updateAddToNote(); + }; + + var head = document.createElement('hbox'); + head.style.display = 'flex'; + + var label = document.createElement('label'); + var button = document.createElement('button'); + button.setAttribute('label', Zotero.Intl.strings['zotero.item.add']); + button.addEventListener('click', () => { + contextNode.setAttribute('selectedIndex', 1); + var item = new Zotero.Item('note'); + item.libraryID = libraryID; + // item.parentKey = parentItem.key; + editor.mode = 'edit'; + editor.item = item; + editor.parentItem = null; + editor.focus(); + _updateAddToNote(); + }); + + + var vbox1 = document.createElement('vbox'); + vbox1.append(label, button); + + var vbox2 = document.createElement('vbox'); + vbox2.style.flex = '1'; + var input = document.createElement('textbox'); + input.setAttribute('type', 'search'); + input.setAttribute('timeout', '250'); + input.addEventListener('command', () => { + updateNotesList(); + }); + vbox2.append(input); + + head.append(vbox2, vbox1); + + var listBox = document.createElement('vbox'); + listBox.style.display = 'flex'; + listBox.setAttribute('flex', '1'); + var listInner = document.createElementNS(HTML_NS, 'div'); + listInner.className = 'notes-list-container'; + listBox.append(listInner); + + list.append(head, listBox); + + var notesListRef = React.createRef(); + + var updateNotesList = async (reset) => { + if (reset) { + input.value = ''; + contextNode.setAttribute('selectedIndex', 0); + } + var text = input.value; + + await Zotero.Schema.schemaUpdatePromise; + var s = new Zotero.Search(); + s.addCondition('libraryID', 'is', libraryID); + s.addCondition('itemType', 'is', 'note'); + s.addCondition('noChildren', 'false'); + if (text) { + s.addCondition('note', 'contains', text, true); + } + var notes = await s.search(); + notes = Zotero.Items.get(notes); + notes.sort((a, b) => { + a = a.getField('dateModified'); + b = b.getField('dateModified'); + return b.localeCompare(a); + }); + + notesListRef.current.setNotes(notes.map(note => { + var text2 = note.note.slice(0, 500); + text2 = text2.trim(); + // TODO: Fix a potential performance issuse + text2 = Zotero.Utilities.unescapeHTML(text2); + var parts = text2.split('\n').map(x => x.trim()).filter(x => x.length); + var parent = null; + if (note.parentID) { + parent = Zotero.Items.get(note.parentID); + } + return { + id: note.id, + parentTitle: parent && parent.getDisplayTitle(), + parentImageSrc: parent && parent.getImageSrc(), + title: parts[0] || Zotero.getString('pane.item.notes.untitled'), + body: parts[1] || '', + date: (new Date(note.dateModified).toLocaleDateString(Zotero.locale)) + }; + })); + + var c = notes.length; + var str = 'pane.item.notes.count.' + (c == 0 && 'zero' || c == 1 && 'singular' || 'plural'); + label.value = Zotero.getString(str, [c]); + } + + ReactDOM.render( + { + _setPinnedNote(libraryID, id); + }} + />, + listInner, + () => { + updateNotesList(); + } + ); + + var context = { + libraryID, + node: contextNode, + updateNotesList, + editor + }; + + _notesContexts.push(context); + + return context; + } + + function _getNotesContext(libraryID) { + var context = _notesContexts.find(x => x.libraryID == libraryID); + if (!context) { + context = _addNotesContext(libraryID); + } + return context; + } + + function _selectNotesContext(libraryID) { + let context = _getNotesContext(libraryID); + _notesPaneDeck.setAttribute('selectedIndex', Array.from(_notesPaneDeck.children).findIndex(x => x == context.node)); + } + + function _removeNotesContext(libraryID) { + var context = _notesContexts.find(x => x.libraryID == libraryID); + context.node.remove(); + _notesContexts = _notesContexts.filter(x => x.libraryID != libraryID); + } + + function _libraryEditable(libraryID) { + var type = Zotero.Libraries.get(libraryID).libraryType; + if (type == 'group') { + var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID); + var group = Zotero.Groups.get(groupID); + return group.editable; + } + return true; + } + + function _setPinnedNote(libraryID, itemID) { + var editable = _libraryEditable(libraryID); + var context = _getNotesContext(libraryID); + if (context) { + let { editor, node } = context; + node.setAttribute('selectedIndex', 1); + editor.mode = editable ? 'edit' : 'view'; + editor.item = Zotero.Items.get(itemID); + editor.parentItem = null; + editor.hideLinksContainer = true; + _updateAddToNote(); + } + } + + function _appendNoteRows(notes, list, editable, onClick, onDelete) { + for (var i = 0; i < notes.length; i++) { + var note = notes[i]; + var id = notes[i].id; + + var icon = document.createElement('image'); + icon.className = 'zotero-box-icon'; + icon.setAttribute('src', `chrome://zotero/skin/treeitem-note${Zotero.hiDPISuffix}.png`); + + var label = document.createElement('label'); + label.className = 'zotero-box-label'; + var title = note.getNoteTitle(); + title = title ? title : Zotero.getString('pane.item.notes.untitled'); + label.setAttribute('value', title); + label.setAttribute('flex', '1'); //so that the long names will flex smaller + label.setAttribute('crop', 'end'); + + var box = document.createElement('box'); + box.setAttribute('class', 'zotero-clicky'); + box.addEventListener('click', () => { + onClick(id); + }); + box.appendChild(icon); + box.appendChild(label); + + if (editable) { + var removeButton = document.createElement('label'); + removeButton.setAttribute('value', '-'); + removeButton.setAttribute('class', 'zotero-clicky zotero-clicky-minus'); + removeButton.addEventListener('click', function () { + onDelete(id); + }); + } + + var row = document.createElement('row'); + row.appendChild(box); + if (editable) { + row.appendChild(removeButton); + } + + list.appendChild(row); + } + } + + function _removeItemContext(tabID) { + document.getElementById(tabID + '-context').remove(); + _itemContexts = _itemContexts.filter(x => x.tabID != tabID); + } + + function _selectItemContext(tabID, type) { + let selectedIndex = Array.from(_itemPaneDeck.children).findIndex(x => x.id == tabID + '-context'); + if (selectedIndex != -1) { + _itemPaneDeck.setAttribute('selectedIndex', selectedIndex); + var context = _itemContexts.find(x => x.tabID == tabID); + if (context && Zotero_Tabs.selectedIndex > 0) { + _panesDeck.setAttribute('selectedIndex', context.selectedIndex); + } + } + } + + function _addItemContext(tabID, itemID) { + var container = document.createElement('vbox'); + container.id = tabID + '-context'; + container.className = 'zotero-item-pane-content'; + _itemPaneDeck.appendChild(container); + + var item = Zotero.Items.get(itemID); + var libraryID = item.libraryID; + var editable = _libraryEditable(libraryID); + var parentID = item.parentID; + + var context = { + tabID, + itemID, + parentID, + libraryID, + selectedIndex: 0, + update: () => {} + }; + _itemContexts.push(context); + + if (!parentID) { + var vbox = document.createElement('vbox'); + vbox.setAttribute('flex', '1'); + vbox.setAttribute('align', 'center'); + vbox.setAttribute('pack', 'center'); + var description = document.createElement('description'); + vbox.append(description); + description.append(Zotero.getString('pane.context.noParent')); + container.append(vbox); + return; + } + + var parentItem = Zotero.Items.get(item.parentID); + + // tabbox + var tabbox = document.createElement('tabbox'); + tabbox.setAttribute('flex', '1'); + tabbox.className = 'zotero-view-tabbox'; + + container.append(tabbox); + + // tabs + var tabs = document.createElement('tabs'); + tabs.className = 'zotero-editpane-tabs'; + // tabpanels + var tabpanels = document.createElement('tabpanels'); + tabpanels.setAttribute('flex', '1'); + tabpanels.className = 'zotero-view-item'; + tabpanels.addEventListener('select', () => { + _updateAddToNote(); + }); + + tabbox.append(tabs, tabpanels); + + // Info tab + var tabInfo = document.createElement('tab'); + tabInfo.setAttribute('label', Zotero.Intl.strings['zotero.tabs.info.label']); + // Notes tab + var tabNotes = document.createElement('tab'); + tabNotes.setAttribute('label', Zotero.Intl.strings['zotero.tabs.notes.label']); + // Tags tab + var tabTags = document.createElement('tab'); + tabTags.setAttribute('label', Zotero.Intl.strings['zotero.tabs.tags.label']); + // Related tab + var tabRelated = document.createElement('tab'); + tabRelated.setAttribute('label', Zotero.Intl.strings['zotero.tabs.related.label']); + + tabs.append(tabInfo, tabNotes, tabTags, tabRelated); + + // Info panel + var panelInfo = document.createElement('tabpanel'); + panelInfo.setAttribute('flex', '1'); + panelInfo.className = 'zotero-editpane-item-box'; + var itemBox = document.createElement('zoteroitembox'); + itemBox.setAttribute('flex', '1'); + panelInfo.append(itemBox); + // Notes panel + var panelNotes = document.createElement('tabpanel'); + panelNotes.setAttribute('flex', '1'); + panelNotes.setAttribute('orient', 'vertical'); + var deck = document.createElement('deck'); + deck.className = 'notes-deck'; + deck.setAttribute('flex', '1'); + panelNotes.append(deck); + var vbox2 = document.createElement('vbox'); + var note = document.createElement('zoteronoteeditor'); + note.setAttribute('flex', 1); + vbox2.append(note); + var vbox = document.createElement('vbox'); + vbox.setAttribute('flex', '1'); + vbox.setAttribute('class', 'zotero-box'); + vbox.style.overflowY = 'auto'; + panelNotes.append(vbox); + var hbox = document.createElement('hbox'); + hbox.setAttribute('align', 'center'); + var label = document.createElement('label'); + var button = document.createElement('button'); + // TODO: Should not depend on the current ZoteroPane state + button.hidden = !editable; + button.setAttribute('label', Zotero.Intl.strings['zotero.item.add']); + button.addEventListener('click', () => { + deck.setAttribute('selectedIndex', 1); + var item = new Zotero.Item('note'); + item.libraryID = parentItem.libraryID; + item.parentID = parentItem.id; + note.returnHandler = () => { + deck.setAttribute('selectedIndex', 0); + _updateAddToNote(); + }; + note.mode = editable ? 'edit' : 'view'; + note.item = item; + note.focus(); + _updateAddToNote(); + }); + hbox.append(label, button); + var grid = document.createElement('grid'); + grid.setAttribute('flex', 1); + var columns = document.createElement('columns'); + var column = document.createElement('column'); + column.setAttribute('flex', 1); + columns.append(column); + var column = document.createElement('column'); + columns.append(column); + grid.append(columns); + var rows = document.createElement('rows'); + rows.setAttribute('flex', 1); + grid.append(rows); + vbox.append(hbox, grid); + deck.append(vbox, vbox2); + deck.setAttribute('selectedIndex', 0); + // Tags panel + var panelTags = document.createElement('tabpanel'); + panelTags.setAttribute('orient', 'vertical'); + panelTags.setAttribute('context', 'tags-context-menu'); + panelTags.className = 'tags-pane'; + panelTags.style.display = 'flex'; + var div = document.createElementNS(HTML_NS, 'div'); + div.className = 'tags-box-container'; + div.style.display = 'flex'; + div.style.flexGrow = '1'; + panelTags.append(div); + var tagsBoxRef = React.createRef(); + ReactDOM.render( + , + div + ); + // Related panel + var panelRelated = document.createElement('tabpanel'); + var relatedBox = document.createElement('relatedbox'); + relatedBox.setAttribute('flex', '1'); + relatedBox.className = 'zotero-editpane-related'; + panelRelated.addEventListener('click', (event) => { + if (event.originalTarget.closest('.zotero-clicky')) { + Zotero_Tabs.select('zotero-pane'); + } + }); + panelRelated.append(relatedBox); + + tabpanels.append(panelInfo, panelNotes, panelTags, panelRelated); + tabbox.selectedIndex = 0; + + + itemBox.mode = editable ? 'edit' : 'view'; + itemBox.item = parentItem; + + relatedBox.mode = editable ? 'edit' : 'view'; + relatedBox.item = parentItem; + + function _renderNotesPanel() { + rows.innerHTML = ''; + var parentNotes = Zotero.Items.get(parentItem.getNotes()); + _appendNoteRows(parentNotes, rows, editable, (id) => { + deck.setAttribute('selectedIndex', 1); + note.returnHandler = () => { + deck.setAttribute('selectedIndex', 0); + _updateAddToNote(); + }; + note.mode = editable ? 'edit' : 'view'; + note.item = Zotero.Items.get(id); + note.parentItem = null; + _updateAddToNote(); + }, (id) => { + _removeNote(id); + }); + var c = parentNotes.length; + var str = 'pane.item.notes.count.' + (c == 0 && 'zero' || c == 1 && 'singular' || 'plural'); + label.value = Zotero.getString(str, [c]); + } + + context.update = _renderNotesPanel; + _renderNotesPanel(); + } +}; + +addEventListener('load', function (e) { ZoteroContextPane.onLoad(e); }, false); +addEventListener('unload', function (e) { ZoteroContextPane.onUnload(e); }, false); diff --git a/chrome/content/zotero/itemPane.js b/chrome/content/zotero/itemPane.js index 1ced0acaf1..33a7f11d88 100644 --- a/chrome/content/zotero/itemPane.js +++ b/chrome/content/zotero/itemPane.js @@ -26,17 +26,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import TagsBoxContainer from 'containers/tagsBoxContainer'; -import NotesList from 'components/itemPane/notesList'; var ZoteroItemPane = new function() { - const HTML_NS = 'http://www.w3.org/1999/xhtml'; - var _lastItem, _itemBox, _notesLabel, _notesButton, _notesList, _tagsBox, _relatedBox; var _selectedNoteID; var _translationTarget; var _noteIDs; - var _contextNoteUpdaters = []; - let _pdfTabHidden = false; this.onLoad = function () { if (!Zotero) { @@ -60,15 +55,7 @@ var ZoteroItemPane = new function() { }; _relatedBox = document.getElementById('zotero-editpane-related'); - this._unregisterID = Zotero.Notifier.registerObserver(this, ['item', 'tab'], 'itemPane'); - - document.getElementById('temp-toggle-1').addEventListener('click', () => { - this.togglePane(); - }); - document.getElementById('temp-toggle-2').addEventListener('click', () => { - this.togglePane(); - }); - this.initStandaloneNotesView(); + this._unregisterID = Zotero.Notifier.registerObserver(this, ['item'], 'itemPane'); } @@ -115,8 +102,6 @@ var ZoteroItemPane = new function() { _lastItem = item; - _updateTitle(); - var viewBox = document.getElementById('zotero-view-item'); viewBox.classList.remove('no-tabs'); @@ -228,32 +213,15 @@ var ZoteroItemPane = new function() { this.notify = Zotero.Promise.coroutine(function* (action, type, ids, extraData) { - if(type == 'item') { - var viewBox = document.getElementById('zotero-view-item'); - // If notes pane is selected, refresh it if any of the notes change or are deleted - if (viewBox.selectedIndex == 1 && (action == 'modify' || action == 'delete')) { - let refresh = false; - if (ids.some(id => _noteIDs.has(id))) { - refresh = true; - } - if (refresh) { - yield this.viewItem(_lastItem, null, 1); - } + var viewBox = document.getElementById('zotero-view-item'); + // If notes pane is selected, refresh it if any of the notes change or are deleted + if (viewBox.selectedIndex == 1 && (action == 'modify' || action == 'delete')) { + let refresh = false; + if (ids.some(id => _noteIDs.has(id))) { + refresh = true; } - this.updateStandaloneNotesList(); - for (let updater of _contextNoteUpdaters) { - updater.callback(); - } - } - else if (type == 'tab') { - if (action == 'add') { - this.addPDFTabContext(ids[0], extraData.itemID); - } - else if (action == 'close') { - this.removeTabContext(ids[0]); - } - else if (action == 'select') { - this.selectTabContext(ids[0], extraData.type); + if (refresh) { + yield this.viewItem(_lastItem, null, 1); } } }); @@ -311,7 +279,6 @@ var ZoteroItemPane = new function() { // noteEditor.clearUndo(); // } - document.getElementById('zotero-view-note-sidebar-button').hidden = !!item.parentID; document.getElementById('zotero-view-note-button').hidden = !editable; document.getElementById('zotero-item-pane-content').selectedIndex = 2; }; @@ -321,17 +288,6 @@ var ZoteroItemPane = new function() { // ZoteroPane.setItemPaneMessage(Zotero.getString('pane.item.notes.editingInWindow')); }; - this.openNoteSidebar = async function () { - var selectedNote = Zotero.Items.get(_selectedNoteID); - - if (!selectedNote.parentID) { - let editor = document.getElementById('zotero-item-pane-pinned-note'); - editor.mode = 'edit'; - editor.item = selectedNote; - document.getElementById('zotero-item-pane-pin-deck2').setAttribute('selectedIndex', 1); - this.togglePane(false); - } - } /** * Select the parent item and open the note editor @@ -480,11 +436,6 @@ var ZoteroItemPane = new function() { }; - function _updateTitle() { - document.getElementById('item-pane-title').textContent = _lastItem.getDisplayTitle(); - } - - function _updateNoteCount() { var c = _notesList.childNodes.length; @@ -503,508 +454,6 @@ var ZoteroItemPane = new function() { _notesLabel.value = Zotero.getString(str, [c]); } - - this.getActiveNote = function () { - let mainDeck = document.getElementById('zotero-item-pane-main-deck'); - if (mainDeck.selectedIndex == 0) { - let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); - if (contextualDeck.selectedIndex > 0) { - let child = contextualDeck.children[contextualDeck.selectedIndex]; - if (child.querySelector('deck').selectedIndex == 1) { - return child.querySelector('zoteronoteeditor'); - } - } - } - else { - let pinnedDeck = document.getElementById('zotero-item-pane-pin-deck2'); - if (pinnedDeck.selectedIndex == 1) { - return pinnedDeck.querySelector('zoteronoteeditor'); - } - } - return null; - } - - this.togglePane = function (forceItem) { - var pane = document.getElementById('zotero-item-pane'); - let mainDeck = document.getElementById('zotero-item-pane-main-deck'); - let value; - if (forceItem !== undefined) { - value = forceItem ? 0 : 1; - } - else { - value = mainDeck.selectedIndex == 0 ? 1 : 0; - } - - if (value == 0) { - pane.setAttribute('data-mode', 'item'); - document.getElementById('temp-toggle-1').innerHTML = '[📄] 📒'; - document.getElementById('temp-toggle-2').innerHTML = '[📄] 📒'; - } - else { - pane.setAttribute('data-mode', 'notes'); - document.getElementById('temp-toggle-1').innerHTML = '📄 [📒]'; - document.getElementById('temp-toggle-2').innerHTML = '📄 [📒]'; - } - - mainDeck.selectedIndex = value; - - let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); - contextualDeck.children[contextualDeck.selectedIndex].setAttribute('state', value); - } - - this.initStandaloneNotesView = async function () { - let container = document.getElementById('zotero-item-pane-pin-deck'); - - let bar = document.createElement('hbox'); - container.appendChild(bar); - let inner = document.createElement('deck'); - inner.id = 'zotero-item-pane-pin-deck2'; - inner.style.backgroundColor = 'white'; - inner.setAttribute('flex', 1); - container.appendChild(inner); - - var listLabel = document.createElementNS(HTML_NS, 'div'); - listLabel.className = 'notes-list-label'; - listLabel.textContent = Zotero.getString('pane.item.notes.allNotes'); - - let list = document.createElement('vbox'); - list.setAttribute('flex', 1); - list.className = 'zotero-box'; - - list.appendChild(listLabel); - - var noteContainer = document.createElement('vbox'); - var backButton = document.createElementNS(HTML_NS, 'div'); - backButton.className = 'item-pane-back-button'; - backButton.textContent = (Zotero.dir == 'ltr' ? '←' : '→') + ' ' - + Zotero.getString('general.back'); - backButton.addEventListener('click', () => { - inner.setAttribute('selectedIndex', 0); - }); - let note = document.createElement('zoteronoteeditor'); - note.id = 'zotero-item-pane-pinned-note'; - note.setAttribute('flex', 1); - noteContainer.appendChild(backButton); - noteContainer.appendChild(note); - - inner.append(list, noteContainer); - inner.setAttribute('selectedIndex', 0); - - let head = document.createElement('hbox'); - let label = document.createElement('label'); - let button = document.createElement('button'); - button.setAttribute('label', Zotero.Intl.strings['zotero.item.add']); - button.addEventListener('click', async () => { - inner.setAttribute('selectedIndex', 1); - let item = new Zotero.Item('note'); - item.libraryID = ZoteroPane_Local.getSelectedLibraryID(); - // item.parentKey = parentItem.key; - note.mode = 'edit'; - note.item = item; - note.parentItem = null; - note.focus(); - }); - - head.style.paddingRight = '10px'; - - - let input = document.createElement('textbox'); - input.setAttribute('type', 'search'); - input.setAttribute('timeout', '250'); - - input.addEventListener('command', (event) => { - _updateStandaloneNotesList(); - }) - - - let vbox1 = document.createElement('vbox'); - vbox1.append(label, button); - - let vbox2 = document.createElement('vbox'); - vbox2.append(input); - - head.append(vbox2, vbox1); - - head.style.display = 'flex'; - vbox2.style.flex = '1'; - - - let listBox = document.createElement('vbox'); - listBox.style.display = 'flex'; - listBox.setAttribute('flex', '1') - - var listInner = document.createElementNS(HTML_NS, 'div'); - listInner.className = 'notes-list-container'; - list.append(head, listBox); - - listBox.append(listInner); - - let noteListRef = React.createRef(); - - let _updateStandaloneNotesList = async (reset) => { - if (reset) { - input.value = ''; - inner.setAttribute('selectedIndex', 0); - } - let text = input.value; - - await Zotero.Schema.schemaUpdatePromise; - var s = new Zotero.Search(); - s.addCondition('libraryID', 'is', ZoteroPane_Local.getSelectedLibraryID()); - s.addCondition('itemType', 'is', 'note'); - s.addCondition('noChildren', 'true'); - if (text) { - s.addCondition('note', 'contains', text, true); - } - let notes = await s.search(); - notes = Zotero.Items.get(notes); - notes.sort((a, b) => { - a = a.getField('dateModified'); - b = b.getField('dateModified'); - return b.localeCompare(a); - }); - - noteListRef.current.setNotes(notes.map(note => { - let text2 = note.note; - text2 = text2.trim(); - // TODO: Fix a potential performance issuse - text2 = Zotero.Utilities.unescapeHTML(text2); - let parts = text2.split('\n').map(x => x.trim()).filter(x => x.length); - return { - id: note.id, - title: parts[0] || Zotero.getString('pane.item.notes.untitled'), - body: parts[1] || '', - date: (new Date(note.dateModified).toLocaleDateString(Zotero.locale)) - }; - })); - - var c = notes.length; - var str = 'pane.item.notes.count.' + (c == 0 && 'zero' || c == 1 && 'singular' || 'plural'); - label.value = Zotero.getString(str, [c]); - } - - ReactDOM.render( - { - this._setPinnedNote(id); - }} - />, - listInner, - () => { - _updateStandaloneNotesList(); - } - ); - - - this.updateStandaloneNotesList = _updateStandaloneNotesList; - } - - this._setPinnedNote = function (itemID) { - let pinnedDeck = document.getElementById('zotero-item-pane-pin-deck2'); - pinnedDeck.setAttribute('selectedIndex', 1); - let pinnedNote = document.getElementById('zotero-item-pane-pinned-note'); - pinnedNote.mode = 'edit'; - pinnedNote.item = Zotero.Items.get(itemID); - pinnedNote.parentItem = null; - this.togglePane(false); - } - - this._appendNoteRows = function (notes, list, editable, onClick, onDelete) { - for (var i = 0; i < notes.length; i++) { - let note = notes[i]; - let id = notes[i].id; - - var icon = document.createElement('image'); - icon.className = 'zotero-box-icon'; - icon.setAttribute('src', `chrome://zotero/skin/treeitem-note${Zotero.hiDPISuffix}.png`); - - var label = document.createElement('label'); - label.className = 'zotero-box-label'; - var title = note.getNoteTitle(); - title = title ? title : Zotero.getString('pane.item.notes.untitled'); - label.setAttribute('value', title); - label.setAttribute('flex', '1'); //so that the long names will flex smaller - label.setAttribute('crop', 'end'); - - var box = document.createElement('box'); - box.setAttribute('class', 'zotero-clicky'); - box.addEventListener('click', () => { - onClick(id); - }); - box.appendChild(icon); - box.appendChild(label); - - if (editable) { - var removeButton = document.createElement('label'); - removeButton.setAttribute('value', '-'); - removeButton.setAttribute('class', 'zotero-clicky zotero-clicky-minus'); - removeButton.addEventListener('click', function () { - onDelete(id) - }); - } - - var row = document.createElement('row'); - row.appendChild(box); - if (editable) { - row.appendChild(removeButton); - } - - list.appendChild(row); - } - } - - this.removeTabContext = function (tabID) { - document.getElementById(tabID + '-context').remove(); - _contextNoteUpdaters = _contextNoteUpdaters.filter(x => x.tabID != tabID); - }; - - this.selectTabContext = function (tabID, type) { - var pane = document.getElementById('zotero-item-pane'); - pane.setAttribute('data-type', type); - - let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); - let prevIndex = contextualDeck.selectedIndex; - contextualDeck.selectedIndex = Array.from(contextualDeck.children).findIndex(x => x.id == tabID + '-context'); - - let toolbar = document.getElementById('zotero-pane-horizontal-space'); - let extendedToolbar = document.getElementById('zotero-item-pane-padding-top'); - let itemPane = document.getElementById('zotero-item-pane'); - - if (prevIndex != 0) { - _pdfTabHidden = itemPane.hidden; - } - - if (type == 'library') { - toolbar.hidden = false; - extendedToolbar.hidden = true; - itemPane.hidden = false; - } - else { - toolbar.hidden = true; - extendedToolbar.hidden = false; - itemPane.hidden = _pdfTabHidden; - } - - let state = contextualDeck.children[contextualDeck.selectedIndex].getAttribute('state'); - let mainDeck = document.getElementById('zotero-item-pane-main-deck'); - if (state == 0) { - pane.setAttribute('data-mode', 'item'); - document.getElementById('temp-toggle-1').innerHTML = '[📄] 📒'; - document.getElementById('temp-toggle-2').innerHTML = '[📄] 📒'; - mainDeck.selectedIndex = state; - } - else if (state == 1) { - pane.setAttribute('data-mode', 'notes'); - document.getElementById('temp-toggle-1').innerHTML = '📄 [📒]'; - document.getElementById('temp-toggle-2').innerHTML = '📄 [📒]'; - mainDeck.selectedIndex = state; - } - }; - - - this.addPDFTabContext = function (tabID, itemID) { - let contextualDeck = document.getElementById('zotero-item-pane-contextual-deck'); - - let container = document.createElement('vbox'); - container.id = tabID + '-context'; - container.className = 'zotero-item-pane-content'; - contextualDeck.appendChild(container); - - var item = Zotero.Items.get(itemID); - if (!item.parentID) { - container.append('The PDF doesn\'t have a parent'); - return; - } - - let parentID = item.parentID; - - let mainDeck = document.getElementById('zotero-item-pane-main-deck'); - let pinDeck = document.getElementById('zotero-item-pane-pin-deck2'); - container.setAttribute('state', (mainDeck.selectedIndex == 1 && pinDeck.selectedIndex == 1) ? 1 : 0) - - - let parentItem = Zotero.Items.get(parentID); - - let tabbox = document.createElement('tabbox'); - tabbox.setAttribute('flex', '1'); - tabbox.className = 'zotero-view-tabbox'; - let tabs = document.createElement('tabs'); - tabs.className = 'zotero-editpane-tabs'; - - container.append(tabbox); - - - let tabInfo = document.createElement('tab'); - tabInfo.setAttribute('label', Zotero.Intl.strings['zotero.tabs.info.label']); - let tabNotes = document.createElement('tab'); - tabNotes.setAttribute('label', Zotero.Intl.strings['zotero.tabs.notes.label']); - let tabTags = document.createElement('tab'); - tabTags.setAttribute('label', Zotero.Intl.strings['zotero.tabs.tags.label']); - let tabRelated = document.createElement('tab'); - tabRelated.setAttribute('label', Zotero.Intl.strings['zotero.tabs.related.label']); - tabs.append(tabInfo, tabNotes, tabTags, tabRelated); - - let tabpanels = document.createElement('tabpanels'); - tabpanels.setAttribute('flex', '1'); - tabpanels.className = 'zotero-view-item'; - - tabbox.append(tabs, tabpanels); - - let panelInfo = document.createElement('tabpanel'); - panelInfo.setAttribute('flex', '1') - panelInfo.className = 'zotero-editpane-item-box'; - let itemBox = document.createElement('zoteroitembox'); - itemBox.setAttribute('flex', '1'); - panelInfo.append(itemBox); - - let panelNotes = document.createElement('tabpanel'); - panelNotes.setAttribute('flex', '1'); - panelNotes.setAttribute('orient', 'vertical'); - - var deck = document.createElement('deck'); - deck.setAttribute('flex', '1'); - - panelNotes.append(deck); - - var vbox2 = document.createElement('vbox'); - - var backButton = document.createElementNS(HTML_NS, 'div'); - backButton.className = 'item-pane-back-button'; - backButton.textContent = (Zotero.dir == 'ltr' ? '←' : '→') + ' ' - + Zotero.getString('general.back'); - backButton.addEventListener('click', () => { - deck.setAttribute('selectedIndex', 0); - }); - - let note = document.createElement('zoteronoteeditor'); - note.setAttribute('flex', 1); - - vbox2.append(backButton, note); - - - var vbox = document.createElement('vbox'); - vbox.setAttribute('flex', '1'); - vbox.setAttribute('class', 'zotero-box'); - panelNotes.append(vbox); - - var hbox = document.createElement('hbox'); - hbox.setAttribute('align', 'center'); - - var label = document.createElement('label'); - var button = document.createElement('button'); - button.setAttribute('label', Zotero.Intl.strings['zotero.item.add']); - button.addEventListener('click', () => { - deck.setAttribute('selectedIndex', 1); - let item = new Zotero.Item('note'); - item.libraryID = parentItem.libraryID; - item.parentItemID = parentItem.id; - note.mode = 'edit'; - note.item = item; - note.focus(); - }); - hbox.append(label, button); - - var grid = document.createElement('grid'); - grid.setAttribute('flex', 1); - var columns = document.createElement('columns'); - var column = document.createElement('column'); - column.setAttribute('flex', 1); - columns.append(column); - var column = document.createElement('column'); - columns.append(column); - grid.append(columns); - var rows = document.createElement('rows'); - rows.setAttribute('flex', 1); - grid.append(rows); - - vbox.append(hbox, grid); - - deck.append(vbox, vbox2); - - deck.setAttribute('selectedIndex', 0); - deck.className = 'zotero-item-pane-content'; - - - let panelTags = document.createElement('tabpanel'); - panelTags.setAttribute('orient', 'vertical'); - panelTags.setAttribute('context', 'tags-context-menu'); - panelTags.className = 'tags-pane'; - panelTags.style.display = 'flex'; - - var div = document.createElementNS(HTML_NS, 'div'); - div.className = 'tags-box-container'; - div.style.display = 'flex'; - div.style.flexGrow = '1'; - panelTags.append(div); - - let panelRelated = document.createElement('tabpanel'); - let relatedBox = document.createElement('relatedbox'); - relatedBox.setAttribute('flex', '1'); - relatedBox.className = 'zotero-editpane-related'; - panelRelated.append(relatedBox); - - tabpanels.append(panelInfo, panelNotes, panelTags, panelRelated); - - itemBox.mode = 'edit'; - itemBox.item = Zotero.Items.get(parentID); - - relatedBox.mode = 'edit'; - relatedBox.item = parentItem; - - panelRelated.addEventListener('click', (event) => { - if (event.originalTarget.closest('.zotero-clicky')) { - Zotero_Tabs.select('zotero-pane'); - } - }); - - let _renderNotesPanel = () => { - while (rows.firstChild) { - rows.firstChild.remove(); - } - - let parentNotes = Zotero.Items.get(parentItem.getNotes()); - this._appendNoteRows(parentNotes, rows, true, (id) => { - deck.setAttribute('selectedIndex', 1); - note.mode = 'edit'; - note.item = Zotero.Items.get(id); - note.parentItem = null; - }, (id) => { - ZoteroItemPane.removeNote(id); - }); - - var c = parentNotes.length; - var str = 'pane.item.notes.count.' + (c == 0 && 'zero' || c == 1 && 'singular' || 'plural'); - label.value = Zotero.getString(str, [c]); - } - - _contextNoteUpdaters.push({ - tabID, - callback: _renderNotesPanel - }); - - _renderNotesPanel(); - - let mode = 'edit'; - - let _tagsBox = { current: null }; - let focusItemsList = false; - - let _renderTagsPanel = () => { - ReactDOM.render( - , - div - ); - } - - _renderTagsPanel(); - } } addEventListener("load", function(e) { ZoteroItemPane.onLoad(e); }, false); diff --git a/chrome/content/zotero/itemPane.xul b/chrome/content/zotero/itemPane.xul index 8daeddc4f1..61318faa17 100644 --- a/chrome/content/zotero/itemPane.xul +++ b/chrome/content/zotero/itemPane.xul @@ -34,138 +34,116 @@ - - + + +