Relations overhaul (requires new DB upgrade from 4.0)

Relations are now properties of collections and items rather than
first-class objects, stored in separate collectionRelations and
itemRelations tables with ids for subjects, with foreign keys to the
associated data objects.

Related items now use dc:relation relations rather than a separate table
(among other reasons, because API syncing won't necessarily sync both
items at the same time, so they can't be stored by id).

The UI assigns related-item relations bidirectionally, and checks for
related-item and linked-object relations are done unidirectionally by
default.

dc:isReplacedBy is now dc:replaces, so that the subject is an existing
object, and the predicate is now named
Zotero.Attachments.replacedItemPredicate.

Some additional work is still needed, notably around following
replaced-item relations, and migration needs to be tested more fully,
but this seems to mostly work.
This commit is contained in:
Dan Stillman 2015-06-01 20:09:39 -04:00
parent 75bcfcb685
commit a740658452
24 changed files with 1414 additions and 812 deletions

View file

@ -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();
]]>
</setter>
</property>
<property name="mode">
<setter>
<![CDATA[
this.id('seeAlso').mode = val;
this.id('related').mode = val;
this.id('tags').mode = val;
]]>
</setter>
@ -447,7 +447,7 @@
}, this);
]]></body>
</method>
<method name="seeAlsoClick">
<method name="relatedClick">
<body><![CDATA[
Zotero.spawn(function* () {
yield this.item.loadRelations();
@ -455,26 +455,26 @@
if (relatedList.length > 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);
]]></body>
</method>
<method name="updateSeeAlsoSummary">
<method name="updateRelatedSummary">
<body><![CDATA[
Zotero.spawn(function* () {
var v = yield this.id('seeAlso').summary;
var v = yield this.id('related').summary;
if (!v || v == "") {
v = "[" + Zotero.getString('pane.item.noteEditor.clickHere') + "]";
}
this.id('seeAlsoLabel').value = Zotero.getString('itemFields.related')
this.id('relatedLabel').value = Zotero.getString('itemFields.related')
+ Zotero.getString('punctuation.colon');
this.id('seeAlsoClick').value = v;
this.id('relatedClick').value = v;
}, this)
]]></body>
</method>
@ -535,8 +535,8 @@
<xul:label id="parentText" class="zotero-clicky" crop="end" onclick="document.getBindingParent(this).parentClick();"/>
</xul:row>
<xul:row>
<xul:label id="seeAlsoLabel"/>
<xul:label id="seeAlsoClick" class="zotero-clicky" crop="end" onclick="document.getBindingParent(this).seeAlsoClick();"/>
<xul:label id="relatedLabel"/>
<xul:label id="relatedClick" class="zotero-clicky" crop="end" onclick="document.getBindingParent(this).relatedClick();"/>
</xul:row>
<xul:row>
<xul:label id="tagsLabel"/>
@ -545,8 +545,8 @@
</xul:rows>
</xul:grid>
<xul:popupset>
<xul:menupopup id="seeAlsoPopup" width="300" onpopupshowing="this.firstChild.reload();">
<xul:seealsobox id="seeAlso" flex="1"/>
<xul:menupopup id="relatedPopup" width="300" onpopupshowing="this.firstChild.reload();">
<xul:relatedbox id="related" flex="1"/>
</xul:menupopup>
<!-- The onpopup* stuff is an ugly hack to keep track of when the
popup is open (and not the descendent autocomplete popup, which also

View file

@ -29,7 +29,7 @@
<bindings xmlns="http://www.mozilla.org/xbl"
xmlns:xbl="http://www.mozilla.org/xbl"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
<binding id="seealso-box">
<binding id="related-box">
<implementation>
<!-- Modes are predefined settings groups for particular tasks -->
<field name="_mode">"view"</field>
@ -67,7 +67,7 @@
<setter>
<![CDATA[
this.itemRef = val;
this.reload();
this.refresh();
]]>
</setter>
</property>
@ -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 @@
]]>
</getter>
</property>
<method name="reload">
<constructor>
<![CDATA[
this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'relatedbox');
]]>
</constructor>
<destructor>
<![CDATA[
Zotero.Notifier.unregisterObserver(this._notifierID);
]]>
</destructor>
<method name="notify">
<parameter name="event"/>
<parameter name="type"/>
<parameter name="ids"/>
<parameter name="extraData"/>
<body><![CDATA[
if (event != 'modify' || !this.item || !this.item.id) return;
for (let i = 0; i < ids.length; i++) {
let id = ids[i];
if (id != this.item.id) {
continue;
}
this.refresh();
break;
}
]]></body>
</method>
<method name="refresh">
<body>
<![CDATA[
return Zotero.spawn(function* () {
var addButton = this.id('addButton');
addButton.hidden = !this.editable;
var rows = this.id('seeAlsoRows');
var rows = this.id('relatedRows');
while(rows.hasChildNodes())
rows.removeChild(rows.firstChild);
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 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);
]]></body>
</method>
@ -213,17 +258,23 @@
<parameter name="id"/>
<body><![CDATA[
return Zotero.spawn(function* () {
if(id) {
// TODO: set attribute on reload to determine
// which of these is necessary
this.item.removeRelatedItem(id);
yield this.item.save();
var item = yield Zotero.Items.getAsync(id);
item.removeRelatedItem(this.item.id);
yield item.save();
var item = yield Zotero.Items.getAsync(id);
if (item) {
yield Zotero.DB.executeTransaction(function* () {
if (this.item.removeRelatedItem(item)) {
yield this.item.save({
skipDateModifiedUpdate: true
});
}
yield item.loadRelations();
if (item.removeRelatedItem(this.item)) {
yield item.save({
skipDateModifiedUpdate: true
});
}
}.bind(this));
}
});
}, this);
]]></body>
</method>
<method name="showItem">
@ -282,7 +333,7 @@
str += 'plural';
break;
}
this.id('seeAlsoNum').value = Zotero.getString(str, [count]);
this.id('relatedNum').value = Zotero.getString(str, [count]);
]]>
</body>
</method>
@ -298,7 +349,7 @@
<content>
<xul:vbox xbl:inherits="flex" class="zotero-box">
<xul:hbox align="center">
<xul:label id="seeAlsoNum"/>
<xul:label id="relatedNum"/>
<xul:button id="addButton" label="&zotero.item.add;"
oncommand="this.parentNode.parentNode.parentNode.add();"/>
</xul:hbox>
@ -307,7 +358,7 @@
<xul:column flex="1"/>
<xul:column/>
</xul:columns>
<xul:rows id="seeAlsoRows"/>
<xul:rows id="relatedRows"/>
</xul:grid>
</xul:vbox>
</content>

