diff --git a/chrome/content/zotero/xpcom/data/search.js b/chrome/content/zotero/xpcom/data/search.js index 31992e0e30..dd8390f325 100644 --- a/chrome/content/zotero/xpcom/data/search.js +++ b/chrome/content/zotero/xpcom/data/search.js @@ -1212,14 +1212,17 @@ Zotero.Search.prototype._buildQuery = async function () { // addCondition() so the expansion isn't saved in the search object. // Splice it in right after this condition so it's processed at the same // nesting depth -- "Any Field" inside a group stays in that group. - // Always an OR-group: "Any Field" means "matches in any one of these - // fields", correct whether the surrounding join mode is 'all' or 'any' - // (an OR-group nested in an 'any' group flattens out). + // For positive operators this is an OR-group ("matches in any one of + // these fields"). For negative operators it must be an AND-group: "the + // value appears in none of these fields" holds only when every field + // fails to match (De Morgan), and an OR-group would match trivially for + // any item missing one of the fields. let op = condition.operator; let val = condition.value; + let joinMode = op == 'isNot' || op == 'doesNotContain' ? 'all' : 'any'; conditionsToProcess.splice(conditionIndex + 1, 0, { condition: 'groupStart', operator: 'true', value: '' }, - { condition: 'joinMode', operator: 'any' }, + { condition: 'joinMode', operator: joinMode }, { condition: 'field', operator: op, value: val }, { condition: 'tag', operator: op, value: val }, { condition: 'note', operator: op, value: val }, @@ -1234,13 +1237,16 @@ Zotero.Search.prototype._buildQuery = async function () { case 'titleCreatorYear': { // Expand to the same field set as 'quicksearch-titleCreatorYear' (without // key detection or the top-level-only restriction, which the result level - // now handles). Spliced in after this condition like 'anyField' above, as - // an OR-group so it matches in any one of these fields. + // now handles). Spliced in after this condition like 'anyField' above. + // An OR-group for positive operators; an AND-group for negative ones, so + // "does not contain"/"is not" matches only when the value is absent from + // every field (see 'anyField' above). let op = condition.operator; let val = condition.value; + let joinMode = op == 'isNot' || op == 'doesNotContain' ? 'all' : 'any'; conditionsToProcess.splice(conditionIndex + 1, 0, { condition: 'groupStart', operator: 'true', value: '' }, - { condition: 'joinMode', operator: 'any' }, + { condition: 'joinMode', operator: joinMode }, { condition: 'title', operator: op, value: val }, { condition: 'publicationTitle', operator: op, value: val }, { condition: 'shortTitle', operator: op, value: val }, diff --git a/test/tests/searchTest.js b/test/tests/searchTest.js index b5773169e3..639d6efa69 100644 --- a/test/tests/searchTest.js +++ b/test/tests/searchTest.js @@ -1539,9 +1539,25 @@ describe("Zotero.Search", function () { s.libraryID = userLibraryID; s.addCondition('titleCreatorYear', 'contains', word); assert.sameMembers(await s.search(), [byTitle.id, byCreator.id]); - + await Zotero.Items.erase([byTitle.id, byCreator.id, byOther.id]); }); + it("should exclude items whose title contains the value for doesNotContain", async function () { + var word = 'ztcy' + Zotero.Utilities.randomString(); + // Title contains the word -- must be excluded + var withWord = await createDataObject('item', { title: 'a ' + word + ' b' }); + // Title lacks the word and has no publicationTitle/year/etc. -- must match + var withoutWord = await createDataObject('item', { title: 'nothing here' }); + + var s = new Zotero.Search(); + s.libraryID = userLibraryID; + s.addCondition('titleCreatorYear', 'doesNotContain', word); + var matches = await s.search(); + assert.include(matches, withoutWord.id); + assert.notInclude(matches, withWord.id); + + await Zotero.Items.erase([withWord.id, withoutWord.id]); + }); });