diff --git a/chrome/content/zotero/bindings/noteeditor.xml b/chrome/content/zotero/bindings/noteeditor.xml index dccdc333cb..1f2daa8e10 100644 --- a/chrome/content/zotero/bindings/noteeditor.xml +++ b/chrome/content/zotero/bindings/noteeditor.xml @@ -390,15 +390,15 @@ this.id('tags').item = this.item; this.updateTagsSummary(); - this.id('seeAlso').item = this.item; - this.updateSeeAlsoSummary(); + this.id('related').item = this.item; + this.updateRelatedSummary(); ]]> @@ -447,7 +447,7 @@ }, this); ]]> - + 0) { var x = this.boxObject.screenX; var y = this.boxObject.screenY; - this.id('seeAlsoPopup').openPopupAtScreen(x, y, false); + this.id('relatedPopup').openPopupAtScreen(x, y, false); } else { - this.id('seeAlso').add(); + this.id('related').add(); } }, this); ]]> - + @@ -535,8 +535,8 @@ - - + + @@ -545,8 +545,8 @@ - - + + "view" @@ -67,7 +67,7 @@ @@ -80,12 +80,12 @@ if (this.item) { yield this.item.loadRelations() .tap(() => Zotero.Promise.check(this.item)); - var related = this.item.relatedItems; - if (related) { - related = yield Zotero.Items.getAsync(related) + var keys = this.item.relatedItems; + if (keys.length) { + let items = yield Zotero.Items.getAsync(keys) .tap(() => Zotero.Promise.check(this.item)); - for(var i = 0; i < related.length; i++) { - r = r + related[i].getDisplayTitle() + ", "; + for (let item of items) { + r = r + item.getDisplayTitle() + ", "; } r = r.substr(0,r.length-2); } @@ -96,87 +96,117 @@ ]]> - + + + + + + + + + + + + + + + + + + Zotero.Promise.check(this.item)); - var related = this.item.relatedItems; - if (related) { - related = yield Zotero.Items.getAsync(related) + var relatedKeys = this.item.relatedItems; + for (var i = 0; i < relatedKeys.length; i++) { + let key = relatedKeys[i]; + let relatedItem = + yield Zotero.Items.getByLibraryAndKeyAsync( + this.item.libraryID, key + ) .tap(() => Zotero.Promise.check(this.item)); - for (var i = 0; i < related.length; i++) { - var icon= document.createElement("image"); - icon.className = "zotero-box-icon"; - var type = Zotero.ItemTypes.getName(related[i].itemTypeID); - if (type=='attachment') - { - switch (related[i].getAttachmentLinkMode()) - { - case Zotero.Attachments.LINK_MODE_LINKED_URL: - type += '-web-link'; - break; - - case Zotero.Attachments.LINK_MODE_IMPORTED_URL: - type += '-snapshot'; - break; - - case Zotero.Attachments.LINK_MODE_LINKED_FILE: - type += '-link'; - break; - - case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: - type += '-file'; - break; - } + let id = relatedItem.id; + yield relatedItem.loadItemData() + .tap(() => Zotero.Promise.check(this.item)); + let icon = document.createElement("image"); + icon.className = "zotero-box-icon"; + let type = Zotero.ItemTypes.getName(relatedItem.itemTypeID); + if (type=='attachment') + { + switch (relatedItem.attaachmentLinkMode) { + case Zotero.Attachments.LINK_MODE_LINKED_URL: + type += '-web-link'; + break; + + case Zotero.Attachments.LINK_MODE_IMPORTED_URL: + type += '-snapshot'; + break; + + case Zotero.Attachments.LINK_MODE_LINKED_FILE: + type += '-link'; + break; + + case Zotero.Attachments.LINK_MODE_IMPORTED_FILE: + type += '-file'; + break; } - icon.setAttribute('src','chrome://zotero/skin/treeitem-' + type + '.png'); - - var label = document.createElement("label"); - label.className = "zotero-box-label"; - label.setAttribute('value', related[i].getDisplayTitle()); - label.setAttribute('crop','end'); - label.setAttribute('flex','1'); - - var box = document.createElement('box'); - box.setAttribute('onclick', - "document.getBindingParent(this).showItem('" + related[i].id + "')"); - box.setAttribute('class','zotero-clicky'); - box.setAttribute('flex','1'); - box.appendChild(icon); - box.appendChild(label); - - if (this.editable) { - var remove = document.createElement("label"); - remove.setAttribute('value','-'); - remove.setAttribute('onclick', - "document.getBindingParent(this).remove('" + related[i].id + "');"); - remove.setAttribute('class','zotero-clicky zotero-clicky-minus'); - } - - var row = document.createElement("row"); - row.appendChild(box); - if (this.editable) { - row.appendChild(remove); - } - row.setAttribute('id', 'seealso-' + related[i].id); - rows.appendChild(row); } - this.updateCount(related.length); - } - else - { - this.updateCount(); + icon.setAttribute('src','chrome://zotero/skin/treeitem-' + type + '.png'); + + var label = document.createElement("label"); + label.className = "zotero-box-label"; + label.setAttribute('value', relatedItem.getDisplayTitle()); + label.setAttribute('crop','end'); + label.setAttribute('flex','1'); + + var box = document.createElement('box'); + box.setAttribute('onclick', + "document.getBindingParent(this).showItem('" + id + "')"); + box.setAttribute('class','zotero-clicky'); + box.setAttribute('flex','1'); + box.appendChild(icon); + box.appendChild(label); + + if (this.editable) { + var remove = document.createElement("label"); + remove.setAttribute('value','-'); + remove.setAttribute('onclick', + "document.getBindingParent(this).remove('" + id + "');"); + remove.setAttribute('class','zotero-clicky zotero-clicky-minus'); + } + + var row = document.createElement("row"); + row.appendChild(box); + if (this.editable) { + row.appendChild(remove); + } + rows.appendChild(row); } + this.updateCount(relatedKeys.length); } }, this); ]]> @@ -190,22 +220,37 @@ window.openDialog('chrome://zotero/content/selectItemsDialog.xul', '', 'chrome,dialog=no,modal,centerscreen,resizable=yes', io); - if(io.dataOut) { - if (io.dataOut.length) { - var relItem = yield Zotero.Items.getAsync(io.dataOut[0]); - if (relItem.libraryID != this.item.libraryID) { - // FIXME - var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] - .getService(Components.interfaces.nsIPromptService); - ps.alert(null, "", "You cannot relate items in different libraries in this Zotero release."); - return; + if (!io.dataOut || !io.dataOut.length) { + return; + } + var relItems = yield Zotero.Items.getAsync(io.dataOut); + if (!relItems.length) { + return; + } + + if (relItems[0].libraryID != this.item.libraryID) { + // FIXME + var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] + .getService(Components.interfaces.nsIPromptService); + ps.alert(null, "", "You cannot relate items in different libraries."); + return; + } + yield Zotero.DB.executeTransaction(function* () { + for (let relItem of relItems) { + yield this.item.loadRelations(); + if (this.item.addRelatedItem(relItem)) { + yield this.item.save({ + skipDateModifiedUpdate: true + }); + } + yield relItem.loadRelations(); + if (relItem.addRelatedItem(this.item)) { + yield relItem.save({ + skipDateModifiedUpdate: true + }); } } - for(var i = 0; i < io.dataOut.length; i++) { - this.item.addRelatedItem(io.dataOut[i]); - } - yield this.item.save(); - } + }.bind(this)); }, this); ]]> @@ -213,17 +258,23 @@ @@ -282,7 +333,7 @@ str += 'plural'; break; } - this.id('seeAlsoNum').value = Zotero.getString(str, [count]); + this.id('relatedNum').value = Zotero.getString(str, [count]); ]]> @@ -298,7 +349,7 @@ - + @@ -307,7 +358,7 @@ - + diff --git a/chrome/content/zotero/itemPane.xul b/chrome/content/zotero/itemPane.xul index 4b6c35a5a8..647c9b532e 100644 --- a/chrome/content/zotero/itemPane.xul +++ b/chrome/content/zotero/itemPane.xul @@ -82,7 +82,7 @@ - + diff --git a/chrome/content/zotero/xpcom/collectionTreeView.js b/chrome/content/zotero/xpcom/collectionTreeView.js index f3022e78ed..f5f28f7210 100644 --- a/chrome/content/zotero/xpcom/collectionTreeView.js +++ b/chrome/content/zotero/xpcom/collectionTreeView.js @@ -1507,7 +1507,7 @@ Zotero.CollectionTreeView.prototype.canDropCheckAsync = Zotero.Promise.coroutine // Cross-library drag if (treeRow.ref.libraryID != item.libraryID) { - let linkedItem = yield item.getLinkedItem(treeRow.ref.libraryID); + let linkedItem = yield item.getLinkedItem(treeRow.ref.libraryID, true); if (linkedItem && !linkedItem.deleted) { // For drag to root, skip if linked item exists if (treeRow.isLibrary(true)) { @@ -1613,18 +1613,13 @@ Zotero.CollectionTreeView.prototype.drop = Zotero.Promise.coroutine(function* (r var targetLibraryType = Zotero.Libraries.getType(targetLibraryID); // Check if there's already a copy of this item in the library - var linkedItem = yield item.getLinkedItem(targetLibraryID); + var linkedItem = yield item.getLinkedItem(targetLibraryID, true); if (linkedItem) { - // If linked item is in the trash, undelete it + // If linked item is in the trash, undelete it and remove it from collections + // (since it shouldn't be restored to previous collections) if (linkedItem.deleted) { - yield linkedItems.loadCollections(); - // Remove from any existing collections, or else when it gets - // undeleted it would reappear in those collections - var collectionIDs = linkedItem.getCollections(); - for each(var collectionID in collectionIDs) { - var col = yield Zotero.Collections.getAsync(collectionID); - col.removeItem(linkedItem.id); - } + yield linkedItem.loadCollections(); + linkedItem.setCollections(); linkedItem.deleted = false; yield linkedItem.save(); } diff --git a/chrome/content/zotero/xpcom/data/cachedTypes.js b/chrome/content/zotero/xpcom/data/cachedTypes.js index 47568048de..f8b4729730 100644 --- a/chrome/content/zotero/xpcom/data/cachedTypes.js +++ b/chrome/content/zotero/xpcom/data/cachedTypes.js @@ -560,3 +560,16 @@ Zotero.CharacterSets = new function() { }; } + +Zotero.RelationPredicates = new function () { + Zotero.CachedTypes.apply(this, arguments); + this.constructor.prototype = new Zotero.CachedTypes(); + + this._typeDesc = 'relation predicate'; + this._typeDescPlural = 'relation predicates'; + this._idCol = 'predicateID'; + this._nameCol = 'predicate'; + this._table = 'relationPredicates'; + this._ignoreCase = false; + this._allowAdd = true; +} diff --git a/chrome/content/zotero/xpcom/data/collection.js b/chrome/content/zotero/xpcom/data/collection.js index a6d9160f2c..8137e24ebf 100644 --- a/chrome/content/zotero/xpcom/data/collection.js +++ b/chrome/content/zotero/xpcom/data/collection.js @@ -40,7 +40,8 @@ Zotero.extendClass(Zotero.DataObject, Zotero.Collection); Zotero.Collection.prototype._objectType = 'collection'; Zotero.Collection.prototype._dataTypes = Zotero.Collection._super.prototype._dataTypes.concat([ 'childCollections', - 'childItems' + 'childItems', + 'relations' ]); Zotero.defineProperty(Zotero.Collection.prototype, 'ChildObjects', { @@ -388,7 +389,7 @@ Zotero.Collection.prototype.addItems = Zotero.Promise.coroutine(function* (itemI * * @return {Promise} */ -Zotero.Collection.prototype.removeItem = function (itemIDs) { +Zotero.Collection.prototype.removeItem = function (itemID) { return this.removeItems([itemID]); } @@ -591,10 +592,6 @@ Zotero.Collection.prototype._eraseData = Zotero.Promise.coroutine(function* (env yield this.ChildObjects.trash(del); } - // Remove relations - var uri = Zotero.URI.getCollectionURI(this); - yield Zotero.Relations.eraseByURI(uri); - var placeholders = collections.map(function () '?').join(); // Remove item associations for all descendent collections @@ -785,30 +782,21 @@ Zotero.Collection.prototype.getDescendents = function (nested, type, includeDele /** * Return a collection in the specified library equivalent to this collection */ -Zotero.Collection.prototype.getLinkedCollection = function (libraryID) { - return this._getLinkedObject(libraryID); -}; +Zotero.Collection.prototype.getLinkedCollection = function (libraryID, bidrectional) { + return this._getLinkedObject(libraryID, bidrectional); +} + +/** + * Add a linked-object relation pointing to the given collection + * + * Does not require a separate save() + */ Zotero.Collection.prototype.addLinkedCollection = Zotero.Promise.coroutine(function* (collection) { - var url1 = Zotero.URI.getCollectionURI(this); - var url2 = Zotero.URI.getCollectionURI(collection); - var predicate = Zotero.Relations.linkedObjectPredicate; - if ((yield Zotero.Relations.getByURIs(url1, predicate, url2)).length - || (yield Zotero.Relations.getByURIs(url2, predicate, url1)).length) { - Zotero.debug(this._ObjectTypePlural + " " + this.key + " and " + collection.key + " are already linked"); - return false; - } - - // If both group libraries, store relation with source group. - // Otherwise, store with personal library. - var userLibraryID = Zotero.Libraries.userLibraryID; - var libraryID = (this.libraryID != userLibraryID && collection.libraryID != userLibraryID) - ? this.libraryID - : Zotero.Libraries.userLibraryID; - - yield Zotero.Relations.add(libraryID, url1, predicate, url2); + return this._addLinkedObject(collection); }); + // // Private methods // diff --git a/chrome/content/zotero/xpcom/data/dataObject.js b/chrome/content/zotero/xpcom/data/dataObject.js index 199d9f7e28..b9dce260e1 100644 --- a/chrome/content/zotero/xpcom/data/dataObject.js +++ b/chrome/content/zotero/xpcom/data/dataObject.js @@ -50,6 +50,8 @@ Zotero.DataObject = function () { this._parentID = null; this._parentKey = null; + this._relations = []; + // Set in dataObjects.js this._inCache = false; @@ -266,39 +268,120 @@ Zotero.DataObject.prototype._setParentKey = function(key) { return true; } - +// +// Relations +// /** * Returns all relations of the object * - * @return {object} Object with predicates as keys and URI[], or URI (as string) - * in the case of a single object, as values + * @return {Object} - Object with predicates as keys and arrays of URIs as values */ Zotero.DataObject.prototype.getRelations = function () { this._requireData('relations'); var relations = {}; for (let i=0; i b[0]) return 1; - if (a[1] < b[1]) return -1; - if (a[1] > b[1]) return 1; - return 0; - }; - - var newRelationsFlat = []; - for (let predicate in newRelations) { - let object = newRelations[predicate]; - for (let i=0; i b[0]) return 1; + if (a[1] < b[1]) return -1; + if (a[1] > b[1]) return 1; + return 0; + }; oldRelations.sort(sortFunc); newRelationsFlat.sort(sortFunc); for (let i=0; i|false} Linked object, or false if not found */ -Zotero.DataObject.prototype._getLinkedObject = Zotero.Promise.coroutine(function* (libraryID) { +Zotero.DataObject.prototype._getLinkedObject = Zotero.Promise.coroutine(function* (libraryID, bidirectional) { + if (!libraryID) { + throw new Error("libraryID not provided"); + } + if (libraryID == this._libraryID) { throw new Error(this._ObjectType + " is already in library " + libraryID); } + yield this.loadRelations(); + var predicate = Zotero.Relations.linkedObjectPredicate; - var uri = Zotero.URI['get' + this._ObjectType + 'URI'](this); + var libraryObjectPrefix = Zotero.URI.getLibraryURI(libraryID) + + "/" + this._objectTypePlural + "/"; - // Get all relations with this object as the subject or object - var links = yield Zotero.Promise.all([ - Zotero.Relations.getSubject(false, predicate, uri), - Zotero.Relations.getObject(uri, predicate, false) - ]); - links = links[0].concat(links[1]); - - if (!links.length) { - return false; - } - - if (libraryID) { - var libraryObjectPrefix = Zotero.URI.getLibraryURI(libraryID) + "/" + this._objectTypePlural + "/"; - } - else { - var libraryObjectPrefix = Zotero.URI.getCurrentUserURI() + "/" + this._objectTypePlural + "/"; - } - - for (let i=0; i} + */ +Zotero.DataObject.prototype._addLinkedObject = Zotero.Promise.coroutine(function* (object) { + if (object.libraryID == this._libraryID) { + throw new Error("Can't add linked " + this._objectType + " in same library"); + } + + yield this.loadRelations(); + + var predicate = Zotero.Relations.linkedObjectPredicate; + var thisURI = Zotero.URI['get' + this._ObjectType + 'URI'](this); + var objectURI = Zotero.URI['get' + this._ObjectType + 'URI'](object); + + var exists = this.hasRelation(predicate, objectURI); + if (exists) { + Zotero.debug(this._ObjectTypePlural + " " + this.libraryKey + + " and " + object.libraryKey + " are already linked"); + return false; + } + + // If one of the items is a personal library, store relation with that. Otherwise, use + // current item's library (which in calling code is the new, copied item, since that's what + // the user definitely has access to). + var userLibraryID = Zotero.Libraries.userLibraryID; + if (this.libraryID == userLibraryID || object.libraryID != userLibraryID) { + this.addRelation(predicate, objectURI); + yield this.save({ + skipDateModifiedUpdate: true + }); + } + else { + yield object.loadRelations(); + object.addRelation(predicate, thisURI); + yield object.save({ + skipDateModifiedUpdate: true + }); + } + + return true; +}); + + /* * Build object from database */ @@ -462,6 +601,66 @@ Zotero.DataObject.prototype.loadPrimaryData = Zotero.Promise.coroutine(function* this.loadFromRow(row, reload); }); + +Zotero.DataObject.prototype.loadRelations = Zotero.Promise.coroutine(function* (reload) { + if (this._objectType != 'collection' && this._objectType != 'item') { + throw new Error("Relations not supported for " + this._objectTypePlural); + } + + if (this._loaded.relations && !reload) { + return; + } + + Zotero.debug("Loading relations for " + this._objectType + " " + this.libraryKey); + + this._requireData('primaryData'); + + var sql = "SELECT predicate, object FROM " + this._objectType + "Relations " + + "JOIN relationPredicates USING (predicateID) " + + "WHERE " + this.ObjectsClass.idColumn + "=?"; + var rows = yield Zotero.DB.queryAsync(sql, this.id); + + var relations = {}; + function addRel(predicate, object) { + if (!relations[predicate]) { + relations[predicate] = []; + } + relations[predicate].push(object); + } + + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + addRel(row.predicate, row.object); + } + + /*if (this._objectType == 'item') { + let getURI = Zotero.URI["get" + this._ObjectType + "URI"].bind(Zotero.URI); + let objectURI = getURI(this); + + // Related items are bidirectional, so include any pointing to this object + let objects = yield Zotero.Relations.getByPredicateAndObject( + Zotero.Relations.relatedItemPredicate, objectURI + ); + for (let i = 0; i < objects.length; i++) { + addRel(Zotero.Relations.relatedItemPredicate, getURI(objects[i])); + } + + // Also include any owl:sameAs relations pointing to this object + objects = yield Zotero.Relations.getByPredicateAndObject( + Zotero.Relations.linkedObjectPredicate, objectURI + ); + for (let i = 0; i < objects.length; i++) { + addRel(Zotero.Relations.linkedObjectPredicate, getURI(objects[i])); + } + }*/ + + // Relations are stored as predicate-object pairs + this._relations = this._flattenRelations(relations); + this._loaded.relations = true; + this._clearChanged('relations'); +}); + + /** * Reloads loaded, changed data * @@ -774,6 +973,53 @@ Zotero.DataObject.prototype._saveData = function (env) { }; Zotero.DataObject.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) { + // Relations + if (this._changed.relations) { + let toAdd, toRemove; + // Convert to individual JSON objects, diff, and convert back + if (this._previousData.relations) { + let oldRelationsJSON = this._previousData.relations.map(x => JSON.stringify(x)); + let newRelationsJSON = this._relations.map(x => JSON.stringify(x)); + toAdd = Zotero.Utilities.arrayDiff(newRelationsJSON, oldRelationsJSON) + .map(x => JSON.parse(x)); + toRemove = Zotero.Utilities.arrayDiff(oldRelationsJSON, newRelationsJSON) + .map(x => JSON.parse(x)); + } + else { + toAdd = this._relations; + toRemove = []; + } + + if (toAdd.length) { + let sql = "INSERT INTO " + this._objectType + "Relations " + + "(" + this._ObjectsClass.idColumn + ", predicateID, object) VALUES "; + // Convert predicates to ids + for (let i = 0; i < toAdd.length; i++) { + toAdd[i][0] = yield Zotero.RelationPredicates.add(toAdd[i][0]); + } + yield Zotero.DB.queryAsync( + sql + toAdd.map(x => "(?, ?, ?)").join(", "), + toAdd.map(x => [this.id, x[0], x[1]]) + .reduce((x, y) => x.concat(y)) + ); + } + + if (toRemove.length) { + for (let i = 0; i < toRemove.length; i++) { + let sql = "DELETE FROM " + this._objectType + "Relations " + + "WHERE " + this._ObjectsClass.idColumn + "=? AND predicateID=? AND object=?"; + yield Zotero.DB.queryAsync( + sql, + [ + this.id, + (yield Zotero.RelationPredicates.add(toRemove[i][0])), + toRemove[i][1] + ] + ); + } + } + } + if (env.isNew) { if (!env.skipCache) { // Register this object's identifiers in Zotero.DataObjects @@ -937,3 +1183,32 @@ Zotero.DataObject.prototype._disabledCheck = function () { + "use Zotero." + this._ObjectTypePlural + ".getAsync()"); } } + + +/** + * Flatten API JSON relations object into an array of unique predicate-object pairs + * + * @param {Object} relations - Relations object in API JSON format, with predicates as keys + * and arrays of URIs as objects + * @return {Array[]} - Predicate-object pairs + */ +Zotero.DataObject.prototype._flattenRelations = function (relations) { + var relationsFlat = []; + for (let predicate in relations) { + let object = relations[predicate]; + if (Array.isArray(object)) { + object = Zotero.Utilities.arrayUnique(object); + for (let i = 0; i < object.length; i++) { + relationsFlat.push([predicate, object[i]]); + } + } + else if (typeof object == 'string') { + relationsFlat.push([predicate, object]); + } + else { + Zotero.debug(object, 1); + throw new Error("Invalid relation value"); + } + } + return relationsFlat; +} diff --git a/chrome/content/zotero/xpcom/data/group.js b/chrome/content/zotero/xpcom/data/group.js index 1c5fc13eba..76103e0924 100644 --- a/chrome/content/zotero/xpcom/data/group.js +++ b/chrome/content/zotero/xpcom/data/group.js @@ -284,9 +284,6 @@ Zotero.Group.prototype.erase = Zotero.Promise.coroutine(function* () { } } - var prefix = "groups/" + this.id; - yield Zotero.Relations.eraseByURIPrefix(Zotero.URI.defaultPrefix + prefix); - // Delete library row, which deletes from tags, syncDeleteLog, syncedSettings, and groups // tables via cascade. If any of those gain caching, they should be deleted separately. sql = "DELETE FROM libraries WHERE libraryID=?"; diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index d18c841fbb..494c2d3272 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -68,7 +68,6 @@ Zotero.Item = function(itemTypeOrID) { this._tags = []; this._collections = []; - this._relations = []; this._bestAttachmentState = null; this._fileExists = null; @@ -78,8 +77,6 @@ Zotero.Item = function(itemTypeOrID) { this._noteAccessTime = null; - this._relatedItems = false; - if (itemTypeOrID) { // setType initializes type-specific properties in this._itemData this.setType(Zotero.ItemTypes.getID(itemTypeOrID)); @@ -159,8 +156,7 @@ Zotero.defineProperty(Zotero.Item.prototype, 'sortCreator', { get: function() this._sortCreator }); Zotero.defineProperty(Zotero.Item.prototype, 'relatedItems', { - get: function() this._getRelatedItems(true), - set: function(arr) this._setRelatedItems(arr) + get: function() this._getRelatedItems() }); Zotero.Item.prototype.getID = function() { @@ -1021,66 +1017,37 @@ for (let name of ['deleted']) { } -Zotero.Item.prototype.addRelatedItem = Zotero.Promise.coroutine(function* (itemID) { - var parsedInt = parseInt(itemID); - if (parsedInt != itemID) { - throw ("itemID '" + itemID + "' not an integer in Zotero.Item.addRelatedItem()"); +/** + * @param {Zotero.Item} + * @return {Boolean} + */ +Zotero.Item.prototype.addRelatedItem = function (item) { + if (!(item instanceof Zotero.Item)) { + throw new Error("'item' must be a Zotero.Item"); } - itemID = parsedInt; - if (itemID == this.id) { + if (item == this) { Zotero.debug("Can't relate item to itself in Zotero.Item.addRelatedItem()", 2); return false; } - var current = this._getRelatedItems(true); - if (current.indexOf(itemID) != -1) { - Zotero.debug("Item " + this.id + " already related to item " - + itemID + " in Zotero.Item.addItem()"); - return false; + if (item.libraryID != this.libraryID) { + throw new Error("Cannot relate item to an item in a different library"); } - var item = yield this.ObjectsClass.getAsync(itemID); - if (!item) { - throw ("Can't relate item to invalid item " + itemID - + " in Zotero.Item.addRelatedItem()"); - } - /* - var otherCurrent = item.relatedItems; - if (otherCurrent.length && otherCurrent.indexOf(this.id) != -1) { - Zotero.debug("Other item " + itemID + " already related to item " - + this.id + " in Zotero.Item.addItem()"); - return false; - } - */ - - this._markFieldChange('related', current); - this._changed.relatedItems = true; - this._relatedItems.push(item); - return true; -}); + return this.addRelation(Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(item)); +} -Zotero.Item.prototype.removeRelatedItem = Zotero.Promise.coroutine(function* (itemID) { - var parsedInt = parseInt(itemID); - if (parsedInt != itemID) { - throw ("itemID '" + itemID + "' not an integer in Zotero.Item.removeRelatedItem()"); - } - itemID = parsedInt; - - var current = this._getRelatedItems(true); - var index = current.indexOf(itemID); - - if (index == -1) { - Zotero.debug("Item " + this.id + " isn't related to item " - + itemID + " in Zotero.Item.removeRelatedItem()"); - return false; +/** + * @param {Zotero.Item} + */ +Zotero.Item.prototype.removeRelatedItem = Zotero.Promise.coroutine(function* (item) { + if (!(item instanceof Zotero.Item)) { + throw new Error("'item' must be a Zotero.Item"); } - this._markFieldChange('related', current); - this._changed.relatedItems = true; - this._relatedItems.splice(index, 1); - return true; + return this.removeRelation(Zotero.Relations.relatedItemPredicate, Zotero.URI.getItemURI(item)); }); @@ -1385,13 +1352,16 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) { } else { // If undeleting, remove any merge-tracking relations - var relations = yield Zotero.Relations.getByURIs( - Zotero.URI.getItemURI(this), - Zotero.Relations.deletedItemPredicate, - false + let predicate = Zotero.Relations.replacedItemPredicate; + let thisURI = Zotero.URI.getItemURI(this); + let mergeItems = yield Zotero.Relations.getByPredicateAndObject( + 'item', predicate, thisURI ); - for each(let relation in relations) { - relation.erase(); + for (let mergeItem of mergeItems) { + mergeItem.removeRelation(predicate, thisURI); + yield mergeItem.save({ + skipDateModifiedUpdate: true + }); } sql = "DELETE FROM deletedItems WHERE itemID=?"; @@ -1510,12 +1480,10 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) { let newTags = this._tags; // Convert to individual JSON objects, diff, and convert back - let oldTagsJSON = oldTags.map(function (x) JSON.stringify(x)); - let newTagsJSON = newTags.map(function (x) JSON.stringify(x)); - let toAdd = Zotero.Utilities.arrayDiff(newTagsJSON, oldTagsJSON) - .map(function (x) JSON.parse(x)); - let toRemove = Zotero.Utilities.arrayDiff(oldTagsJSON, newTagsJSON) - .map(function (x) JSON.parse(x));; + let oldTagsJSON = oldTags.map(x => JSON.stringify(x)); + let newTagsJSON = newTags.map(x => JSON.stringify(x)); + let toAdd = Zotero.Utilities.arrayDiff(newTagsJSON, oldTagsJSON).map(x => JSON.parse(x)); + let toRemove = Zotero.Utilities.arrayDiff(oldTagsJSON, newTagsJSON).map(x => JSON.parse(x)); for (let i=0; i} + */ +Zotero.Item.prototype.getLinkedItem = function (libraryID, bidirectional) { + return this._getLinkedObject(libraryID, bidirectional); +}; + + +/** + * Add a linked-object relation pointing to the given item + * + * Does not require a separate save() + * + * @return {Promise} + */ +Zotero.Item.prototype.addLinkedItem = Zotero.Promise.coroutine(function* (item) { + return this._addLinkedObject(item); }); @@ -4633,21 +4453,16 @@ Zotero.Item.prototype.loadRelations = Zotero.Promise.coroutine(function* (reload // ////////////////////////////////////////////////////////////////////////////// /** - * Returns related items this item point to + * Returns related items this item points to * - * @return {String[]} - An array of item keys + * @return {String[]} - Keys of related items */ Zotero.Item.prototype._getRelatedItems = function () { this._requireData('relations'); var predicate = Zotero.Relations.relatedItemPredicate; - var relatedItemURIs = this.getRelations()[predicate]; - if (!relatedItemURIs) { - return []; - } - - if (typeof relatedItemURIs == 'string') relatedItemURIs = [relatedItemURIs]; + var relatedItemURIs = this.getRelationsByPredicate(predicate); // Pull out object values from related-item relations, turn into items, and pull out keys var keys = []; @@ -4661,83 +4476,6 @@ Zotero.Item.prototype._getRelatedItems = function () { } - -Zotero.Item.prototype._setRelatedItems = Zotero.Promise.coroutine(function* (itemIDs) { - if (!this._loaded.relatedItems) { - yield this._loadRelatedItems(); - } - - if (itemIDs.constructor.name != 'Array') { - throw ('ids must be an array in Zotero.Items._setRelatedItems()'); - } - - var currentIDs = this._getRelatedItems(true); - var oldIDs = []; // children being kept - var newIDs = []; // new children - - if (itemIDs.length == 0) { - if (currentIDs.length == 0) { - Zotero.debug('No related items added', 4); - return false; - } - } - else { - for (var i in itemIDs) { - var id = itemIDs[i]; - var parsedInt = parseInt(id); - if (parsedInt != id) { - throw ("itemID '" + id + "' not an integer in Zotero.Item.addRelatedItem()"); - } - id = parsedInt; - - if (id == this.id) { - Zotero.debug("Can't relate item to itself in Zotero.Item._setRelatedItems()", 2); - continue; - } - - if (currentIDs.indexOf(id) != -1) { - Zotero.debug("Item " + this.id + " is already related to item " + id); - oldIDs.push(id); - continue; - } - - var item = yield this.ObjectsClass.getAsync(id); - if (!item) { - throw ("Can't relate item to invalid item " + id - + " in Zotero.Item._setRelatedItems()"); - } - /* - var otherCurrent = item.relatedItems; - if (otherCurrent.length && otherCurrent.indexOf(this.id) != -1) { - Zotero.debug("Other item " + id + " already related to item " - + this.id + " in Zotero.Item._setRelatedItems()"); - return false; - } - */ - - newIDs.push(id); - } - } - - // Mark as changed if new or removed ids - if (newIDs.length > 0 || oldIDs.length != currentIDs.length) { - this._markFieldChange('related', currentIDs); - this._changed.relatedItems = true - } - else { - Zotero.debug('Related items not changed in Zotero.Item._setRelatedItems()', 4); - return false; - } - - newIDs = oldIDs.concat(newIDs); - this._relatedItems = []; - for each(var itemID in newIDs) { - this._relatedItems.push(yield this.ObjectsClass.getAsync(itemID)); - } - return true; -}); - - /** * @return {Object} Return a copy of the creators, with additional 'id' properties */ diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js index 2a7632e57c..d7d56d1950 100644 --- a/chrome/content/zotero/xpcom/data/items.js +++ b/chrome/content/zotero/xpcom/data/items.js @@ -410,12 +410,16 @@ Zotero.Items = function() { yield item.loadTags(); yield item.loadRelations(); + var replPred = Zotero.Relations.replacedItemPredicate; + var toSave = {}; + toSave[this.id]; for each(var otherItem in otherItems) { yield otherItem.loadChildItems(); yield otherItem.loadCollections(); yield otherItem.loadTags(); yield otherItem.loadRelations(); + let otherItemURI = Zotero.URI.getItemURI(otherItem); // Move child items to master var ids = otherItem.getAttachments(true).concat(otherItem.getNotes(true)); @@ -428,6 +432,34 @@ Zotero.Items = function() { yield attachment.save(); } + // Add relations to master + item.setRelations(otherItem.getRelations()); + + // Remove merge-tracking relations from other item, so that there aren't two + // subjects for a given deleted object + let replItems = otherItem.getRelationsByPredicate(replPred); + for (let replItem of replItems) { + otherItem.removeRelation(replPred, replItem); + } + + // Update relations on items in the library that point to the other item + // to point to the master instead + let rels = yield Zotero.Relations.getByObject('item', otherItemURI); + for (let rel of rels) { + // Skip merge-tracking relations, which are dealt with above + if (rel.predicate == replPred) continue; + // Skip items in other libraries. They might not be editable, and even + // if they are, merging items in one library shouldn't affect another library, + // so those will follow the merge-tracking relations and can optimize their + // path if they're resaved. + if (rel.subject.libraryID != item.libraryID) continue; + rel.subject.removeRelation(rel.predicate, otherItemURI); + rel.subject.addRelation(rel.predicate, itemURI); + if (!toSave[rel.subject.id]) { + toSave[rel.subject.id] = rel.subject; + } + } + // All other operations are additive only and do not affect the, // old item, which will be put in the trash @@ -444,34 +476,17 @@ Zotero.Items = function() { item.addTag(tags[j].tag); } - // Related items - var relatedItems = otherItem.relatedItems; - for each(var relatedItemID in relatedItems) { - yield item.addRelatedItem(relatedItemID); - } - - // Relations - yield Zotero.Relations.copyURIs( - item.libraryID, - Zotero.URI.getItemURI(otherItem), - Zotero.URI.getItemURI(item) - ); - // Add relation to track merge - var otherItemURI = Zotero.URI.getItemURI(otherItem); - yield Zotero.Relations.add( - item.libraryID, - otherItemURI, - Zotero.Relations.deletedItemPredicate, - itemURI - ); + item.addRelation(replPred, otherItemURI); // Trash other item otherItem.deleted = true; yield otherItem.save(); } - yield item.save(); + for (let i in toSave) { + yield toSave[i].save(); + } }.bind(this)); }; diff --git a/chrome/content/zotero/xpcom/data/relations.js b/chrome/content/zotero/xpcom/data/relations.js index 4ba998e17c..d8bb060fa2 100644 --- a/chrome/content/zotero/xpcom/data/relations.js +++ b/chrome/content/zotero/xpcom/data/relations.js @@ -23,77 +23,66 @@ ***** END LICENSE BLOCK ***** */ +"use strict"; + Zotero.Relations = new function () { Zotero.defineProperty(this, 'relatedItemPredicate', {value: 'dc:relation'}); Zotero.defineProperty(this, 'linkedObjectPredicate', {value: 'owl:sameAs'}); - Zotero.defineProperty(this, 'deletedItemPredicate', {value: 'dc:isReplacedBy'}); + Zotero.defineProperty(this, 'replacedItemPredicate', {value: 'dc:replaces'}); this._namespaces = { dc: 'http://purl.org/dc/elements/1.1/', owl: 'http://www.w3.org/2002/07/owl#' }; + var _types = ['collection', 'item']; + /** - * @return {Object[]} + * Get the data objects that are subjects with the given predicate and object + * + * @param {String} objectType - Type of relation to search for (e.g., 'item') + * @param {String} predicate + * @param {String} object + * @return {Promise} */ - this.getByURIs = Zotero.Promise.coroutine(function* (subject, predicate, object) { + this.getByPredicateAndObject = Zotero.Promise.coroutine(function* (objectType, predicate, object) { + var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); if (predicate) { predicate = this._getPrefixAndValue(predicate).join(':'); } - - if (!subject && !predicate && !object) { - throw new Error("No values provided"); - } - - var sql = "SELECT ROWID FROM relations WHERE 1"; - var params = []; - if (subject) { - sql += " AND subject=?"; - params.push(subject); - } - if (predicate) { - sql += " AND predicate=?"; - params.push(predicate); - } - if (object) { - sql += " AND object=?"; - params.push(object); - } - var rows = yield Zotero.DB.columnQueryAsync(sql, params); + var sql = "SELECT " + objectsClass.idColumn + " FROM " + objectType + "Relations " + + "JOIN relationPredicates USING (predicateID) WHERE predicate=? AND object=?"; + var ids = yield Zotero.DB.columnQueryAsync(sql, [predicate, object]); + return yield objectsClass.getAsync(ids, { noCache: true }); + }); + + + /** + * Get the data objects that are subjects with the given predicate and object + * + * @param {String} objectType - Type of relation to search for (e.g., 'item') + * @param {String} object + * @return {Promise} - Promise for an object with a Zotero.DataObject as 'subject' + * and a predicate string as 'predicate' + */ + this.getByObject = Zotero.Promise.coroutine(function* (objectType, object) { + var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); + var sql = "SELECT " + objectsClass.idColumn + " AS id, predicate " + + "FROM " + objectType + "Relations JOIN relationPredicates USING (predicateID) " + + "WHERE object=?"; var toReturn = []; - for (let i=0; i x) : false; + objectMatch = objectMatch ? objectMatch.filter(x => x) : false; + let subjectLibraryID = false; + let subjectType = false; + let subject = false; + let objectLibraryID = false; + let objectType = false; + let object = false; + if (subjectMatch) { + subjectLibraryID = (yield resolveLibrary(subjectMatch[1], subjectMatch[2])) || false; + subjectType = subjectMatch[3]; + } + if (objectMatch) { + objectLibraryID = (yield resolveLibrary(objectMatch[1], objectMatch[2])) || false; + objectType = objectMatch[3]; + } + // Use subject if it's a user library or it isn't but neither is object, and if object can be found + if (subjectLibraryID && (subjectLibraryID == 1 || objectLibraryID != 1)) { + let key = subjectMatch[4]; + if (subjectType == 'collection') { + let collectionID = yield Zotero.DB.valueQueryAsync("SELECT collectionID FROM collections WHERE libraryID=? AND key=?", [subjectLibraryID, key]); + if (collectionID) { + collectionRels.push([collectionID, row.predicate, row.object]); + continue; + } + } + else { + let itemID = yield Zotero.DB.valueQueryAsync("SELECT itemID FROM items WHERE libraryID=? AND key=?", [subjectLibraryID, key]); + if (itemID) { + itemRels.push([itemID, row.predicate, row.object]); + continue; + } + } + } + + // Otherwise use object if it can be found + if (objectLibraryID) { + let key = objectMatch[4]; + if (objectType == 'collection') { + let collectionID = yield Zotero.DB.valueQueryAsync("SELECT collectionID FROM collections WHERE libraryID=? AND key=?", [objectLibraryID, key]); + if (collectionID) { + collectionRels.push([collectionID, row.predicate, row.subject]); + continue; + } + } + else { + let itemID = yield Zotero.DB.valueQueryAsync("SELECT itemID FROM items WHERE libraryID=? AND key=?", [objectLibraryID, key]); + if (itemID) { + itemRels.push([itemID, row.predicate, row.subject]); + continue; + } + } + Zotero.logError("Neither subject nor object found: " + concat); + report += concat + "\n"; + } + break; + + case 'dc:replaces': + let match = row.subject.match(itemRE); + if (!match) { + Zotero.logError("Unrecognized subject: " + concat); + report += concat + "\n"; + continue; + } + // Remove empty captured groups + match = match.filter(x => x); + let libraryID; + // Users + if (match[1] == 'users') { + let itemID = yield Zotero.DB.valueQueryAsync("SELECT itemID FROM items WHERE libraryID=? AND key=?", [1, match[3]]); + if (!itemID) { + Zotero.logError("Subject not found: " + concat); + report += concat + "\n"; + continue; + } + itemRels.push([itemID, row.predicate, row.object]); + } + // Groups + else { + let itemID = yield Zotero.DB.valueQueryAsync("SELECT itemID FROM items JOIN groups USING (libraryID) WHERE groupID=? AND key=?", [match[2], match[3]]); + if (!itemID) { + Zotero.logError("Subject not found: " + concat); + report += concat + "\n"; + continue; + } + itemRels.push([itemID, row.predicate, row.object]); + } + break; + + default: + Zotero.logError("Unknown predicate '" + row.predicate + "': " + concat); + report += concat + "\n"; + continue; + } + } + catch (e) { + Zotero.logError(e); + } + } + + if (collectionRels.length) { + for (let i = 0; i < collectionRels.length; i++) { + collectionRels[i][1] = yield resolvePredicate(collectionRels[i][1]); + } + yield Zotero.DB.queryAsync(collectionSQL + collectionRels.map(() => "(?, ?, ?)").join(", "), collectionRels.reduce((x, y) => x.concat(y))); + } + if (itemRels.length) { + for (let i = 0; i < itemRels.length; i++) { + itemRels[i][1] = yield resolvePredicate(itemRels[i][1]); + } + yield Zotero.DB.queryAsync(itemSQL + itemRels.map(() => "(?, ?, ?)").join(", "), itemRels.reduce((x, y) => x.concat(y))); + } + + start += limit; + } + if (report.length) { + report = "Removed relations:\n\n" + report; + Zotero.debug(report); + } + yield Zotero.DB.queryAsync("DROP TABLE relations"); + + // + // Migrate related items + // + // If no user id and no local key, create a local key + if (!(yield Zotero.DB.valueQueryAsync("SELECT value FROM settings WHERE setting='account' AND key='userID'")) + && !(yield Zotero.DB.valueQueryAsync("SELECT value FROM settings WHERE setting='account' AND key='localUserKey'"))) { + yield Zotero.DB.queryAsync("INSERT INTO settings (setting, key, value) VALUES ('account', 'localUserKey', ?)", Zotero.randomString(8)); + } + var predicateID = predicateMap["dc:relation"]; + if (!predicateID) { + yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO relationPredicates VALUES (NULL, 'dc:relation')"); + predicateID = yield Zotero.DB.valueQueryAsync("SELECT predicateID FROM relationPredicates WHERE predicate=?", 'dc:relation'); + } + yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO itemRelations SELECT ISA.itemID, " + predicateID + ", 'http://zotero.org/' || (CASE WHEN G.libraryID IS NULL THEN 'users/' || IFNULL((SELECT value FROM settings WHERE setting='account' AND key='userID'), (SELECT value FROM settings WHERE setting='account' AND key='localUserKey')) ELSE 'groups/' || G.groupID END) || '/' || I.key FROM itemSeeAlso ISA JOIN items I ON (ISA.linkedItemID=I.itemID) LEFT JOIN groups G USING (libraryID)"); + yield Zotero.DB.queryAsync("DROP TABLE itemSeeAlso"); + }); } diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js index b809804184..58035e9e4f 100644 --- a/chrome/content/zotero/xpcom/zotero.js +++ b/chrome/content/zotero/xpcom/zotero.js @@ -592,8 +592,9 @@ Components.utils.import("resource://gre/modules/osfile.jsm"); yield Zotero.ItemTypes.init(); yield Zotero.ItemFields.init(); yield Zotero.CreatorTypes.init(); - yield Zotero.CharacterSets.init(); yield Zotero.FileTypes.init(); + yield Zotero.CharacterSets.init(); + yield Zotero.RelationPredicates.init(); Zotero.locked = false; diff --git a/chrome/skin/default/zotero/zotero.css b/chrome/skin/default/zotero/zotero.css index 32854a3369..867e62f610 100644 --- a/chrome/skin/default/zotero/zotero.css +++ b/chrome/skin/default/zotero/zotero.css @@ -111,9 +111,9 @@ customcolorpicker[type=button] { -moz-binding: url(chrome://zotero/content/bindings/customcolorpicker.xml#custom-colorpicker-button); } -seealsobox +relatedbox { - -moz-binding: url('chrome://zotero/content/bindings/relatedbox.xml#seealso-box'); + -moz-binding: url('chrome://zotero/content/bindings/relatedbox.xml#related-box'); -moz-user-focus: ignore; } diff --git a/resource/schema/triggers.sql b/resource/schema/triggers.sql index 74c56eddc5..01827b0d17 100644 --- a/resource/schema/triggers.sql +++ b/resource/schema/triggers.sql @@ -210,25 +210,6 @@ CREATE TRIGGER fku_itemNotes END; --- itemSeeAlso libraryID -DROP TRIGGER IF EXISTS fki_itemSeeAlso_libraryID; -CREATE TRIGGER fki_itemSeeAlso_libraryID - BEFORE INSERT ON itemSeeAlso - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'insert on table "itemSeeAlso" violates foreign key constraint "fki_itemSeeAlso_libraryID"') - WHERE (SELECT libraryID FROM items WHERE itemID = NEW.itemID) != (SELECT libraryID FROM items WHERE itemID = NEW.linkedItemID);--- - END; - -DROP TRIGGER IF EXISTS fku_itemSeeAlso_libraryID; -CREATE TRIGGER fku_itemSeeAlso_libraryID - BEFORE UPDATE ON itemSeeAlso - FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'update on table "itemSeeAlso" violates foreign key constraint "fku_itemSeeAlso_libraryID"') - WHERE (SELECT libraryID FROM items WHERE itemID = NEW.itemID) != (SELECT libraryID FROM items WHERE itemID = NEW.linkedItemID);--- - END; - - - -- itemTags libraryID DROP TRIGGER IF EXISTS fki_itemTags_libraryID; CREATE TRIGGER fki_itemTags_libraryID diff --git a/resource/schema/userdata.sql b/resource/schema/userdata.sql index 651abaf180..99cd9c03fd 100644 --- a/resource/schema/userdata.sql +++ b/resource/schema/userdata.sql @@ -126,6 +126,17 @@ CREATE TABLE tags ( UNIQUE (libraryID, name) ); +CREATE TABLE itemRelations ( + itemID INT NOT NULL, + predicateID INT NOT NULL, + object TEXT NOT NULL, + PRIMARY KEY (itemID, predicateID, object), + FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE, + FOREIGN KEY (predicateID) REFERENCES relationPredicates(predicateID) ON DELETE CASCADE +); +CREATE INDEX itemRelations_predicateID ON itemRelations(predicateID); +CREATE INDEX itemRelations_object ON itemRelations(object); + CREATE TABLE itemTags ( itemID INT NOT NULL, tagID INT NOT NULL, @@ -191,6 +202,17 @@ CREATE TABLE collectionItems ( ); CREATE INDEX collectionItems_itemID ON collectionItems(itemID); +CREATE TABLE collectionRelations ( + collectionID INT NOT NULL, + predicateID INT NOT NULL, + object TEXT NOT NULL, + PRIMARY KEY (collectionID, predicateID, object), + FOREIGN KEY (collectionID) REFERENCES collections(collectionID) ON DELETE CASCADE, + FOREIGN KEY (predicateID) REFERENCES relationPredicates(predicateID) ON DELETE CASCADE +); +CREATE INDEX collectionRelations_predicateID ON collectionRelations(predicateID); +CREATE INDEX collectionRelations_object ON collectionRelations(object); + CREATE TABLE savedSearches ( savedSearchID INTEGER PRIMARY KEY, savedSearchName TEXT NOT NULL, @@ -222,17 +244,6 @@ CREATE TABLE deletedItems ( ); CREATE INDEX deletedItems_dateDeleted ON deletedItems(dateDeleted); -CREATE TABLE relations ( - libraryID INT NOT NULL, - subject TEXT NOT NULL, - predicate TEXT NOT NULL, - object TEXT NOT NULL, - clientDateModified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (subject, predicate, object), - FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE -); -CREATE INDEX relations_object ON relations(object); - CREATE TABLE libraries ( libraryID INTEGER PRIMARY KEY, libraryType TEXT NOT NULL, @@ -368,6 +379,10 @@ CREATE TABLE proxyHosts ( ); CREATE INDEX proxyHosts_proxyID ON proxyHosts(proxyID); +CREATE TABLE relationPredicates ( + predicateID INTEGER PRIMARY KEY, + predicate TEXT UNIQUE +); -- These shouldn't be used yet CREATE TABLE customItemTypes ( diff --git a/test/content/support.js b/test/content/support.js index ae3f10b1e8..bd834df9fe 100644 --- a/test/content/support.js +++ b/test/content/support.js @@ -89,8 +89,9 @@ function waitForWindow(uri, callback) { return deferred.promise; } -var selectLibrary = Zotero.Promise.coroutine(function* (win) { - yield win.ZoteroPane.collectionsView.selectLibrary(Zotero.Libraries.userLibraryID); +var selectLibrary = Zotero.Promise.coroutine(function* (win, libraryID) { + libraryID = libraryID || Zotero.Libraries.userLibraryID; + yield win.ZoteroPane.collectionsView.selectLibrary(libraryID); yield waitForItemsLoad(win); }); diff --git a/test/tests/collectionTreeViewTest.js b/test/tests/collectionTreeViewTest.js index 613bcb4f07..8f36ca668a 100644 --- a/test/tests/collectionTreeViewTest.js +++ b/test/tests/collectionTreeViewTest.js @@ -189,14 +189,79 @@ describe("Zotero.CollectionTreeView", function() { }) describe("#drop()", function () { + /** + * Simulate a drag and drop + * + * @param {String} targetRowID - Tree row id (e.g., "L123") + * @param {Integer[]} itemIDs + * @param {Promise} [promise] - If a promise is provided, it will be waited for and its + * value returned after the drag. Otherwise, an item 'add' + * event will be waited for, and the added ids will be + * returned. + */ + var drop = Zotero.Promise.coroutine(function* (targetRowID, itemIDs, promise) { + var row = collectionsView.getRowIndexByID(targetRowID); + + var stub = sinon.stub(Zotero.DragDrop, "getDragTarget"); + stub.returns(collectionsView.getRow(row)); + if (!promise) { + promise = waitForItemEvent("add"); + } + yield collectionsView.drop(row, 0, { + dropEffect: 'copy', + effectAllowed: 'copy', + mozSourceNode: win.document.getElementById('zotero-items-tree'), + types: { + contains: function (type) { + return type == 'zotero/item'; + } + }, + getData: function (type) { + if (type == 'zotero/item') { + return itemIDs.join(","); + } + } + }); + + // Add observer to wait for add + var result = yield promise; + stub.restore(); + return result; + }); + + + var canDrop = Zotero.Promise.coroutine(function* (targetRowID, itemIDs) { + var row = collectionsView.getRowIndexByID(targetRowID); + + var stub = sinon.stub(Zotero.DragDrop, "getDragTarget"); + stub.returns(collectionsView.getRow(row)); + var dt = { + dropEffect: 'copy', + effectAllowed: 'copy', + mozSourceNode: win.document.getElementById('zotero-items-tree'), + types: { + contains: function (type) { + return type == 'zotero/item'; + } + }, + getData: function (type) { + if (type == 'zotero/item') { + return itemIDs.join(","); + } + } + }; + var canDrop = collectionsView.canDropCheck(row, 0, dt); + if (canDrop) { + canDrop = yield collectionsView.canDropCheckAsync(row, 0, dt); + } + stub.restore(); + return canDrop; + }); + + it("should add an item to a collection", function* () { - var collection = yield createDataObject('collection', false, { - skipSelect: true - }); - var item = yield createDataObject('item', false, { - skipSelect: true - }); - var row = collectionsView.getRowIndexByID("C" + collection.id); + var collection = yield createDataObject('collection', false, { skipSelect: true }); + var item = yield createDataObject('item', false, { skipSelect: true }); // Add observer to wait for collection add var deferred = Zotero.Promise.defer(); @@ -211,27 +276,8 @@ describe("Zotero.CollectionTreeView", function() { } }, 'collection-item', 'test'); - // Simulate a drag and drop - var stub = sinon.stub(Zotero.DragDrop, "getDragTarget"); - stub.returns(collectionsView.getRow(row)); - collectionsView.drop(row, 0, { - dropEffect: 'copy', - effectAllowed: 'copy', - mozSourceNode: win.document.getElementById('zotero-items-tree'), - types: { - contains: function (type) { - return type == 'zotero/item'; - } - }, - getData: function (type) { - if (type == 'zotero/item') { - return "" + item.id; - } - } - }) + var ids = yield drop("C" + collection.id, [item.id], deferred.promise); - yield deferred.promise; - stub.restore(); Zotero.Notifier.unregisterObserver(observerID); yield collectionsView.selectCollection(collection.id); yield waitForItemsLoad(win); @@ -242,60 +288,87 @@ describe("Zotero.CollectionTreeView", function() { assert.equal(treeRow.ref.id, item.id); }) - it("should add an item to a library", function* () { - var group = new Zotero.Group; - group.id = 75161251; - group.name = "Test"; - group.description = ""; - group.editable = true; - group.filesEditable = true; - group.version = 1234; - yield group.save(); + it("should copy an item with an attachment to a group", function* () { + var group = yield getGroup(); - var item = yield createDataObject('item', false, { - skipSelect: true - }); + var item = yield createDataObject('item', false, { skipSelect: true }); var file = getTestDataDirectory(); file.append('test.png'); - yield Zotero.Attachments.importFromFile({ + var attachment = yield Zotero.Attachments.importFromFile({ file: file, parentItemID: item.id }); - var row = collectionsView.getRowIndexByID("L" + group.libraryID); + // Hack to unload relations to test proper loading + // + // Probably need a better method for this + item._loaded.relations = false; + attachment._loaded.relations = false; - // Simulate a drag and drop - var stub = sinon.stub(Zotero.DragDrop, "getDragTarget"); - stub.returns(collectionsView.getRow(row)); - collectionsView.drop(row, 0, { - dropEffect: 'copy', - effectAllowed: 'copy', - mozSourceNode: win.document.getElementById('zotero-items-tree'), - types: { - contains: function (type) { - return type == 'zotero/item'; - } - }, - getData: function (type) { - if (type == 'zotero/item') { - return "" + item.id; - } - } - }); + var ids = yield drop("L" + group.libraryID, [item.id]); - // Add observer to wait for collection add - var ids = yield waitForItemEvent("add"); - - stub.restore(); yield collectionsView.selectLibrary(group.libraryID); yield waitForItemsLoad(win); var itemsView = win.ZoteroPane.itemsView assert.equal(itemsView.rowCount, 1); var treeRow = itemsView.getRow(0); + assert.equal(treeRow.ref.libraryID, group.libraryID); assert.equal(treeRow.ref.id, ids[0]); - yield group.erase(); + // New item should link back to original + var linked = yield item.getLinkedItem(group.libraryID); + assert.equal(linked.id, treeRow.ref.id); + }) + + it("should not copy an item or its attachment to a group twice", function* () { + var group = yield getGroup(); + + var itemTitle = Zotero.Utilities.randomString(); + var item = yield createDataObject('item', false, { skipSelect: true }); + var file = getTestDataDirectory(); + file.append('test.png'); + var attachment = yield Zotero.Attachments.importFromFile({ + file: file, + parentItemID: item.id + }); + var attachmentTitle = Zotero.Utilities.randomString(); + attachment.setField('title', attachmentTitle); + yield attachment.save(); + + var ids = yield drop("L" + group.libraryID, [item.id]); + assert.isFalse(yield canDrop("L" + group.libraryID, [item.id])); + }) + + it("should remove a linked, trashed item in a group from the trash and collections", function* () { + var group = yield getGroup(); + var collection = yield createDataObject('collection', { libraryID: group.libraryID }); + + var item = yield createDataObject('item', false, { skipSelect: true }); + var ids = yield drop("L" + group.libraryID, [item.id]); + + var droppedItem = yield item.getLinkedItem(group.libraryID); + droppedItem.setCollections([collection.id]); + droppedItem.deleted = true; + yield droppedItem.save(); + + // Add observer to wait for collection add + var deferred = Zotero.Promise.defer(); + var observerID = Zotero.Notifier.registerObserver({ + notify: function (event, type, ids) { + if (event == 'refresh' && type == 'trash' && ids[0] == group.libraryID) { + setTimeout(function () { + deferred.resolve(); + }); + } + } + }, 'trash', 'test'); + var ids = yield drop("L" + group.libraryID, [item.id], deferred.promise); + Zotero.Notifier.unregisterObserver(observerID); + + assert.isFalse(droppedItem.deleted); + // Should be removed from collections when removed from trash + assert.lengthOf(droppedItem.getCollections(), 0); }) }) }) diff --git a/test/tests/dataObjectTest.js b/test/tests/dataObjectTest.js index 30aadae62f..e970c301a5 100644 --- a/test/tests/dataObjectTest.js +++ b/test/tests/dataObjectTest.js @@ -171,4 +171,111 @@ describe("Zotero.DataObject", function() { } }) }) + + describe("Relations", function () { + var types = ['collection', 'item']; + + function makeObjectURI(objectType) { + var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); + return 'http://zotero.org/groups/1/' + objectTypePlural + '/' + + Zotero.Utilities.generateObjectKey(); + } + + describe("#addRelation()", function () { + it("should add a relation to an object", function* () { + for (let type of types) { + let predicate = 'owl:sameAs'; + let object = makeObjectURI(type); + let obj = createUnsavedDataObject(type); + obj.addRelation(predicate, object); + yield obj.saveTx(); + var relations = obj.getRelations(); + assert.property(relations, predicate); + assert.include(relations[predicate], object); + } + }) + }) + + describe("#removeRelation()", function () { + it("should remove a relation from an object", function* () { + for (let type of types) { + let predicate = 'owl:sameAs'; + let object = makeObjectURI(type); + let obj = createUnsavedDataObject(type); + obj.addRelation(predicate, object); + yield obj.saveTx(); + + obj.removeRelation(predicate, object); + yield obj.saveTx(); + + assert.lengthOf(Object.keys(obj.getRelations()), 0); + } + }) + }) + + describe("#hasRelation()", function () { + it("should return true if an object has a given relation", function* () { + for (let type of types) { + let predicate = 'owl:sameAs'; + let object = makeObjectURI(type); + let obj = createUnsavedDataObject(type); + obj.addRelation(predicate, object); + yield obj.saveTx(); + assert.ok(obj.hasRelation(predicate, object)); + } + }) + }) + + describe("#_getLinkedObject()", function () { + it("should return a linked object in another library", function* () { + var group = yield getGroup(); + var item1 = yield createDataObject('item'); + var item2 = yield createDataObject('item', { libraryID: group.libraryID }); + var item2URI = Zotero.URI.getItemURI(item2); + + yield item2.addLinkedItem(item1); + var linkedItem = yield item1.getLinkedItem(item2.libraryID); + assert.equal(linkedItem.id, item2.id); + }) + + it("shouldn't return reverse linked objects by default", function* () { + var group = yield getGroup(); + var item1 = yield createDataObject('item'); + var item1URI = Zotero.URI.getItemURI(item1); + var item2 = yield createDataObject('item', { libraryID: group.libraryID }); + + yield item2.addLinkedItem(item1); + var linkedItem = yield item2.getLinkedItem(item1.libraryID); + assert.isFalse(linkedItem); + }) + + it("should return reverse linked objects with bidirectional flag", function* () { + var group = yield getGroup(); + var item1 = yield createDataObject('item'); + var item1URI = Zotero.URI.getItemURI(item1); + var item2 = yield createDataObject('item', { libraryID: group.libraryID }); + + yield item2.addLinkedItem(item1); + var linkedItem = yield item2.getLinkedItem(item1.libraryID, true); + assert.equal(linkedItem.id, item1.id); + }) + }) + + describe("#_addLinkedObject()", function () { + it("should add an owl:sameAs relation", function* () { + var group = yield getGroup(); + var item1 = yield createDataObject('item'); + var dateModified = item1.getField('dateModified'); + var item2 = yield createDataObject('item', { libraryID: group.libraryID }); + var item2URI = Zotero.URI.getItemURI(item2); + + yield item2.addLinkedItem(item1); + var preds = item1.getRelationsByPredicate(Zotero.Relations.linkedObjectPredicate); + assert.include(preds, item2URI); + + // Make sure Date Modified hasn't changed + assert.equal(item1.getField('dateModified'), dateModified); + }) + }) + }) }) diff --git a/test/tests/itemTest.js b/test/tests/itemTest.js index e769c08f14..aa4befda06 100644 --- a/test/tests/itemTest.js +++ b/test/tests/itemTest.js @@ -588,6 +588,37 @@ describe("Zotero.Item", function () { }) }) + // + // Relations and related items + // + describe("#addRelatedItem", function () { + it("#should add a dc:relation relation to an item", function* () { + var item1 = yield createDataObject('item'); + var item2 = yield createDataObject('item'); + item1.addRelatedItem(item2); + yield item1.save(); + + var rels = item1.getRelationsByPredicate(Zotero.Relations.relatedItemPredicate); + assert.lengthOf(rels, 1); + assert.equal(rels[0], Zotero.URI.getItemURI(item2)); + }) + + it("#should throw an error for a relation in a different library", function* () { + var group = yield getGroup(); + var item1 = yield createDataObject('item'); + var item2 = yield createDataObject('item', { libraryID: group.libraryID }); + try { + item1.addRelatedItem(item2) + } + catch (e) { + assert.ok(e); + assert.equal(e.message, "Cannot relate item to an item in a different library"); + return; + } + assert.fail("addRelatedItem() allowed for an item in a different library"); + }) + }) + describe("#clone()", function () { // TODO: Expand to other data it("should copy creators", function* () { diff --git a/test/tests/itemsTest.js b/test/tests/itemsTest.js index de56affccc..2bec5aa707 100644 --- a/test/tests/itemsTest.js +++ b/test/tests/itemsTest.js @@ -13,6 +13,78 @@ describe("Zotero.Items", function () { win.close(); }) + + describe("#merge()", function () { + it("should merge two items", function* () { + var item1 = yield createDataObject('item'); + var item2 = yield createDataObject('item'); + var item2URI = Zotero.URI.getItemURI(item2); + + yield Zotero.Items.merge(item1, [item2]); + + assert.isFalse(item1.deleted); + assert.isTrue(item2.deleted); + + // Check for merge-tracking relation + var rels = item1.getRelationsByPredicate(Zotero.Relations.replacedItemPredicate); + assert.lengthOf(rels, 1); + assert.equal(rels[0], item2URI); + }) + + it("should move merge-tracking relation from replaced item to master", function* () { + var item1 = yield createDataObject('item'); + var item2 = yield createDataObject('item'); + var item2URI = Zotero.URI.getItemURI(item2); + var item3 = yield createDataObject('item'); + var item3URI = Zotero.URI.getItemURI(item3); + + yield Zotero.Items.merge(item2, [item3]); + yield Zotero.Items.merge(item1, [item2]); + + // Check for merge-tracking relation from 1 to 3 + var rels = item1.getRelationsByPredicate(Zotero.Relations.replacedItemPredicate); + assert.lengthOf(rels, 2); + assert.sameMembers(rels, [item2URI, item3URI]); + }) + + it("should update relations pointing to replaced item to point to master", function* () { + var item1 = yield createDataObject('item'); + var item1URI = Zotero.URI.getItemURI(item1); + var item2 = yield createDataObject('item'); + var item2URI = Zotero.URI.getItemURI(item2); + var item3 = createUnsavedDataObject('item'); + var predicate = Zotero.Relations.relatedItemPredicate; + item3.addRelation(predicate, item2URI); + yield item3.saveTx(); + + yield Zotero.Items.merge(item1, [item2]); + + // Check for related-item relation from 3 to 1 + var rels = item3.getRelationsByPredicate(predicate); + assert.deepEqual(rels, [item1URI]); + }) + + it("should not update relations pointing to replaced item in other libraries", function* () { + var group1 = yield createGroup(); + var group2 = yield createGroup(); + + var item1 = yield createDataObject('item', { libraryID: group1.libraryID }); + var item1URI = Zotero.URI.getItemURI(item1); + var item2 = yield createDataObject('item', { libraryID: group1.libraryID }); + var item2URI = Zotero.URI.getItemURI(item2); + var item3 = createUnsavedDataObject('item', { libraryID: group2.libraryID }); + var predicate = Zotero.Relations.linkedObjectPredicate; + item3.addRelation(predicate, item2URI); + yield item3.saveTx(); + + yield Zotero.Items.merge(item1, [item2]); + + // Check for related-item relation from 3 to 2 + var rels = item3.getRelationsByPredicate(predicate); + assert.deepEqual(rels, [item2URI]); + }) + }) + describe("#emptyTrash()", function () { it("should delete items in the trash", function* () { var item1 = createUnsavedDataObject('item'); diff --git a/test/tests/relatedboxTest.js b/test/tests/relatedboxTest.js new file mode 100644 index 0000000000..587e6bedf8 --- /dev/null +++ b/test/tests/relatedboxTest.js @@ -0,0 +1,103 @@ +"use strict"; + +describe("Related Box", function () { + var win, doc, itemsView; + + before(function* () { + win = yield loadZoteroPane(); + doc = win.document; + itemsView = win.ZoteroPane.itemsView; + }); + after(function () { + win.close(); + }) + + describe("Add button", function () { + it("should add a related item", function* () { + var item1 = yield createDataObject('item'); + var item2 = yield createDataObject('item'); + + // Select the Related pane + var tabbox = doc.getElementById('zotero-view-tabbox'); + tabbox.selectedIndex = 3; + var relatedbox = doc.getElementById('zotero-editpane-related'); + assert.lengthOf(relatedbox.id('relatedRows').childNodes, 0); + + // Click the Add button to open the Select Items dialog + setTimeout(function () { + relatedbox.id('addButton').click(); + }); + var selectWin = yield waitForWindow('chrome://zotero/content/selectItemsDialog.xul'); + // wrappedJSObject isn't working on zotero-collections-tree for some reason, so + // just wait for the items tree to be created and select it directly + do { + var view = selectWin.document.getElementById('zotero-items-tree').view.wrappedJSObject; + yield Zotero.Promise.delay(50); + } + while (!view); + var deferred = Zotero.Promise.defer(); + view.addEventListener('load', () => deferred.resolve()); + yield deferred.promise; + + // Select the other item + for (let i = 0; i < view.rowCount; i++) { + if (view.getRow(i).ref.id == item1.id) { + view.selection.select(i); + } + } + selectWin.document.documentElement.acceptDialog(); + + // Wait for relations list to populate + do { + yield Zotero.Promise.delay(50); + } + while (!relatedbox.id('relatedRows').childNodes.length); + + assert.lengthOf(relatedbox.id('relatedRows').childNodes, 1); + + var items = item1.relatedItems; + assert.lengthOf(items, 1); + assert.equal(items[0], item2.key); + + // Relation should be assigned bidirectionally + var items = item2.relatedItems; + assert.lengthOf(items, 1); + assert.equal(items[0], item1.key); + }) + }) + + describe("Remove button", function () { + it("should remove a related item", function* () { + var item1 = yield createDataObject('item'); + var item2 = yield createDataObject('item'); + + yield item1.loadRelations(); + item1.addRelatedItem(item2); + yield item1.save(); + yield item2.loadRelations(); + item2.addRelatedItem(item1); + yield item2.save(); + + // Select the Related pane + var tabbox = doc.getElementById('zotero-view-tabbox'); + tabbox.selectedIndex = 3; + var relatedbox = doc.getElementById('zotero-editpane-related'); + + // Wait for relations list to populate + do { + yield Zotero.Promise.delay(50); + } + while (!relatedbox.id('relatedRows').childNodes.length); + + doc.getAnonymousNodes(relatedbox)[0] + .getElementsByAttribute('value', '-')[0] + .click(); + + // Wait for relations list to clear + do { + yield Zotero.Promise.delay(50); + } + while (relatedbox.id('relatedRows').childNodes.length); + }) + }) +}) diff --git a/test/tests/relationsTest.js b/test/tests/relationsTest.js new file mode 100644 index 0000000000..21720f9a5b --- /dev/null +++ b/test/tests/relationsTest.js @@ -0,0 +1,24 @@ +"use strict"; + +describe("Zotero.Relations", function () { + describe("#getByPredicateAndObject()", function () { + it("should return items matching predicate and object", function* () { + var item = createUnsavedDataObject('item'); + item.setRelations({ + "dc:relation": [ + "http://zotero.org/users/1/items/SHREREMS" + ], + "owl:sameAs": [ + "http://zotero.org/groups/1/items/SRRMGSRM", + "http://zotero.org/groups/1/items/GSMRRSSM" + ] + }) + yield item.saveTx(); + var objects = yield Zotero.Relations.getByPredicateAndObject( + 'item', 'owl:sameAs', 'http://zotero.org/groups/1/items/SRRMGSRM' + ); + assert.lengthOf(objects, 1); + assert.equal(objects[0], item); + }) + }) +})