From 02b19d0cdee8778dbba71287f5243218ef85b0e6 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Fri, 26 Jun 2026 16:52:37 -0400 Subject: [PATCH] Match multi-level search condition against any ancestor level for descendant results A field that exists at more than one level (e.g., Title, on both top-level items and attachments) targeting a descendant result level only matched the closest ancestor, so searching annotations by Title found nothing, since it checked the parent attachment's title rather than the top-level item's. Map down from each ancestor level and union them, testing the predicate once so its bound parameters aren't duplicated. Addresses #5978 --- chrome/content/zotero/xpcom/data/search.js | 35 ++++++++++++++++++++++ test/tests/searchTest.js | 35 ++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/chrome/content/zotero/xpcom/data/search.js b/chrome/content/zotero/xpcom/data/search.js index 3dbcc73c85..c9897e3834 100644 --- a/chrome/content/zotero/xpcom/data/search.js +++ b/chrome/content/zotero/xpcom/data/search.js @@ -2311,6 +2311,41 @@ Zotero.Search.mapPredicate = function (sql, fromLevel, toLevel, negated = false) return s; }; + // A multi-level field (e.g. title, which exists on both the top-level item and an attachment) + // targeting a descendant should match if *any* of those ancestor levels has the value -- an + // annotation matches when its parent attachment OR its top-level item has the title (and a + // snapshot's URL stays matchable at the attachment level). Map down from each such ancestor + // and union them, testing the predicate once (a single `anc IN (...)`) so its bound parameters + // aren't duplicated. A negation keeps the single-level behavior below. + let ancestorLevels = fromLevels.filter( + l => l != 'any' && Zotero.Search._isAncestorLevel(l, toLevel)); + if (!negated && ancestorLevels.length > 1) { + // (toLevel itemID, ancestor itemID) pairs, walking from toLevel up to `anc`. A standalone + // attachment/note has no parent, so it stands in as its own top-level item (COALESCE). + let pairsTo = (anc) => { + let levels = []; + let l = toLevel; + while (l != anc) { + levels.push(l); + l = Zotero.Search._levelParent[l]; + } + let aliases = levels.map((_, i) => 't' + i); + let fromSQL = `${Zotero.Search._levelChildTable[levels[0]]} ${aliases[0]}`; + for (let i = 1; i < levels.length; i++) { + fromSQL += ` JOIN ${Zotero.Search._levelChildTable[levels[i]]} ${aliases[i]}` + + ` ON ${aliases[i]}.itemID = ${aliases[i - 1]}.parentItemID`; + } + let last = aliases[aliases.length - 1]; + let lastLevel = levels[levels.length - 1]; + let ancExpr = Zotero.Search._levelCanBeStandalone[lastLevel] + ? `COALESCE(${last}.parentItemID, ${last}.itemID)` + : `${last}.parentItemID`; + return `SELECT ${aliases[0]}.itemID AS itemID, ${ancExpr} AS anc FROM ${fromSQL}`; + }; + let union = ancestorLevels.map(pairsTo).join(' UNION ALL '); + return `itemID IN (SELECT itemID FROM (${union}) WHERE anc IN (${matches}))`; + } + let mapped = Zotero.Search._isAncestorLevel(toLevel, from) ? mapUp(matches, from, toLevel) : mapDown(matches, from, toLevel); diff --git a/test/tests/searchTest.js b/test/tests/searchTest.js index f188c71365..a079e8c19b 100644 --- a/test/tests/searchTest.js +++ b/test/tests/searchTest.js @@ -356,6 +356,41 @@ describe("Zotero.Search", function () { await other.eraseTx(); }); + it("should match an annotation by its top-level item's title", async function () { + // Title exists on both items and attachments, so targeting annotations must + // reach the top-level item's title, not just the parent attachment's + var title = 'zanntitle' + Zotero.Utilities.randomString(); + var item = await createDataObject('item', { title }); + var attachment = await importPDFAttachment(item); + var annotation = await createAnnotation('highlight', attachment); + + var s = new Zotero.Search(); + s.libraryID = userLibraryID; + s.addCondition('resultLevel', 'annotation'); + s.addCondition('title', 'contains', title); + assert.sameMembers(await s.search(), [annotation.id]); + + await item.eraseTx(); + }); + + it("should match an annotation by its parent attachment's title", async function () { + // The other half of the union: an attachment's own title still matches + var title = 'zatttitle' + Zotero.Utilities.randomString(); + var item = await createDataObject('item'); + var attachment = await importPDFAttachment(item); + attachment.setField('title', title); + await attachment.saveTx(); + var annotation = await createAnnotation('highlight', attachment); + + var s = new Zotero.Search(); + s.libraryID = userLibraryID; + s.addCondition('resultLevel', 'annotation'); + s.addCondition('title', 'contains', title); + assert.sameMembers(await s.search(), [annotation.id]); + + await item.eraseTx(); + }); + it("should match annotations by type and color", async function () { var item = await createDataObject('item'); var attachment = await importPDFAttachment(item);