View file

@ -82,7 +82,7 @@
</tabpanel>
<tabpanel>
<seealsobox id="zotero-editpane-related" flex="1"/>
<relatedbox id="zotero-editpane-related" flex="1"/>
</tabpanel>
</tabpanels>
</tabbox>

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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
//

View file

@ -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<this._relations.length; i++) {
let relation = this._relations[i];
let rel = this._relations[i];
// Relations are stored internally as predicate-object pairs
let predicate = relation[0];
if (relations[predicate]) {
// If object with predicate exists, convert to an array
if (typeof relations[predicate] == 'string') {
relations[predicate] = [relations[predicate]];
}
// Add new object to array
relations[predicate].push(relation[1]);
}
// Add first object as a string
else {
relations[predicate] = relation[1];
let p = rel[0];
if (!relations[p]) {
relations[p] = [];
}
relations[p].push(rel[1]);
}
return relations;
}
/**
* Returns all relations of the object with a given predicate
*
* @return {String[]} - URIs linked to this object with the given predicate
*/
Zotero.DataObject.prototype.getRelationsByPredicate = function (predicate) {
this._requireData('relations');
if (!predicate) {
throw new Error("Predicate not provided");
}
var relations = [];
for (let i=0; i<this._relations.length; i++) {
let rel = this._relations[i];
// Relations are stored internally as predicate-object pairs
let p = rel[0];
if (p !== predicate) {
continue;
}
relations.push(rel[1]);
}
return relations;
}
/**
* @return {Boolean} - True if the relation has been queued, false if it already exists
*/
Zotero.DataObject.prototype.addRelation = function (predicate, object) {
this._requireData('relations');
if (!predicate) {
throw new Error("Predicate not provided");
}
if (!object) {
throw new Error("Object not provided");
}
for (let i = 0; i < this._relations.length; i++) {
let rel = this._relations[i];
if (rel[0] == predicate && rel[1] == object) {
Zotero.debug("Relation " + predicate + " - " + object + " already exists for "
+ this._objectType + " " + this.libraryKey);
return false;
}
}
this._markFieldChange('relations', this._relations);
this._changed.relations = true;
this._relations.push([predicate, object]);
return true;
}
Zotero.DataObject.prototype.hasRelation = function (predicate, object) {
this._requireData('relations');
for (let i = 0; i < this._relations.length; i++) {
let rel = this._relations[i];
if (rel[0] == predicate && rel[1] == object) {
return true
}
}
return false;
}
Zotero.DataObject.prototype.removeRelation = function (predicate, object) {
this._requireData('relations');
for (let i = 0; i < this._relations.length; i++) {
let rel = this._relations[i];
if (rel[0] == predicate && rel[1] == object) {
Zotero.debug("Removing relation " + predicate + " - " + object + " from "
+ this._objectType + " " + this.libraryKey);
this._markFieldChange('relations', this._relations);
this._changed.relations = true;
this._relations.splice(i, 1);
return true;
}
}
Zotero.debug("Relation " + predicate + " - " + object + " did not exist for "
+ this._objectType + " " + this.libraryKey);
return false;
}
/**
* Updates the object's relations
*
@ -308,37 +391,30 @@ Zotero.DataObject.prototype.getRelations = function () {
Zotero.DataObject.prototype.setRelations = function (newRelations) {
this._requireData('relations');
// There can be more than one object for a given predicate, so build
// flat arrays with individual predicate-object pairs so we can use
// array_diff to determine what changed
var oldRelations = this._relations;
var sortFunc = function (a, b) {
if (a[0] < b[0]) return -1;
if (a[0] > 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<object.length; i++) {
newRelationsFlat.push([predicate, object[i]]);
}
}
// Relations are stored internally as a flat array with individual predicate-object pairs,
// so convert the incoming relations to that
var newRelationsFlat = this._flattenRelations(newRelations);
var changed = false;
if (oldRelations.length != newRelationsFlat.length) {
changed = true;
}
else {
let sortFunc = function (a, b) {
if (a[0] < b[0]) return -1;
if (a[0] > 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<oldRelations.length; i++) {
if (oldRelations[i] != newRelationsFlat[i]) {
if (oldRelations[i][0] != newRelationsFlat[i][0]
|| oldRelations[i][1] != newRelationsFlat[i][1]) {
changed = true;
break;
}
@ -352,7 +428,6 @@ Zotero.DataObject.prototype.setRelations = function (newRelations) {
this._markFieldChange('relations', this._relations);
this._changed.relations = true;
// Store relations internally as array of predicate-object pairs
this._relations = newRelationsFlat;
return true;
}
@ -360,50 +435,114 @@ Zotero.DataObject.prototype.setRelations = function (newRelations) {
/**
* Return an object in the specified library equivalent to this object
*
* Use Zotero.Collection.getLinkedCollection() and Zotero.Item.getLinkedItem() instead of
* calling this directly.
*
* @param {Integer} [libraryID]
* @return {Object|false} Linked item, or false if not found
* @return {Promise<Zotero.DataObject>|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<links.length; i++) {
let link = links[i];
if (link.startsWith(libraryObjectPrefix)) {
var obj = yield Zotero.URI['getURI' + this._ObjectType](link);
// Try the relations with this as a subject
var uris = this.getRelationsByPredicate(predicate);
for (let i = 0; i < uris.length; i++) {
let uri = uris[i];
if (uri.startsWith(libraryObjectPrefix)) {
let obj = yield Zotero.URI['getURI' + this._ObjectType](uri);
if (!obj) {
Zotero.debug("Referenced linked " + this._objectType + " '" + link + "' not found "
+ "in Zotero." + this._ObjectType + ".getLinked" + this._ObjectType + "()", 2);
Zotero.debug("Referenced linked " + this._objectType + " '" + uri + "' not found "
+ "in Zotero." + this._ObjectType + "::getLinked" + this._ObjectType + "()", 2);
continue;
}
return obj;
}
}
// Then try relations with this as an object
if (bidirectional) {
var thisURI = Zotero.URI['get' + this._ObjectType + 'URI'](this);
var objects = yield Zotero.Relations.getByPredicateAndObject(
this._objectType, predicate, thisURI
);
for (let i = 0; i < objects.length; i++) {
let obj = objects[i];
if (obj.objectType != this._objectType) {
Zotero.logError("Found linked object of different type "
+ "(expected " + this._objectType + ", found " + obj.objectType + ")");
continue;
}
if (obj.libraryID == libraryID) {
return obj;
}
}
}
return false;
});
/**
* Add a linked-item relation to a pair of objects
*
* A separate save() is not required.
*
* @param {Zotero.DataObject} object
* @param {Promise<Boolean>}
*/
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;
}

