Revised logic of trashed collections in item cache (#4358)
- revert change from2401a34031
that only loads un-trashed collections in _loadCollections. If an item only belongs to deleted collections, item._loaded.collections = true from _loadCollections will never run, so an exception will be thrown in item.toJSON() when syncing happens. Instead, to address the problem of item.getCollections() having stale data #4307, add 'includeTrashed' parameter to item.getCollections() based on which item._collections will be filtered. Fixes: #4346 - revert earlier, no more necessary, changes froma532cfb475
to not alter item._collections cache when collections are being trashed or restored. Collection is removed from item._collections only when it is permanently erased. - removed unnecessary test checking for consistent item._collections value before and after reload, since item._collections is no longer modified - fix encountered bug where a trashed child collection is not unloaded if a parent collection is erased without being trashed first. - tweaked Zotero.Search sql construction to count items that only belong to trashed collections into 'unfiled'. Fixes: #4347 --------- Co-authored-by: Dan Stillman
This commit is contained in:
parent
58c445b616
commit
2d3375e9f6
7 changed files with 97 additions and 59 deletions
|
@ -347,21 +347,6 @@ Zotero.Collection.prototype._saveData = Zotero.Promise.coroutine(function* (env)
|
||||||
else {
|
else {
|
||||||
let sql = "DELETE FROM deletedCollections WHERE collectionID=?";
|
let sql = "DELETE FROM deletedCollections WHERE collectionID=?";
|
||||||
|
|
||||||
// Subcollection is restored from trash - add it back into the object cache
|
|
||||||
if (this.parentKey) {
|
|
||||||
let parent = Zotero.Collections.getIDFromLibraryAndKey(this.libraryID, this.parentKey);
|
|
||||||
Zotero.DB.addCurrentCallback("commit", function () {
|
|
||||||
this.ObjectsClass.registerChildCollection(parent, this.id);
|
|
||||||
}.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add restored collection back into item's _collections cache
|
|
||||||
this.getChildItems(false, true).forEach((item) => {
|
|
||||||
let collectionCached = item._collections.includes(this.id);
|
|
||||||
if (!collectionCached) {
|
|
||||||
item._collections.push(this.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
yield Zotero.DB.queryAsync(sql, collectionID);
|
yield Zotero.DB.queryAsync(sql, collectionID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -616,7 +601,6 @@ Zotero.Collection.prototype.trash = Zotero.Promise.coroutine(function* (env) {
|
||||||
var libraryHasTrash = Zotero.Libraries.hasTrash(this.libraryID);
|
var libraryHasTrash = Zotero.Libraries.hasTrash(this.libraryID);
|
||||||
|
|
||||||
var del = [];
|
var del = [];
|
||||||
var itemsToUpdate = [];
|
|
||||||
for(var i=0, len=descendents.length; i<len; i++) {
|
for(var i=0, len=descendents.length; i<len; i++) {
|
||||||
// Descendent collections
|
// Descendent collections
|
||||||
if (descendents[i].type == 'collection') {
|
if (descendents[i].type == 'collection') {
|
||||||
|
@ -641,10 +625,6 @@ Zotero.Collection.prototype.trash = Zotero.Promise.coroutine(function* (env) {
|
||||||
del.push(descendents[i].id);
|
del.push(descendents[i].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If item isn't being removed or is just moving to the trash, mark for update
|
|
||||||
if (!env.options.deleteItems || libraryHasTrash) {
|
|
||||||
itemsToUpdate.push(descendents[i].id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (del.length) {
|
if (del.length) {
|
||||||
|
@ -678,7 +658,6 @@ Zotero.Collection.prototype.trash = Zotero.Promise.coroutine(function* (env) {
|
||||||
if (env.isNew) {
|
if (env.isNew) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
env.deletedObjectIDs = collections;
|
|
||||||
|
|
||||||
// Reload collection data to show/restore deleted collections from trash
|
// Reload collection data to show/restore deleted collections from trash
|
||||||
for (let collectionID of collections) {
|
for (let collectionID of collections) {
|
||||||
|
@ -686,14 +665,6 @@ Zotero.Collection.prototype.trash = Zotero.Promise.coroutine(function* (env) {
|
||||||
yield collection.loadDataType('primaryData', true);
|
yield collection.loadDataType('primaryData', true);
|
||||||
yield collection.loadDataType('childCollections', true);
|
yield collection.loadDataType('childCollections', true);
|
||||||
}
|
}
|
||||||
// Update collection cache for descendant items
|
|
||||||
if (itemsToUpdate.length) {
|
|
||||||
let deletedCollections = new Set(env.deletedObjectIDs);
|
|
||||||
itemsToUpdate.forEach((itemID) => {
|
|
||||||
let item = Zotero.Items.get(itemID);
|
|
||||||
item._collections = item._collections.filter(c => !deletedCollections.has(c));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -707,10 +678,13 @@ Zotero.Collection.prototype._eraseData = Zotero.Promise.coroutine(function* (env
|
||||||
}
|
}
|
||||||
|
|
||||||
var collections = [this.id];
|
var collections = [this.id];
|
||||||
var descendents = this.getDescendents(false, null, true)
|
var descendents = this.getDescendents(false, null, true);
|
||||||
|
collections = descendents
|
||||||
.filter(d => d.type == 'collection')
|
.filter(d => d.type == 'collection')
|
||||||
.map(c => c.id);
|
.map(c => c.id).concat(collections);
|
||||||
collections = collections.concat(descendents);
|
|
||||||
|
// Make sure all descendant collections will be unloaded in this._finalizeErase()
|
||||||
|
env.deletedObjectIDs = collections;
|
||||||
|
|
||||||
yield Zotero.Utilities.Internal.forEachChunkAsync(
|
yield Zotero.Utilities.Internal.forEachChunkAsync(
|
||||||
collections,
|
collections,
|
||||||
|
@ -741,6 +715,14 @@ Zotero.Collection.prototype._eraseData = Zotero.Promise.coroutine(function* (env
|
||||||
this.ObjectsClass.unregisterChildCollection(parentCollectionID, this.id);
|
this.ObjectsClass.unregisterChildCollection(parentCollectionID, this.id);
|
||||||
}.bind(this));
|
}.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove erased collection from collection cache of descendant items
|
||||||
|
var itemsToUpdate = descendents.filter(d => d.type == 'item').map(c => c.id);
|
||||||
|
let deletedCollections = new Set(collections);
|
||||||
|
itemsToUpdate.forEach((itemID) => {
|
||||||
|
let item = Zotero.Items.get(itemID);
|
||||||
|
item._collections = item._collections.filter(c => !deletedCollections.has(c));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Zotero.Collection.prototype._finalizeErase = Zotero.Promise.coroutine(function* (env) {
|
Zotero.Collection.prototype._finalizeErase = Zotero.Promise.coroutine(function* (env) {
|
||||||
|
|
|
@ -4261,11 +4261,21 @@ Zotero.Item.prototype.removeAllTags = function() {
|
||||||
/**
|
/**
|
||||||
* Gets the collections the item is in
|
* Gets the collections the item is in
|
||||||
*
|
*
|
||||||
|
* @param {Boolean} includeTrashed Include trashed collections
|
||||||
* @return {Array<Integer>} An array of collectionIDs for all collections the item belongs to
|
* @return {Array<Integer>} An array of collectionIDs for all collections the item belongs to
|
||||||
*/
|
*/
|
||||||
Zotero.Item.prototype.getCollections = function () {
|
Zotero.Item.prototype.getCollections = function (includeTrashed) {
|
||||||
this._requireData('collections');
|
this._requireData('collections');
|
||||||
|
if (includeTrashed) {
|
||||||
return this._collections.concat();
|
return this._collections.concat();
|
||||||
|
}
|
||||||
|
return this._collections.filter((id) => {
|
||||||
|
var col = Zotero.Collections.get(id);
|
||||||
|
if (!col) {
|
||||||
|
throw new Error("Collection " + id + " not found for item " + this.libraryKey);
|
||||||
|
}
|
||||||
|
return !col.deleted;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -797,8 +797,7 @@ Zotero.Items = function() {
|
||||||
this._loadCollections = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
|
this._loadCollections = Zotero.Promise.coroutine(function* (libraryID, ids, idSQL) {
|
||||||
var sql = "SELECT itemID, collectionID FROM items "
|
var sql = "SELECT itemID, collectionID FROM items "
|
||||||
+ "LEFT JOIN collectionItems USING (itemID) "
|
+ "LEFT JOIN collectionItems USING (itemID) "
|
||||||
+ "LEFT JOIN deletedCollections USING (collectionID) "
|
+ "WHERE libraryID=?" + idSQL;
|
||||||
+ "WHERE deletedCollections.collectionID IS NULL AND libraryID=?" + idSQL;
|
|
||||||
var params = [libraryID];
|
var params = [libraryID];
|
||||||
|
|
||||||
var lastItemID;
|
var lastItemID;
|
||||||
|
|
|
@ -1145,7 +1145,8 @@ Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
|
||||||
|
|
||||||
if (unfiled) {
|
if (unfiled) {
|
||||||
sql += " AND (itemID NOT IN ("
|
sql += " AND (itemID NOT IN ("
|
||||||
+ "SELECT itemID FROM collectionItems "
|
// Exclude items that belong to non-trashed collections
|
||||||
|
+ "SELECT itemID FROM collectionItems WHERE collectionID NOT IN (SELECT collectionID FROM deletedCollections) "
|
||||||
// Exclude children
|
// Exclude children
|
||||||
+ "UNION SELECT itemID FROM itemAttachments WHERE parentItemID IS NOT NULL "
|
+ "UNION SELECT itemID FROM itemAttachments WHERE parentItemID IS NOT NULL "
|
||||||
+ "UNION SELECT itemID FROM itemNotes WHERE parentItemID IS NOT NULL "
|
+ "UNION SELECT itemID FROM itemNotes WHERE parentItemID IS NOT NULL "
|
||||||
|
|
|
@ -102,8 +102,10 @@ describe("Zotero.Collection", function() {
|
||||||
collection1.deleted = true;
|
collection1.deleted = true;
|
||||||
await collection1.saveTx();
|
await collection1.saveTx();
|
||||||
|
|
||||||
// Deleted collection is gone from item's cache
|
// Trashed collection does not count as one of item's containers
|
||||||
assert.notInclude(item1.getCollections(), collection1.id);
|
assert.notInclude(item1.getCollections(), collection1.id);
|
||||||
|
// But it should still return it if includeTrashed=true is passed
|
||||||
|
assert.include(item1.getCollections(true), collection1.id);
|
||||||
|
|
||||||
// Restore deleted collection
|
// Restore deleted collection
|
||||||
collection1.deleted = false;
|
collection1.deleted = false;
|
||||||
|
@ -114,40 +116,25 @@ describe("Zotero.Collection", function() {
|
||||||
// Collection is restored from trash
|
// Collection is restored from trash
|
||||||
assert.notInclude(deleted, collection1.id);
|
assert.notInclude(deleted, collection1.id);
|
||||||
|
|
||||||
// Collection is back in item's cache
|
// Item belongs to the restored collection
|
||||||
assert.include(item1.getCollections(), collection1.id);
|
assert.include(item1.getCollections(), collection1.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should permanently delete collections from trash", async function () {
|
it("should permanently delete collections from trash", async function () {
|
||||||
var collection1 = await createDataObject('collection');
|
var collection1 = await createDataObject('collection');
|
||||||
var collection2 = await createDataObject('collection', { parentID: collection1.id });
|
var collection2 = await createDataObject('collection', { parentID: collection1.id });
|
||||||
var collection3 = await createDataObject('collection', { parentID: collection2.id });
|
var collection3 = await createDataObject('collection', { parentID: collection2.id, deleted: true });
|
||||||
|
var item = await createDataObject('item', { collections: [collection1.id, collection2.id, collection3.id] });
|
||||||
|
|
||||||
await collection1.eraseTx();
|
await collection1.eraseTx();
|
||||||
|
|
||||||
assert.equal(await Zotero.Collections.getAsync(collection1.id), false);
|
assert.equal(await Zotero.Collections.getAsync(collection1.id), false);
|
||||||
assert.equal(await Zotero.Collections.getAsync(collection2.id), false);
|
assert.equal(await Zotero.Collections.getAsync(collection2.id), false);
|
||||||
assert.equal(await Zotero.Collections.getAsync(collection3.id), false);
|
assert.equal(await Zotero.Collections.getAsync(collection3.id), false);
|
||||||
});
|
|
||||||
|
|
||||||
it("should not load deleted collections into item's cache on startup", async function () {
|
// Erased collections are fully removed as item's containers
|
||||||
var collection1 = await createDataObject('collection');
|
assert.equal(item.getCollections().length, 0);
|
||||||
var collection2 = await createDataObject('collection');
|
assert.equal(item.getCollections(true).length, 0);
|
||||||
var item = await createDataObject('item', { collections: [collection1.id, collection2.id] });
|
|
||||||
|
|
||||||
collection1.deleted = true;
|
|
||||||
await collection1.saveTx();
|
|
||||||
|
|
||||||
// Make sure the deleted collection is gone from item's cache
|
|
||||||
assert.notInclude(item.getCollections(), collection1.id);
|
|
||||||
assert.include(item.getCollections(), collection2.id);
|
|
||||||
|
|
||||||
// Reload the item's data from DB
|
|
||||||
await Zotero.Items.get(item.id).reload(null, true);
|
|
||||||
|
|
||||||
// Make sure the deleted collection is not back in item's cache
|
|
||||||
assert.notInclude(item.getCollections(), collection1.id);
|
|
||||||
assert.include(item.getCollections(), collection2.id);
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -696,6 +696,46 @@ describe("Zotero.Item", function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe("#getCollections()", function () {
|
||||||
|
it("shouldn't include collections in the trash", async function () {
|
||||||
|
var collection1 = await createDataObject('collection');
|
||||||
|
var collection2 = await createDataObject('collection');
|
||||||
|
var item = await createDataObject('item', { collections: [collection1.id, collection2.id] });
|
||||||
|
|
||||||
|
assert.sameMembers(item.getCollections(), [collection1.id, collection2.id]);
|
||||||
|
|
||||||
|
collection1.deleted = true;
|
||||||
|
await collection1.saveTx();
|
||||||
|
|
||||||
|
assert.sameMembers(item.getCollections(), [collection2.id]);
|
||||||
|
|
||||||
|
// Simulate a restart
|
||||||
|
await Zotero.Items.get(item.id).reload(null, true);
|
||||||
|
|
||||||
|
// Make sure the deleted collection is not back in item's cache
|
||||||
|
assert.sameMembers(item.getCollections(), [collection2.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include collections in the trash if includeTrashed=true", async function () {
|
||||||
|
var collection1 = await createDataObject('collection');
|
||||||
|
var collection2 = await createDataObject('collection');
|
||||||
|
var item = await createDataObject('item', { collections: [collection1.id, collection2.id] });
|
||||||
|
|
||||||
|
assert.sameMembers(item.getCollections(true), [collection1.id, collection2.id]);
|
||||||
|
|
||||||
|
collection1.deleted = true;
|
||||||
|
await collection1.saveTx();
|
||||||
|
|
||||||
|
assert.sameMembers(item.getCollections(true), [collection1.id, collection2.id]);
|
||||||
|
|
||||||
|
// Simulate a restart
|
||||||
|
await Zotero.Items.get(item.id).reload(null, true);
|
||||||
|
|
||||||
|
assert.sameMembers(item.getCollections(true), [collection1.id, collection2.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("#setCollections()", function () {
|
describe("#setCollections()", function () {
|
||||||
it("should add a collection with an all-numeric key", async function () {
|
it("should add a collection with an all-numeric key", async function () {
|
||||||
var col = new Zotero.Collection();
|
var col = new Zotero.Collection();
|
||||||
|
|
|
@ -582,6 +582,25 @@ describe("Zotero.Search", function() {
|
||||||
assert.include(matches, item1.id);
|
assert.include(matches, item1.id);
|
||||||
assert.notInclude(matches, item2.id);
|
assert.notInclude(matches, item2.id);
|
||||||
});
|
});
|
||||||
|
it("should include items belonging only to trashed collections", async function () {
|
||||||
|
var collection = await createDataObject('collection');
|
||||||
|
var item = await createDataObject('item', { collections: [collection.id] });
|
||||||
|
|
||||||
|
var s = new Zotero.Search;
|
||||||
|
s.libraryID = Zotero.Libraries.userLibraryID;
|
||||||
|
s.addCondition('unfiled', 'true');
|
||||||
|
|
||||||
|
// item belonging to a non-trashed collection is not unfiled
|
||||||
|
var matches = await s.search();
|
||||||
|
assert.notInclude(matches, item.id);
|
||||||
|
|
||||||
|
collection.deleted = true;
|
||||||
|
await collection.saveTx();
|
||||||
|
|
||||||
|
// item that only belongs to a trashed collection is unfiled
|
||||||
|
matches = await s.search();
|
||||||
|
assert.include(matches, item.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Quick search", function () {
|
describe("Quick search", function () {
|
||||||
|
|
Loading…
Reference in a new issue