Fix negated Title/Creator/Year and Any Field advanced searches

These conditions expanded into an OR-group across their underlying
fields, so a "does not contain"/"is not" operator matched almost every
item: any item missing one of the fields satisfied that field's negated
condition. Use an AND-group for negative operators, so the value must be
absent from every field.

https://forums.zotero.org/discussion/132835/
This commit is contained in:
Dan Stillman 2026-07-21 14:14:31 -04:00
parent 40974e493d
commit e81e72dccf
2 changed files with 30 additions and 8 deletions

View file

@ -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 },

View file

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