View file

@ -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=?";

View file

@ -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<toAdd.length; i++) {
let tag = toAdd[i];
@ -1595,72 +1563,6 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) {
}
}
// Related items
if (this._changed.relatedItems) {
var removed = [];
var newids = [];
var currentIDs = this._getRelatedItems(true);
for each(var id in currentIDs) {
newids.push(id);
}
if (newids.length) {
var sql = "REPLACE INTO itemSeeAlso (itemID, linkedItemID) VALUES (?,?)";
var replaceStatement = Zotero.DB.getAsyncStatement(sql);
for each(var linkedItemID in newids) {
replaceStatement.bindInt32Parameter(0, itemID);
replaceStatement.bindInt32Parameter(1, linkedItemID);
yield Zotero.DB.executeAsyncStatement(replaceStatement);
}
}
Zotero.Notifier.trigger('modify', 'item', removed.concat(newids));
}
// Related items
if (this._changed.relatedItems) {
var removed = [];
var newids = [];
var currentIDs = this._getRelatedItems(true);
for each(var id in this._previousData.related) {
if (currentIDs.indexOf(id) == -1) {
removed.push(id);
}
}
for each(var id in currentIDs) {
if (this._previousData.related.indexOf(id) != -1) {
continue;
}
newids.push(id);
}
if (removed.length) {
var sql = "DELETE FROM itemSeeAlso WHERE itemID=? "
+ "AND linkedItemID IN ("
+ removed.map(function () '?').join()
+ ")";
yield Zotero.DB.queryAsync(sql, [this.id].concat(removed));
}
if (newids.length) {
var sql = "INSERT INTO itemSeeAlso (itemID, linkedItemID) VALUES (?,?)";
var insertStatement = Zotero.DB.getAsyncStatement(sql);
for each(var linkedItemID in newids) {
insertStatement.bindInt32Parameter(0, this.id);
insertStatement.bindInt32Parameter(1, linkedItemID);
yield Zotero.DB.executeAsyncStatement(insertStatement);
}
}
Zotero.Notifier.queue('modify', 'item', removed.concat(newids));
}
// Update child item counts and contents
if (reloadParentChildItems) {
for (let parentItemID in reloadParentChildItems) {
@ -2411,8 +2313,8 @@ Zotero.Item.prototype._updateAttachmentStates = function (exists) {
// standalone attachment was modified locally and remotely was changed
// into a child attachment
catch (e) {
Zotero.debug("Attachment parent doesn't exist for source key "
+ "in Zotero.Item.updateAttachmentStates()", 1);
Zotero.logError("Attachment parent doesn't exist for source key "
+ "in Zotero.Item.updateAttachmentStates()");
return;
}
@ -2421,11 +2323,15 @@ Zotero.Item.prototype._updateAttachmentStates = function (exists) {
}
catch (e) {
if (e instanceof Zotero.Exception.UnloadedDataException) {
Zotero.debug("Attachment parent not yet loaded in Zotero.Item.updateAttachmentStates()", 2);
Zotero.logError("Attachment parent not yet loaded in Zotero.Item.updateAttachmentStates()");
return;
}
throw e;
}
if (!item) {
Zotero.logError("Attachment parent doesn't exist");
return;
}
item.clearBestAttachmentState();
};
@ -3463,46 +3369,6 @@ Zotero.Item.prototype.inCollection = function (collectionID) {
};
//
// Methods dealing with relations
//
/**
* Return an item in the specified library equivalent to this item
*
* @return {Promise}
*/
Zotero.Item.prototype.getLinkedItem = function (libraryID) {
return this._getLinkedObject(libraryID);
}
Zotero.Item.prototype.addLinkedItem = Zotero.Promise.coroutine(function* (item) {
var url1 = Zotero.URI.getItemURI(this);
var url2 = Zotero.URI.getItemURI(item);
var predicate = Zotero.Relations.linkedObjectPredicate;
if ((yield Zotero.Relations.getByURIs(url1, predicate, url2)).length
|| (yield Zotero.Relations.getByURIs(url2, predicate, url1)).length) {
Zotero.debug("Items " + this.key + " and " + item.key + " 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).
var userLibraryID = Zotero.Libraries.userLibraryID;
var libraryID = (this.libraryID == userLibraryID || item.libraryID == userLibraryID)
? userLibraryID
: this.libraryID;
yield Zotero.Relations.add(libraryID, url1, predicate, url2);
});
Zotero.Item.prototype.getImageSrc = function() {
var itemType = Zotero.ItemTypes.getName(this.itemTypeID);
if (itemType == 'attachment') {
@ -3902,10 +3768,6 @@ Zotero.Item.prototype._eraseData = Zotero.Promise.coroutine(function* (env) {
// Flag related items for notification
// TEMP: Do something with relations
/*var relateds = this._getRelatedItems(true);
for each(var id in relateds) {
let relatedItem = Zotero.Items.get(id);
}*/
// Clear fulltext cache
if (this.isAttachment()) {
@ -3913,10 +3775,6 @@ Zotero.Item.prototype._eraseData = Zotero.Promise.coroutine(function* (env) {
//Zotero.Fulltext.clearItemContent(this.id);
}
// Remove relations (except for merge tracker)
var uri = Zotero.URI.getItemURI(this);
yield Zotero.Relations.eraseByURI(uri, [Zotero.Relations.deletedItemPredicate]);
env.parentItem = parentItem;
});
@ -4149,29 +4007,7 @@ Zotero.Item.prototype.toJSON = Zotero.Promise.coroutine(function* (options) {
// Relations
yield this.loadRelations();
obj.relations = {};
var rels = yield Zotero.Relations.getByURIs(Zotero.URI.getItemURI(this));
for each (let rel in rels) {
obj.relations[rel.predicate] = rel.object;
}
var relatedItems = this._getRelatedItems().map(function (key) {
return this.ObjectsClass.getIDFromLibraryAndKey(this.libraryID, key);
}.bind(this)).filter(function (val) val !== false);
relatedItems = this.ObjectsClass.get(relatedItems);
var pred = Zotero.Relations.relatedItemPredicate;
for (let i=0; i<relatedItems.length; i++) {
let item = relatedItems[i];
let uri = Zotero.URI.getItemURI(item);
if (obj.relations[pred]) {
if (typeof obj.relations[pred] == 'string') {
obj.relations[pred] = [uri];
}
obj.relations[pred].push(uri)
}
else {
obj.relations[pred] = uri;
}
}
obj.relations = this.getRelations()
// Deleted
let deleted = this.deleted;
@ -4589,41 +4425,25 @@ Zotero.Item.prototype.loadCollections = Zotero.Promise.coroutine(function* (relo
});
Zotero.Item.prototype.loadRelations = Zotero.Promise.coroutine(function* (reload) {
if (this._loaded.relations && !reload) {
return;
}
Zotero.debug("Loading relations for item " + this.libraryKey);
this._requireData('primaryData');
var itemURI = Zotero.URI.getItemURI(this);
var relations = yield Zotero.Relations.getByURIs(itemURI);
relations = relations.map(function (rel) [rel.predicate, rel.object]);
// Related items are bidirectional, so include any with this item as the object
var reverseRelations = yield Zotero.Relations.getByURIs(
false, Zotero.Relations.relatedItemPredicate, itemURI
);
for (let i=0; i<reverseRelations.length; i++) {
let rel = reverseRelations[i];
relations.push([rel.predicate, rel.subject]);
}
// Also include any owl:sameAs relations with this item as the object
reverseRelations = yield Zotero.Relations.getByURIs(
false, Zotero.Relations.linkedObjectPredicate, itemURI
);
for (let i=0; i<reverseRelations.length; i++) {
let rel = reverseRelations[i];
relations.push([rel.predicate, rel.subject]);
}
this._relations = relations;
this._loaded.relations = true;
this._clearChanged('relations');
/**
* Return an item in the specified library equivalent to this item
*
* @return {Promise<Zotero.Item>}
*/
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
*/

View file

@ -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));
};

View file

@ -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<Zotero.DataObject[]>}
*/
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<Object[]>} - 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<rows.length; i++) {
let row = rows[i];
var rows = yield Zotero.DB.queryAsync(sql, object);
for (let i = 0; i < rows.length; i++) {
toReturn.push({
subject: row.subject,
predicate: row.predicate,
object: row.object
subject: yield objectsClass.getAsync(rows[i].id, { noCache: true }),
predicate: rows[i].predicate
});
}
return toReturn;
});
this.getSubject = Zotero.Promise.coroutine(function* (subject, predicate, object) {
var subjects = [];
var relations = yield this.getByURIs(subject, predicate, object);
for each(var relation in relations) {
subjects.push(relation.subject);
}
return subjects;
});
this.getObject = Zotero.Promise.coroutine(function* (subject, predicate, object) {
var objects = [];
var relations = yield this.getByURIs(subject, predicate, object);
for each(var relation in relations) {
objects.push(relation.object);
}
return objects;
});
this.updateUser = Zotero.Promise.coroutine(function* (fromUserID, fromLibraryID, toUserID, toLibraryID) {
if (!fromUserID) {
throw ("Invalid source userID " + fromUserID + " in Zotero.Relations.updateUserID");
@ -118,104 +107,53 @@ Zotero.Relations = new function () {
+ "object=REPLACE(object, 'zotero.org/users/" + fromUserID + "', "
+ "'zotero.org/users/" + toUserID + "') "
+ "WHERE predicate IN (?, ?)";
yield Zotero.DB.queryAsync(sql, [this.linkedObjectPredicate, this.deletedItemPredicate]);
yield Zotero.DB.queryAsync(sql, [this.linkedObjectPredicate, this.replacedItemPredicate]);
}.bind(this));
});
this.add = Zotero.Promise.coroutine(function* (libraryID, subject, predicate, object) {
predicate = this._getPrefixAndValue(predicate).join(':');
var sql = "INSERT INTO relations (libraryID, subject, predicate, object) "
+ "VALUES (?, ?, ?, ?)";
yield Zotero.DB.queryAsync(sql, [libraryID, subject, predicate, object]);
});
/**
* Copy relations from one object to another within the same library
*/
this.copyURIs = Zotero.Promise.coroutine(function* (libraryID, fromURI, toURI) {
var rels = yield this.getByURIs(fromURI);
for each(var rel in rels) {
yield this.add(libraryID, toURI, rel.predicate, rel.object);
}
var rels = yield this.getByURIs(false, false, fromURI);
for each(var rel in rels) {
yield this.add(libraryID, rel.subject, rel.predicate, toURI);
}
});
/**
* Deletes relations directly from the DB by URI prefix
*
* This does not update associated objects.
*
* @param {String} prefix
* @param {String[]} ignorePredicates
*/
this.eraseByURIPrefix = Zotero.Promise.method(function (prefix, ignorePredicates) {
prefix = prefix + '%';
var sql = "DELETE FROM relations WHERE (subject LIKE ? OR object LIKE ?)";
var params = [prefix, prefix];
if (ignorePredicates) {
for each(var ignorePredicate in ignorePredicates) {
sql += " AND predicate != ?";
params.push(ignorePredicate);
}
}
yield Zotero.DB.queryAsync(sql, params);
});
/**
* Deletes relations directly from the DB by URI prefix
*
* This does not update associated objects.
*
* @return {Promise}
*/
this.eraseByURI = Zotero.Promise.coroutine(function* (uri, ignorePredicates) {
var sql = "DELETE FROM relations WHERE (subject=? OR object=?)";
var params = [uri, uri];
if (ignorePredicates) {
for each(var ignorePredicate in ignorePredicates) {
sql += " AND predicate != ?";
params.push(ignorePredicate);
}
}
yield Zotero.DB.queryAsync(sql, params);
});
this.purge = Zotero.Promise.coroutine(function* () {
Zotero.DB.requireTransaction();
Zotero.debug("Purging relations");
Zotero.DB.requireTransaction();
var t = new Date;
var sql = "SELECT subject FROM relations WHERE predicate != ? "
+ "UNION SELECT object FROM relations WHERE predicate != ?";
var uris = yield Zotero.DB.columnQueryAsync(sql, [this.deletedItemPredicate, this.deletedItemPredicate]);
if (uris) {
var prefix = Zotero.URI.defaultPrefix;
for each(var uri in uris) {
// Skip URIs that don't begin with the default prefix,
// since they don't correspond to local items
if (uri.indexOf(prefix) == -1) {
continue;
let prefix = Zotero.URI.defaultPrefix;
var types = ['collection', 'item'];
for (let type of types) {
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
let getFunc = "getURI" + Zotero.Utilities.capitalize(type);
let objects = {};
// Get all object URIs except merge-tracking ones
let sql = "SELECT " + objectsClass.idColumn + " AS id, predicate, object "
+ "FROM " + type + "Relations "
+ "JOIN relationPredicates USING (predicateID) WHERE predicate != ?";
let rows = yield Zotero.DB.queryAsync(sql, [this.replacedItemPredicate]);
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
let uri = row.object;
// Erase Zotero URIs of this type that don't resolve to a local object
//
// TODO: Check for replaced-item relations and update relation rather than
// removing
if (uri.indexOf(prefix) != -1
&& uri.indexOf("/" + type + "s/") != -1
&& !Zotero.URI[getFunc](uri)) {
if (!objects[row.id]) {
objects[row.id] = yield objectsClass.getAsync(row.id, { noCache: true });
}
objects[row.id].removeRelation(row.predicate, uri);
}
if (uri.indexOf("/items/") != -1 && !Zotero.URI.getURIItemID(uri)) {
yield this.eraseByURI(uri);
}
if (uri.indexOf("/collections/") != -1 && !Zotero.URI.getURICollectionID(uri)) {
yield this.eraseByURI(uri);
for (let i in objects) {
yield objects[i].save();
}
}
Zotero.debug("Purged relations in " + ((new Date) - t) + "ms");
}
});
this._getPrefixAndValue = function(uri) {
var [prefix, value] = uri.split(':');
if (prefix && value) {

View file

@ -3118,7 +3118,7 @@ Zotero.Integration.URIMap.prototype.getZoteroItemForURIs = function(uris) {
// Follow merged item relations until we find an item or hit a dead end
while (!zoteroItem) {
var relations = Zotero.Relations.getByURIs(uri, Zotero.Relations.deletedItemPredicate);
var relations = Zotero.Relations.getByURIs(uri, Zotero.Relations.replacedItemPredicate);
// No merged items found
if(!relations.length) {
break;

View file

@ -1938,8 +1938,6 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync("INSERT INTO libraries VALUES (1, 'user')");
yield Zotero.DB.queryAsync("INSERT INTO libraries VALUES (2, 'publications')");
let oldUserLibraryID = yield Zotero.DB.valueQueryAsync("SELECT value FROM settings WHERE setting='account' AND key='libraryID'");
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO syncObjectTypes VALUES (7, 'setting')");
yield Zotero.DB.queryAsync("DELETE FROM version WHERE schema IN ('userdata2', 'userdata3')");
@ -2152,12 +2150,6 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync("DROP INDEX IF EXISTS itemAttachments_syncState");
yield Zotero.DB.queryAsync("CREATE INDEX itemAttachments_syncState ON itemAttachments(syncState)");
yield Zotero.DB.queryAsync("ALTER TABLE itemSeeAlso RENAME TO itemSeeAlsoOld");
yield Zotero.DB.queryAsync("CREATE TABLE itemSeeAlso (\n itemID INT NOT NULL,\n linkedItemID INT NOT NULL,\n PRIMARY KEY (itemID, linkedItemID),\n FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE,\n FOREIGN KEY (linkedItemID) REFERENCES items(itemID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO itemSeeAlso SELECT * FROM itemSeeAlsoOld");
yield Zotero.DB.queryAsync("DROP INDEX IF EXISTS itemSeeAlso_linkedItemID");
yield Zotero.DB.queryAsync("CREATE INDEX itemSeeAlso_linkedItemID ON itemSeeAlso(linkedItemID)");
yield Zotero.DB.queryAsync("ALTER TABLE collectionItems RENAME TO collectionItemsOld");
yield Zotero.DB.queryAsync("CREATE TABLE collectionItems (\n collectionID INT NOT NULL,\n itemID INT NOT NULL,\n orderIndex INT NOT NULL DEFAULT 0,\n PRIMARY KEY (collectionID, itemID),\n FOREIGN KEY (collectionID) REFERENCES collections(collectionID) ON DELETE CASCADE,\n FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO collectionItems SELECT * FROM collectionItemsOld");
@ -2175,12 +2167,7 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync("DROP INDEX IF EXISTS deletedItems_dateDeleted");
yield Zotero.DB.queryAsync("CREATE INDEX deletedItems_dateDeleted ON deletedItems(dateDeleted)");
yield Zotero.DB.queryAsync("UPDATE relations SET libraryID=1 WHERE libraryID=?", oldUserLibraryID);
yield Zotero.DB.queryAsync("ALTER TABLE relations RENAME TO relationsOld");
yield Zotero.DB.queryAsync("CREATE TABLE relations (\n libraryID INT NOT NULL,\n subject TEXT NOT NULL,\n predicate TEXT NOT NULL,\n object TEXT NOT NULL,\n clientDateModified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (subject, predicate, object),\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO relations SELECT * FROM relationsOld");
yield Zotero.DB.queryAsync("DROP INDEX IF EXISTS relations_object");
yield Zotero.DB.queryAsync("CREATE INDEX relations_object ON relations(object)");
yield _migrateUserData_80_relations();
yield Zotero.DB.queryAsync("ALTER TABLE groups RENAME TO groupsOld");
yield Zotero.DB.queryAsync("CREATE TABLE groups (\n groupID INTEGER PRIMARY KEY,\n libraryID INT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n description TEXT NOT NULL,\n editable INT NOT NULL,\n filesEditable INT NOT NULL,\n version INT NOT NULL,\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)");
@ -2256,9 +2243,7 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync("DROP TABLE itemCreatorsOld");
yield Zotero.DB.queryAsync("DROP TABLE itemDataOld");
yield Zotero.DB.queryAsync("DROP TABLE itemNotesOld");
yield Zotero.DB.queryAsync("DROP TABLE itemSeeAlsoOld");
yield Zotero.DB.queryAsync("DROP TABLE itemTagsOld");
yield Zotero.DB.queryAsync("DROP TABLE relationsOld");
yield Zotero.DB.queryAsync("DROP TABLE savedSearchesOld");
yield Zotero.DB.queryAsync("DROP TABLE storageDeleteLogOld");
yield Zotero.DB.queryAsync("DROP TABLE syncDeleteLogOld");
@ -2274,4 +2259,203 @@ Zotero.Schema = new function(){
yield _updateDBVersion('userdata', toVersion);
return true;
});
//
// Longer functions for specific upgrade steps
//
var _migrateUserData_80_relations = Zotero.Promise.coroutine(function* () {
yield Zotero.DB.queryAsync("CREATE TABLE relationPredicates (\n predicateID INTEGER PRIMARY KEY,\n predicate TEXT UNIQUE\n)");
yield Zotero.DB.queryAsync("CREATE TABLE collectionRelations (\n collectionID INT NOT NULL,\n predicateID INT NOT NULL,\n object TEXT NOT NULL,\n PRIMARY KEY (collectionID, predicateID, object),\n FOREIGN KEY (collectionID) REFERENCES collections(collectionID) ON DELETE CASCADE,\n FOREIGN KEY (predicateID) REFERENCES relationPredicates(predicateID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("CREATE INDEX collectionRelations_predicateID ON collectionRelations(predicateID)");
yield Zotero.DB.queryAsync("CREATE INDEX collectionRelations_object ON collectionRelations(object);");
yield Zotero.DB.queryAsync("CREATE TABLE itemRelations (\n itemID INT NOT NULL,\n predicateID INT NOT NULL,\n object TEXT NOT NULL,\n PRIMARY KEY (itemID, predicateID, object),\n FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE,\n FOREIGN KEY (predicateID) REFERENCES relationPredicates(predicateID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("CREATE INDEX itemRelations_predicateID ON itemRelations(predicateID)");
yield Zotero.DB.queryAsync("CREATE INDEX itemRelations_object ON itemRelations(object);");
yield Zotero.DB.queryAsync("UPDATE relations SET subject=object, predicate='dc:replaces', object=subject WHERE predicate='dc:isReplacedBy'");
var start = 0;
var limit = 100;
var collectionSQL = "INSERT OR IGNORE INTO collectionRelations (collectionID, predicateID, object) VALUES ";
var itemSQL = "INSERT OR IGNORE INTO itemRelations (itemID, predicateID, object) VALUES ";
// 1 2 1 2 3 4
var objectRE = /(?:(users)\/(\d+|local\/\w+)|(groups)\/(\d+))\/(collections|items)\/([A-Z0-9]{8})/;
// 1 2 1 2 3
var itemRE = /(?:(users)\/(\d+|local\/\w+)|(groups)\/(\d+))\/items\/([A-Z0-9]{8})/;
var report = "";
var groupLibraryIDMap = {};
var resolveLibrary = Zotero.Promise.coroutine(function* (usersOrGroups, id) {
if (usersOrGroups == 'users') return 1;
if (groupLibraryIDMap[id] !== undefined) return groupLibraryIDMap[id];
return groupLibraryIDMap[id] = (yield Zotero.DB.valueQueryAsync("SELECT libraryID FROM groups WHERE libraryID=?", id));
});
var predicateMap = {};
var resolvePredicate = Zotero.Promise.coroutine(function* (predicate) {
if (predicateMap[predicate]) return predicateMap[predicate];
yield Zotero.DB.queryAsync("INSERT INTO relationPredicates (predicateID, predicate) VALUES (NULL, ?)", predicate);
return predicateMap[predicate] = Zotero.DB.valueQueryAsync("SELECT predicateID FROM relationPredicates WHERE predicate=?", predicate);
});
while (true) {
let rows = yield Zotero.DB.queryAsync("SELECT subject, predicate, object FROM relations LIMIT ?, ?", [start, limit]);
if (!rows.length) {
break;
}
let collectionRels = [];
let itemRels = [];
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
let concat = row.subject + " - " + row.predicate + " - " + row.object;
try {
switch (row.predicate) {
case 'owl:sameAs':
let subjectMatch = row.subject.match(objectRE);
let objectMatch = row.object.match(objectRE);
if (!subjectMatch && !objectMatch) {
Zotero.logError("No match for relation subject or object: " + concat);
report += concat + "\n";
continue;
}
// Remove empty captured groups
subjectMatch = subjectMatch ? subjectMatch.filter(x => 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");
});
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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

View file

@ -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 (

View file

@ -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);
});

View file

@ -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);
})
})
})

View file

@ -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);
})
})
})
})

View file

@ -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* () {

View file

@ -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');

View file

@ -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);
})
})
})

View file

@ -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);
})
})
})