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

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