Revised logic of trashed collections in item cache (#4358)

- revert change from 2401a34031
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 from a532cfb475
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:
abaevbog 2024-07-09 00:34:12 -07:00 committed by GitHub
parent 58c445b616
commit 2d3375e9f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 97 additions and 59 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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