zotero/test/tests/searchTest.js
Dan Stillman ad98e84d24 Fix Any Field searches at a non-item result level
Any Field expands to a generic 'field' condition, which the cross-level
code treated as matching only on top-level items. At an attachment
result level it therefore matched attachments whose parent item had the
value, instead of attachments with the value themselves. Since the
condition stands in for every field, it's now treated as matching at any
level those fields live at.
2026-08-21 12:56:05 -04:00

2244 lines
88 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

describe("Zotero.Search", function () {
describe("#name", function () {
it("should fail if empty", async function () {
var s = new Zotero.Search();
assert.throws(() => s.name = '');
});
});
describe("#addCondition()", function () {
it("should convert old-style 'collection' condition value", async function () {
var col = await createDataObject('collection');
var item = await createDataObject('item', { collections: [col.id] });
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.name = "Test";
s.addCondition('collection', 'is', '0_' + col.key);
var matches = await s.search();
assert.sameMembers(matches, [item.id]);
});
});
// This is for Zotero.Search._loadConditions()
describe("Loading", function () {
it("should convert old-style 'collection' condition value", async function () {
var col = await createDataObject('collection');
var item = await createDataObject('item', { collections: [col.id] });
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.name = "Test";
s.addCondition('collection', 'is', col.key);
await s.saveTx();
await Zotero.DB.queryAsync(
"UPDATE savedSearchConditions SET value=? WHERE savedSearchID=? AND condition=?",
["0_" + col.key, s.id, 'collection']
);
await s.reload(['conditions'], true);
var matches = await s.search();
assert.sameMembers(matches, [item.id]);
});
it("should migrate a stored `childNote` condition to `note` at the item level", async function () {
var text = 'zloadcn' + Zotero.Utilities.randomString();
var item = await createDataObject('item', { title: 'zloadcnitem' });
var note = new Zotero.Item('note');
note.libraryID = item.libraryID;
note.parentID = item.id;
note.setNote('<p>' + text + '</p>');
await note.saveTx();
// Save a 'note' search with no result level, then rewrite the stored condition to the
// obsolete childNote to stand in for a legacy saved search
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.name = "Test";
s.addCondition('note', 'contains', text);
await s.saveTx();
await Zotero.DB.queryAsync(
"UPDATE savedSearchConditions SET condition=? WHERE savedSearchID=? AND condition=?",
['childNote', s.id, 'note']
);
await s.reload(['conditions'], true);
var conds = Object.values(s.getConditions());
assert.include(conds.map(c => c.condition), 'note');
assert.notInclude(conds.map(c => c.condition), 'childNote');
// An item result level is seeded so the note rolls up to its parent, as childNote did
var resultLevel = conds.find(c => c.condition == 'resultLevel');
assert.ok(resultLevel);
assert.equal(resultLevel.operator, 'item');
assert.sameMembers(s.toJSON().conditions.map(c => c.condition), ['note', 'resultLevel']);
assert.sameMembers(await s.search(), [item.id]);
await item.eraseTx();
await s.eraseTx();
});
});
describe("#save()", function () {
it("should fail without a name", async function () {
var s = new Zotero.Search;
s.addCondition('title', 'is', 'test');
var e = await getPromiseError(s.saveTx());
assert.ok(e);
assert.equal(e.constructor.name, Error.prototype.constructor.name); // TEMP: Error mismatch
assert.equal(e.message, "Name not provided for saved search");
});
it("should save a new search", async function () {
// Save search
var s = new Zotero.Search;
s.name = "Test";
s.addCondition('title', 'is', 'test');
var id = await s.saveTx();
assert.typeOf(id, 'number');
// Check saved search
s = Zotero.Searches.get(id);
assert.ok(s);
assert.instanceOf(s, Zotero.Search);
assert.equal(s.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(s.name, "Test");
var conditions = s.getConditions();
assert.lengthOf(Object.keys(conditions), 1);
assert.property(conditions, "0");
var condition = conditions[0];
assert.propertyVal(condition, 'condition', 'title')
assert.propertyVal(condition, 'operator', 'is')
assert.propertyVal(condition, 'value', 'test')
});
it("should add a condition to an existing search", async function () {
// Save search
var s = new Zotero.Search;
s.libraryID = Zotero.Libraries.userLibraryID;
s.name = "Test";
s.addCondition('title', 'is', 'test');
var id = await s.saveTx();
assert.typeOf(id, 'number');
// Add condition
s = await Zotero.Searches.getAsync(id);
s.addCondition('title', 'contains', 'foo');
var saved = await s.saveTx();
assert.isTrue(saved);
// Check saved search
s = await Zotero.Searches.getAsync(id);
var conditions = s.getConditions();
assert.lengthOf(Object.keys(conditions), 2);
});
it("should remove a condition from an existing search", async function () {
// Save search
var s = new Zotero.Search;
s.libraryID = Zotero.Libraries.userLibraryID;
s.name = "Test";
s.addCondition('title', 'is', 'test');
s.addCondition('title', 'contains', 'foo');
var id = await s.saveTx();
assert.typeOf(id, 'number');
// Remove condition
s = await Zotero.Searches.getAsync(id);
s.removeCondition(0);
var saved = await s.saveTx();
assert.isTrue(saved);
// Check saved search
s = await Zotero.Searches.getAsync(id);
var conditions = s.getConditions();
assert.lengthOf(Object.keys(conditions), 1);
assert.property(conditions, "0");
assert.propertyVal(conditions[0], 'value', 'foo')
});
});
describe("#combineConditions()", function () {
function pred(sql, params = []) {
return { sql, params };
}
it("should AND predicates in 'all' mode", function () {
var r = Zotero.Search.combineConditions([pred('A'), pred('B')]);
assert.equal(r.sql, 'A AND B');
});
it("should OR and parenthesize predicates in 'any' mode", function () {
var r = Zotero.Search.combineConditions([
{ marker: 'joinMode', operator: 'any' }, pred('A'), pred('B')
]);
assert.equal(r.sql, '(A OR B)');
});
it("should combine a nested group", function () {
var r = Zotero.Search.combineConditions([
pred('A'),
{ marker: 'groupStart' },
{ marker: 'joinMode', operator: 'any' },
pred('B'),
pred('C'),
{ marker: 'groupEnd' }
]);
assert.equal(r.sql, 'A AND (B OR C)');
});
it("should collect params in order", function () {
var r = Zotero.Search.combineConditions([pred('A=?', [1]), pred('B=?', [2])]);
assert.equal(r.sql, 'A=? AND B=?');
assert.deepEqual(r.params, [1, 2]);
});
});
describe("#mapPredicate()", function () {
var c = (sql, from, to, neg) => Zotero.Search.mapPredicate(sql, from, to, neg);
it("should leave a predicate unchanged when the result level is 'any'", function () {
assert.equal(c('X', 'item', 'any'), 'X');
assert.equal(c('X', 'any', 'any'), 'X');
});
it("should not roll up a negated level-agnostic condition", function () {
assert.equal(c('X', 'any', 'item', true), 'X');
});
it("should match nothing for unrelated branches (note vs annotation)", function () {
assert.equal(c('X', 'note', 'annotation'), '0');
});
it("should leave a multi-level field unchanged at a level it matches at", function () {
// e.g., title exists on both items and attachments, so at either result level the
// predicate is used as-is (the result-level FROM filters to the right rows)
assert.equal(c('X', ['item', 'attachment'], 'item'), 'X');
assert.equal(c('X', ['item', 'attachment'], 'attachment'), 'X');
assert.equal(c('X', ['item', 'attachment'], 'any'), 'X');
});
});
describe("#search()", function () {
var userLibraryID;
var fooItem;
var foobarItem;
var bazItem;
var importedURLItem;
var linkedFileItem;
var linkedURLItem;
var fooItemGroup;
var foobarItemGroup;
var bazItemGroup;
before(async function () {
await resetDB({
thisArg: this,
skipBundledFiles: true
});
fooItem = await importFileAttachment("search/foo.html");
foobarItem = await importFileAttachment("search/foobar.html");
bazItem = await importFileAttachment("search/baz.pdf");
userLibraryID = fooItem.libraryID;
let testPDF = getTestDataDirectory();
testPDF.append('test.pdf');
importedURLItem = await Zotero.Attachments.importSnapshotFromFile({
file: testPDF,
libraryID: userLibraryID,
title: 'imported-url-pdf',
url: 'http://example.com/imported-url.pdf',
contentType: 'application/pdf',
singleFile: true
});
linkedFileItem = await Zotero.Attachments.linkFromFile({
file: OS.Path.join(getTestDataDirectory().path, 'test.pdf'),
title: 'linked-file-pdf'
});
linkedURLItem = await Zotero.Attachments.linkFromURL({
url: 'http://example.com/linked-url.pdf',
title: 'linked-url',
contentType: 'application/pdf'
});
let group = await getGroup();
fooItemGroup = await importFileAttachment("search/foo.html", { libraryID: group.libraryID });
foobarItemGroup = await importFileAttachment("search/foobar.html", { libraryID: group.libraryID });
bazItemGroup = await importFileAttachment("search/baz.pdf", { libraryID: group.libraryID });
});
after(function* () {
yield fooItem.eraseTx();
yield foobarItem.eraseTx();
yield bazItem.eraseTx();
yield importedURLItem.eraseTx();
yield linkedFileItem.eraseTx();
yield linkedURLItem.eraseTx();
yield fooItemGroup.eraseTx();
yield foobarItemGroup.eraseTx();
yield bazItemGroup.eraseTx();
});
describe("Conditions", function () {
describe("Nested condition groups", function () {
it("should match A AND (B OR C)", async function () {
var ab = await createDataObject('item', { title: 'zgrpA', tags: [{ tag: 'zgrpB' }] });
var ac = await createDataObject('item', { title: 'zgrpA', tags: [{ tag: 'zgrpC' }] });
await createDataObject('item', { title: 'zgrpA' });
await createDataObject('item', { tags: [{ tag: 'zgrpB' }] });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'all');
s.addCondition('title', 'contains', 'zgrpA');
s.addCondition('groupStart', 'true', '');
s.addCondition('joinMode', 'any');
s.addCondition('tag', 'is', 'zgrpB');
s.addCondition('tag', 'is', 'zgrpC');
s.addCondition('groupEnd', 'true', '');
assert.sameMembers(await s.search(), [ab.id, ac.id]);
});
it("should match A OR (B AND C)", async function () {
var a = await createDataObject('item', { title: 'zg2A' });
var bc = await createDataObject('item', { title: 'zg2B', tags: [{ tag: 'zg2C' }] });
await createDataObject('item', { title: 'zg2B' });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('title', 'contains', 'zg2A');
s.addCondition('groupStart', 'true', '');
s.addCondition('joinMode', 'all');
s.addCondition('title', 'contains', 'zg2B');
s.addCondition('tag', 'is', 'zg2C');
s.addCondition('groupEnd', 'true', '');
assert.sameMembers(await s.search(), [a.id, bc.id]);
});
it("should match nested groups A AND (B OR (C AND D))", async function () {
var ab = await createDataObject('item', { title: 'zg3A', tags: [{ tag: 'zg3B' }] });
var acd = await createDataObject('item', { title: 'zg3A zg3C', tags: [{ tag: 'zg3D' }] });
await createDataObject('item', { title: 'zg3A zg3C' });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'all');
s.addCondition('title', 'contains', 'zg3A');
s.addCondition('groupStart', 'true', '');
s.addCondition('joinMode', 'any');
s.addCondition('tag', 'is', 'zg3B');
s.addCondition('groupStart', 'true', '');
s.addCondition('joinMode', 'all');
s.addCondition('title', 'contains', 'zg3C');
s.addCondition('tag', 'is', 'zg3D');
s.addCondition('groupEnd', 'true', '');
s.addCondition('groupEnd', 'true', '');
assert.sameMembers(await s.search(), [ab.id, acd.id]);
});
});
describe("Cross-level scope", function () {
it("should match a top-level item by a condition on a descendant annotation", async function () {
var text = 'zscopematch' + Zotero.Utilities.randomString();
// Author Smith, with a matching annotation on a child PDF -- should match
var item = await createDataObject('item', {
creators: [{ lastName: 'Zscopesmith', creatorType: 'author' }]
});
var attachment = await importPDFAttachment(item);
var annotation = await createAnnotation('highlight', attachment);
annotation.annotationText = text;
await annotation.saveTx();
// Author Smith, but no matching annotation -- shouldn't match
var noAnnotation = await createDataObject('item', {
creators: [{ lastName: 'Zscopesmith', creatorType: 'author' }]
});
// Matching annotation, but a different author -- shouldn't match
var other = await createDataObject('item', {
creators: [{ lastName: 'Zscopejones', creatorType: 'author' }]
});
var otherAttachment = await importPDFAttachment(other);
var otherAnnotation = await createAnnotation('highlight', otherAttachment);
otherAnnotation.annotationText = text;
await otherAnnotation.saveTx();
// creator is Smith AND (has an annotation whose text matches)
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('creator', 'contains', 'Zscopesmith');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationText', 'contains', text);
s.addCondition('groupEnd', 'true', '');
assert.sameMembers(await s.search(), [item.id]);
await item.eraseTx();
await noAnnotation.eraseTx();
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);
var highlight = await createAnnotation('highlight', attachment);
highlight.annotationColor = '#ff6666';
await highlight.saveTx();
var note = await createAnnotation('note', attachment);
note.annotationColor = '#5fb236';
await note.saveTx();
// Type 'is' returns only the matching annotation
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('annotationType', 'is',
Zotero.Annotations.ANNOTATION_TYPE_HIGHLIGHT.toString());
assert.sameMembers(await s.search(), [highlight.id]);
// Color 'is' returns only the matching annotation
s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('annotationColor', 'is', '#5fb236');
assert.sameMembers(await s.search(), [note.id]);
// 'isNot' returns the other annotation -- not the parent item, the
// attachment, or every item in the library
s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('annotationType', 'isNot',
Zotero.Annotations.ANNOTATION_TYPE_NOTE.toString());
var matches = await s.search();
assert.include(matches, highlight.id);
assert.notInclude(matches, note.id);
assert.notInclude(matches, item.id);
assert.notInclude(matches, attachment.id);
await item.eraseTx();
});
it("should return descendant annotations as results with a top-level condition", async function () {
var text = 'zresult' + Zotero.Utilities.randomString();
// Smith item with two matching and one non-matching annotation
var item = await createDataObject('item', {
creators: [{ lastName: 'Zresultsmith', creatorType: 'author' }]
});
var attachment = await importPDFAttachment(item);
var match1 = await createAnnotation('highlight', attachment);
match1.annotationText = text;
await match1.saveTx();
var match2 = await createAnnotation('highlight', attachment);
match2.annotationText = text;
await match2.saveTx();
await createAnnotation('highlight', attachment); // random text -- no match
// Different author with a matching annotation -- shouldn't match
var other = await createDataObject('item', {
creators: [{ lastName: 'Zresultjones', creatorType: 'author' }]
});
var otherAttachment = await importPDFAttachment(other);
var otherAnnotation = await createAnnotation('highlight', otherAttachment);
otherAnnotation.annotationText = text;
await otherAnnotation.saveTx();
// Result level annotations: the matching annotations on Smith's items
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'annotation');
s.addCondition('creator', 'contains', 'Zresultsmith');
s.addCondition('annotationText', 'contains', text);
assert.sameMembers(await s.search(), [match1.id, match2.id]);
await item.eraseTx();
await other.eraseTx();
});
it("should match annotations for a negated annotation condition at the annotation result level", async function () {
// At the annotation result level, a negated annotation condition matches
// the annotations that lack the value
var text = 'zneg' + Zotero.Utilities.randomString();
var item = await createDataObject('item');
var attachment = await importPDFAttachment(item);
var withText = await createAnnotation('highlight', attachment);
withText.annotationText = text;
await withText.saveTx();
var withoutText = await createAnnotation('highlight', attachment);
withoutText.annotationText = 'zsomethingelse';
await withoutText.saveTx();
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'annotation');
s.addCondition('annotationText', 'doesNotContain', text);
assert.sameMembers(await s.search(), [withoutText.id]);
await item.eraseTx();
});
it("should match an item in a collection by a child attachment's content", async function () {
// Collection (item-level) and Attachment Content (rolls up from the
// attachment) both resolve at the top-level item and intersect there
var collection = await createDataObject('collection');
// In the collection, with a child attachment whose content matches
var match = await createDataObject('item', { collections: [collection.id] });
await importFileAttachment("search/foobar.html", { parentID: match.id });
// In the collection, but no matching attachment content
var noContent = await createDataObject('item', { collections: [collection.id] });
// Has the matching content, but isn't in the collection
var notInCollection = await createDataObject('item');
await importFileAttachment("search/foobar.html", { parentID: notInCollection.id });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('collection', 'is', collection.key);
s.addCondition('fulltextContent', 'contains', 'foo bar');
assert.sameMembers(await s.search(), [match.id]);
await match.eraseTx();
await noContent.eraseTx();
await notInCollection.eraseTx();
});
it("should return a collection's matching descendants at a descendant result level", async function () {
// Collection (item-level) maps down to the annotation result level, so
// a matching annotation under a collection item is returned even though the
// top-level item doesn't itself match the annotation condition
var text = 'zcoll' + Zotero.Utilities.randomString();
var collection = await createDataObject('collection');
var item = await createDataObject('item', { collections: [collection.id] });
var attachment = await importPDFAttachment(item);
var match = await createAnnotation('highlight', attachment);
match.annotationText = text;
await match.saveTx();
// A matching annotation, but its item isn't in the collection
var other = await createDataObject('item');
var otherAttachment = await importPDFAttachment(other);
var otherAnnotation = await createAnnotation('highlight', otherAttachment);
otherAnnotation.annotationText = text;
await otherAnnotation.saveTx();
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'annotation');
s.addCondition('collection', 'is', collection.key);
s.addCondition('annotationText', 'contains', text);
assert.sameMembers(await s.search(), [match.id]);
await item.eraseTx();
await other.eraseTx();
});
it("should match an itemData field at the item or attachment level it lives at", async function () {
// title (like url/accessDate) exists on both top-level items and
// attachments, so it matches the right thing at each result level without
// rolling an attachment title up to its parent (or a parent title down)
var itemTitle = 'zti' + Zotero.Utilities.randomString();
var attTitle = 'zta' + Zotero.Utilities.randomString();
var item = await createDataObject('item', { title: itemTitle });
var attachment = await importPDFAttachment(item);
attachment.setField('title', attTitle);
await attachment.saveTx();
let search = (level, value) => {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', level);
s.addCondition('title', 'contains', value);
return s.search();
};
// Attachment result level matches the attachment's own title, not its parent's
assert.sameMembers(await search('attachment', attTitle), [attachment.id]);
assert.sameMembers(await search('attachment', itemTitle), []);
// Item result level matches the item's own title, and an attachment title
// does not roll up to the item (option C: no rollup)
assert.sameMembers(await search('item', itemTitle), [item.id]);
assert.sameMembers(await search('item', attTitle), []);
await item.eraseTx();
});
it("should match Any Field against an attachment's own fields at the attachment result level", async function () {
// 'Any Field' expands to the generic 'field' condition, which covers every
// searchable field, including the ones attachments have -- so it matches an
// attachment's own URL, not just its parent item's
var itemURL = 'https://example.com/zaf' + Zotero.Utilities.randomString();
var attURL = 'https://example.com/zaf' + Zotero.Utilities.randomString();
var item = await createDataObject('item');
item.setField('url', itemURL);
await item.saveTx();
var attachment = await importPDFAttachment(item);
attachment.setField('url', attURL);
await attachment.saveTx();
let search = (value) => {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'attachment');
s.addCondition('anyField', 'contains', value);
return s.search();
};
assert.sameMembers(await search(attURL), [attachment.id]);
// The parent item's URL isn't the attachment's, as with a single-field condition
assert.sameMembers(await search(itemURL), []);
await item.eraseTx();
});
it("should match an item by Any Field in a group at the attachment level", async function () {
// "top-level items matching, in the same attachment, Any Field contains X"
var url = 'https://example.com/zafg' + Zotero.Utilities.randomString();
var item = await createDataObject('item');
var attachment = await importPDFAttachment(item);
attachment.setField('url', url);
await attachment.saveTx();
var other = await createDataObject('item');
await importPDFAttachment(other);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'attachment');
s.addCondition('anyField', 'contains', url);
s.addCondition('groupEnd', 'true', '');
assert.sameMembers(await s.search(), [item.id]);
await item.eraseTx();
await other.eraseTx();
});
it("should map a bare descendant condition to the result level (no group)", async function () {
var text = 'zbarecorr' + Zotero.Utilities.randomString();
var item = await createDataObject('item', { title: 'zbarecorritem' });
var attachment = await importPDFAttachment(item);
var annotation = await createAnnotation('highlight', attachment);
annotation.annotationText = text;
await annotation.saveTx();
// Result level item + a plain annotation condition (no group) rolls up
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('annotationText', 'contains', text);
assert.sameMembers(await s.search(), [item.id]);
await item.eraseTx();
});
it("should match an item by its tag and a descendant annotation's comment", async function () {
// A cross-level search combining levels: a top-level item that itself has a
// given tag AND has a descendant annotation whose comment contains a word, returned
// at the item result level. The tag (level-agnostic) and the annotation
// comment (annotation level) both resolve at the item and intersect there.
var tag = 'ztag' + Zotero.Utilities.randomString();
var word = 'zword' + Zotero.Utilities.randomString();
// Tagged, with a descendant annotation whose comment contains the word
var item = await createDataObject('item', { tags: [{ tag }] });
var attachment = await importPDFAttachment(item);
await createAnnotation('highlight', attachment, { comment: 'foo ' + word + ' bar' });
// Tagged, but no matching annotation comment
var tagOnly = await createDataObject('item', { tags: [{ tag }] });
await importPDFAttachment(tagOnly);
// Has the matching annotation comment, but not the tag
var annotationOnly = await createDataObject('item');
var annAttachment = await importPDFAttachment(annotationOnly);
await createAnnotation('highlight', annAttachment, { comment: 'foo ' + word + ' bar' });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('tag', 'is', tag);
s.addCondition('annotationComment', 'contains', word);
assert.sameMembers(await s.search(), [item.id]);
await item.eraseTx();
await tagOnly.eraseTx();
await annotationOnly.eraseTx();
});
it("should map a bare full-text condition to the result level (no group)", async function () {
// A top-level full-text condition matches the item that owns the matching
// attachment, materializing and mapping to the result level
var item = await createDataObject('item', { title: 'zbareft' });
await importFileAttachment("search/foobar.html", { parentID: item.id });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('fulltextContent', 'contains', 'foo bar');
// The standalone foobarItem also matches and is itself a top-level item, so it's
// returned alongside the child attachment's parent
assert.sameMembers(await s.search(), [item.id, foobarItem.id]);
await item.eraseTx();
});
it("should return a standalone attachment whose annotation matches, at the item result level", async function () {
// A standalone (top-level) attachment is itself a top-level item, so its annotation
// maps up to the attachment's own id and the attachment is returned
var word = 'zsa' + Zotero.Utilities.randomString();
var standalone = await importPDFAttachment(); // top-level PDF, no parent
await createAnnotation('highlight', standalone, { comment: 'x ' + word + ' y' });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('annotationComment', 'contains', word);
assert.sameMembers(await s.search(), [standalone.id]);
await standalone.eraseTx();
});
it("should return a standalone attachment whose annotation has a tag, in a search for top-level items", async function () {
// Same as above, but for a level-agnostic condition (tag), which rolls up
// separately from a fixed-level one like annotationComment
var tag = 'zsat' + Zotero.Utilities.randomString();
var standalone = await importPDFAttachment(); // top-level PDF, no parent
var annotation = await createAnnotation('highlight', standalone);
annotation.addTag(tag);
await annotation.saveTx();
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('tag', 'is', tag);
assert.sameMembers(await s.search(), [standalone.id]);
await standalone.eraseTx();
});
it("should bind a same-entity group below a non-item result level", async function () {
// Result level attachment + a group scoped to annotation: find attachments
// that have a single annotation matching all of the group's conditions.
// Exercises mapping where the parent level is 'attachment', not 'item'.
var text = 'zsame' + Zotero.Utilities.randomString();
var comment = 'zsamec' + Zotero.Utilities.randomString();
var item = await createDataObject('item', { title: 'zsameitem' });
// One annotation matches both text and comment -> this attachment matches
var attachmentBoth = await importPDFAttachment(item);
var both = await createAnnotation('highlight', attachmentBoth, { comment });
both.annotationText = text;
await both.saveTx();
// Two separate annotations, one matching each -> shouldn't match
var attachmentSplit = await importPDFAttachment(item);
var hasText = await createAnnotation('highlight', attachmentSplit);
hasText.annotationText = text;
await hasText.saveTx();
await createAnnotation('highlight', attachmentSplit, { comment });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'attachment'); // result level = attachments
s.addCondition('groupStart', 'true', '');
s.addCondition('resultLevel', 'annotation'); // the same annotation
s.addCondition('annotationText', 'contains', text);
s.addCondition('annotationComment', 'contains', comment);
s.addCondition('groupEnd', 'true', '');
assert.sameMembers(await s.search(), [attachmentBoth.id]);
await item.eraseTx();
});
it("should roll a tag on a descendant up to the result item", async function () {
var tag = 'zroll' + Zotero.Utilities.randomString();
// Tagged child attachment (the item itself is untagged)
var viaChild = await createDataObject('item', { title: 'zrollchild' });
var attachment = await importPDFAttachment(viaChild);
attachment.addTag(tag);
await attachment.saveTx();
// Tagged directly
var viaSelf = await createDataObject('item', { title: 'zrollself', tags: [{ tag }] });
// Untagged
var untagged = await createDataObject('item', { title: 'zrollmiss' });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('tag', 'is', tag);
// The child's tag rolls up to its item; the directly tagged item matches too
assert.sameMembers(await s.search(), [viaChild.id, viaSelf.id]);
await viaChild.eraseTx();
await viaSelf.eraseTx();
await untagged.eraseTx();
});
it("should not propagate a tag down to descendant result items", async function () {
var tag = 'zdown' + Zotero.Utilities.randomString();
// The item is tagged, but its attachment is not
var item = await createDataObject('item', { title: 'zdownitem', tags: [{ tag }] });
await importPDFAttachment(item);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'attachment');
s.addCondition('tag', 'is', tag);
// Rollup is up-only: a parent's tag must not match its child attachment
assert.lengthOf(await s.search(), 0);
await item.eraseTx();
});
it("should not error when a condition can't reach the result level", async function () {
// A note can't be (or be under) an attachment, so this is empty -- but it
// must not throw a SQL param error: the dead predicate drops to the constant
// '0', and its bound value param must be dropped with it
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'attachment');
s.addCondition('joinMode', 'any');
s.addCondition('note', 'contains', 'zsqltest');
assert.lengthOf(await s.search(), 0);
// Same in 'all' mode
var s2 = new Zotero.Search();
s2.libraryID = userLibraryID;
s2.addCondition('resultLevel', 'attachment');
s2.addCondition('note', 'contains', 'zsqltest');
assert.lengthOf(await s2.search(), 0);
});
it("should project to all descendant annotations when there's no annotation condition", async function () {
// Result level annotations with only a top-level condition -> every
// annotation under matching items
var item = await createDataObject('item', {
creators: [{ lastName: 'Zprojsmith', creatorType: 'author' }]
});
var attachment = await importPDFAttachment(item);
var a1 = await createAnnotation('highlight', attachment);
var a2 = await createAnnotation('highlight', attachment);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'annotation');
s.addCondition('creator', 'contains', 'Zprojsmith');
assert.sameMembers(await s.search(), [a1.id, a2.id]);
await item.eraseTx();
});
});
describe("collection", function () {
it("should find item in collection", async function () {
var col = await createDataObject('collection');
var item = await createDataObject('item', { collections: [col.id] });
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('collection', 'is', col.key);
var matches = await s.search();
assert.sameMembers(matches, [item.id]);
});
it("should find items not in collection", async function () {
var col = await createDataObject('collection');
var item = await createDataObject('item', { collections: [col.id] });
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('collection', 'isNot', col.key);
var matches = await s.search();
assert.notInclude(matches, item.id);
});
it("shouldn't find item in collection with no items", async function () {
var col = await createDataObject('collection');
var item = await createDataObject('item');
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('collection', 'is', col.key);
var matches = await s.search();
assert.lengthOf(matches, 0);
});
it("should find item in subcollection in recursive mode", async function () {
var col1 = await createDataObject('collection');
var col2 = await createDataObject('collection', { parentID: col1.id });
var item = await createDataObject('item', { collections: [col2.id] });
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('collection', 'is', col1.key);
s.addCondition('recursive', 'true');
var matches = await s.search();
assert.sameMembers(matches, [item.id]);
});
it("should return no results for a collection that doesn't exist in recursive mode", async function () {
var item = await createDataObject('item');
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.name = "Test";
s.addCondition('collection', 'is', Zotero.DataObjectUtilities.generateKey());
s.addCondition('recursive', 'true');
var matches = await s.search();
assert.lengthOf(matches, 0);
});
it("should have same result after the same search conditions is removed and added", async function () {
var titleOne = 'zremadd' + Zotero.Utilities.randomString();
var titleTwo = 'zremadd' + Zotero.Utilities.randomString();
var itemOne = await createDataObject('item', { title: titleOne });
var itemTwo = await createDataObject('item', { title: titleTwo });
var s = new Zotero.Search();
s.libraryID = itemOne.libraryID;
s.addCondition("joinMode", "any");
// Match both items
s.addCondition('title', 'contains', titleOne);
s.addCondition('title', 'contains', titleTwo);
var matches = await s.search();
assert.sameMembers(matches, [itemOne.id, itemTwo.id]);
// Remove the first condition and add it again
s.removeCondition(1);
s.addCondition('title', 'contains', titleOne);
matches = await s.search();
// Result should be the same
assert.sameMembers(matches, [itemOne.id, itemTwo.id]);
await itemOne.eraseTx();
await itemTwo.eraseTx();
});
});
describe("tag", function () {
it("should match annotation with tag", async function () {
var attachment = await importPDFAttachment();
var annotation = await createAnnotation('highlight', attachment);
var tag = Zotero.Utilities.randomString();
annotation.addTag(tag);
await annotation.saveTx();
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('tag', 'is', tag);
var matches = await s.search();
assert.sameMembers(matches, [annotation.id]);
});
});
describe("numTags", function () {
it("should match by tag count", async function () {
var item1 = await createDataObject('item');
var item2 = await createDataObject('item');
item2.addTag(Zotero.Utilities.randomString());
item2.addTag(Zotero.Utilities.randomString());
await item2.saveTx();
var s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('numTags', 'is', '0');
var matches = await s.search();
assert.include(matches, item1.id);
assert.notInclude(matches, item2.id);
s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('numTags', 'is', '2');
matches = await s.search();
assert.include(matches, item2.id);
assert.notInclude(matches, item1.id);
s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('numTags', 'isGreaterThan', '1');
matches = await s.search();
assert.include(matches, item2.id);
assert.notInclude(matches, item1.id);
s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('numTags', 'isLessThan', '1');
matches = await s.search();
assert.include(matches, item1.id);
assert.notInclude(matches, item2.id);
});
});
describe("numNotes/numAttachments/numAnnotations", function () {
it("should match top-level items by child note and attachment counts", async function () {
var item1 = await createDataObject('item');
var item2 = await createDataObject('item');
var note = new Zotero.Item('note');
note.setNote('foo');
note.parentItemID = item2.id;
await note.saveTx();
var attachment = await importFileAttachment('test.png', { parentID: item2.id });
var s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('numNotes', 'is', '0');
var matches = await s.search();
assert.include(matches, item1.id);
assert.notInclude(matches, item2.id);
// Rows that can't have child notes aren't matched
assert.notInclude(matches, note.id);
assert.notInclude(matches, attachment.id);
s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('numNotes', 'is', '1');
matches = await s.search();
assert.include(matches, item2.id);
assert.notInclude(matches, item1.id);
s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('numAttachments', 'is', '1');
matches = await s.search();
assert.include(matches, item2.id);
assert.notInclude(matches, item1.id);
assert.notInclude(matches, attachment.id);
// A trashed note doesn't count
note.deleted = true;
await note.saveTx();
s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('numNotes', 'is', '0');
matches = await s.search();
assert.include(matches, item2.id);
});
it("should count annotations on an attachment and under a top-level item", async function () {
var item = await createDataObject('item');
var attachment = await importPDFAttachment(item);
var annotation = await createAnnotation('highlight', attachment);
var plainItem = await createDataObject('item');
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('numAnnotations', 'is', '1');
var matches = await s.search();
// The attachment's own count and the item's total both match
assert.include(matches, attachment.id);
assert.include(matches, item.id);
assert.notInclude(matches, plainItem.id);
assert.notInclude(matches, annotation.id);
s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('numAnnotations', 'is', '0');
matches = await s.search();
assert.include(matches, plainItem.id);
assert.notInclude(matches, item.id);
assert.notInclude(matches, attachment.id);
assert.notInclude(matches, annotation.id);
});
});
describe("is", function () {
it("should match a value in a different case", async function () {
var item = await createDataObject('item', { itemType: 'journalArticle' });
item.setField('publicationTitle', 'Review of Finance');
await item.saveTx();
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('publicationTitle', 'is', 'review of finance');
assert.sameMembers(await s.search(), [item.id]);
});
it("should match a value with different accents", async function () {
var item = await createDataObject('item', { itemType: 'journalArticle' });
item.setField('publicationTitle', 'Revue de Séance');
await item.saveTx();
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.addCondition('publicationTitle', 'is', 'revue de seance');
assert.sameMembers(await s.search(), [item.id]);
});
});
describe("isEmpty/isNotEmpty", function () {
it("should match items with an empty or non-empty text field", async function () {
var item1 = await createDataObject('item');
var item2 = await createDataObject('item');
item2.setField('place', 'Berlin');
await item2.saveTx();
var s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('place', 'isEmpty');
var matches = await s.search();
assert.include(matches, item1.id);
assert.notInclude(matches, item2.id);
s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('place', 'isNotEmpty');
matches = await s.search();
assert.include(matches, item2.id);
assert.notInclude(matches, item1.id);
});
it("should match items with an empty date field", async function () {
var item1 = await createDataObject('item');
var item2 = await createDataObject('item');
item2.setField('date', '2020-01-01');
await item2.saveTx();
var s = new Zotero.Search();
s.libraryID = item1.libraryID;
s.addCondition('date', 'isEmpty');
var matches = await s.search();
assert.include(matches, item1.id);
assert.notInclude(matches, item2.id);
});
});
describe("dateAdded", function () {
it("should handle 'today'", async function () {
var item = await createDataObject('item');
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.name = "Test";
s.addCondition('dateAdded', 'is', 'today');
var matches = await s.search();
assert.includeMembers(matches, [item.id]);
// Make sure 'yesterday' doesn't match
s = new Zotero.Search();
s.libraryID = item.libraryID;
s.name = "Test";
s.addCondition('dateAdded', 'is', 'yesterday');
matches = await s.search();
assert.lengthOf(matches, 0);
});
});
describe("date-type item fields", function () {
it("should compare dates and still accept text operators", async function () {
let item1 = await createDataObject('item', { itemType: 'patent' });
item1.setField('filingDate', '2019-06-08');
await item1.saveTx();
let item2 = await createDataObject('item', { itemType: 'patent' });
item2.setField('filingDate', '2021-01-15');
await item2.saveTx();
// A value with no parsable year has nothing to compare
let item3 = await createDataObject('item', { itemType: 'patent' });
item3.setField('filingDate', 'Foo');
await item3.saveTx();
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('filingDate', 'isBefore', '2020');
let matches = await s.search();
assert.include(matches, item1.id);
assert.notInclude(matches, item2.id);
assert.notInclude(matches, item3.id);
// A text operator, as in an existing saved search
s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('filingDate', 'contains', '2021');
matches = await s.search();
assert.include(matches, item2.id);
assert.notInclude(matches, item1.id);
});
});
describe("fileTypeID", function () {
it("should search by attachment file type", async function () {
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('fileTypeID', 'is', Zotero.FileTypes.getID('webpage'));
let matches = await s.search();
assert.sameMembers(matches, [fooItem.id, foobarItem.id]);
});
});
describe("attachmentStorageType", function () {
it("should find stored files", async function () {
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('attachmentStorageType', 'is', 'storedFile');
let matches = await s.search();
assert.includeMembers(matches, [
fooItem.id,
foobarItem.id,
bazItem.id,
importedURLItem.id
]);
assert.notInclude(matches, linkedFileItem.id);
assert.notInclude(matches, linkedURLItem.id);
});
it("should find linked files", async function () {
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('attachmentStorageType', 'is', 'linkedFile');
let matches = await s.search();
assert.include(matches, linkedFileItem.id);
assert.notInclude(matches, bazItem.id);
assert.notInclude(matches, linkedURLItem.id);
});
it("should find web links", async function () {
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('attachmentStorageType', 'is', 'webLink');
let matches = await s.search();
assert.include(matches, linkedURLItem.id);
assert.notInclude(matches, bazItem.id);
assert.notInclude(matches, linkedFileItem.id);
});
it("should match a top-level item via a child attachment", async function () {
let item = await createDataObject('item');
let attachment = await importPDFAttachment(item);
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('attachmentStorageType', 'is', 'storedFile');
let matches = await s.search();
assert.include(matches, item.id);
assert.notInclude(matches, attachment.id);
await item.eraseTx();
});
});
describe("lastRead", function () {
it("should roll a child attachment's last-read date up to its top-level item", async function () {
var item = await createDataObject('item', { title: 'zlastread' });
var attachment = await importPDFAttachment(item);
attachment.attachmentLastRead = Math.round(Date.now() / 1000);
await attachment.saveTx();
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('resultLevel', 'item');
s.addCondition('lastRead', 'isInTheLast', '1 days');
assert.sameMembers(await s.search(), [item.id]);
await item.eraseTx();
});
});
describe("fulltextContent", function () {
it("should find text in HTML files", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('fulltextContent', 'contains', 'foo bar');
var matches = await s.search();
assert.sameMembers(matches, [foobarItem.id]);
});
it("should work in subsearch", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('fulltextContent', 'contains', 'foo bar');
var s2 = new Zotero.Search();
s2.setScope(s);
s2.addCondition('title', 'contains', 'foobar');
var matches = await s2.search();
assert.sameMembers(matches, [foobarItem.id]);
});
it("should find matching items with joinMode=ANY with no other conditions", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent', 'contains', 'foo');
s.addCondition('fulltextContent', 'contains', 'bar');
var matches = await s.search();
assert.sameMembers(matches, [fooItem.id, foobarItem.id]);
});
it("should find matching items with joinMode=ANY and non-matching other condition", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent', 'contains', 'foo');
s.addCondition('fulltextContent', 'contains', 'bar');
s.addCondition('title', 'contains', 'nomatch');
var matches = await s.search();
assert.sameMembers(matches, [fooItem.id, foobarItem.id]);
});
it("should find matching items in regexp mode with joinMode=ANY with matching other condition", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent/regexp', 'contains', 'foo.+bar');
s.addCondition('title', 'is', fooItem.getField('title'));
var matches = await s.search();
assert.sameMembers(matches, [fooItem.id, foobarItem.id]);
});
it("should find matching item in regexp mode with joinMode=ANY and non-matching other condition", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent/regexp', 'contains', 'foo.+bar');
s.addCondition('title', 'contains', 'nomatch');
var matches = await s.search();
assert.sameMembers(matches, [foobarItem.id]);
});
it("should find item matching other condition in regexp mode when joinMode=ANY", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent/regexp', 'contains', 'nomatch');
s.addCondition('title', 'is', foobarItem.getField('title'));
var matches = await s.search();
assert.sameMembers(matches, [foobarItem.id]);
});
it("should find matching item in regexp mode with joinMode=ANY and recursive mode flag", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent/regexp', 'contains', 'foo.+bar');
s.addCondition('recursive', 'true');
var matches = await s.search();
assert.sameMembers(matches, [foobarItem.id]);
});
it("should find items that don't contain a single word with joinMode=ANY", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent', 'doesNotContain', 'foo');
var matches = await s.search();
assert.notIncludeMembers(matches, [fooItem.id, foobarItem.id]);
});
it("should find items that don't contain a phrase with joinMode=ANY", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent', 'doesNotContain', 'foo bar');
var matches = await s.search();
assert.notIncludeMembers(matches, [foobarItem.id]);
});
it("should find items that don't contain a regexp pattern with joinMode=ANY", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent/regexp', 'doesNotContain', 'foo.+bar');
var matches = await s.search();
assert.notIncludeMembers(matches, [foobarItem.id]);
assert.includeMembers(matches, [fooItem.id, bazItem.id]);
});
// fulltextContent inside a group must combine with its siblings under the
// group's own join mode, not the search's top-level mode (it used to be
// applied as a global post-filter keyed on the top-level join mode)
it("should obey the group join mode for a grouped fulltextContent", async function () {
// OR: (title is foo.html OR full-text "foo bar") matches the title item and the text item
var orSearch = new Zotero.Search();
orSearch.libraryID = userLibraryID;
orSearch.addCondition('groupStart', 'true', '');
orSearch.addCondition('joinMode', 'any');
orSearch.addCondition('title', 'is', fooItem.getField('title'));
orSearch.addCondition('fulltextContent', 'contains', 'foo bar');
orSearch.addCondition('groupEnd', 'true', '');
assert.sameMembers(await orSearch.search(), [fooItem.id, foobarItem.id]);
// AND: title is foo.html OR (title is foobar.html AND full-text "nomatchphrase").
// The group's full-text doesn't match, so only fooItem (the OR branch) matches.
var andSearch = new Zotero.Search();
andSearch.libraryID = userLibraryID;
andSearch.addCondition('joinMode', 'any');
andSearch.addCondition('title', 'is', fooItem.getField('title'));
andSearch.addCondition('groupStart', 'true', '');
andSearch.addCondition('joinMode', 'all');
andSearch.addCondition('title', 'is', foobarItem.getField('title'));
andSearch.addCondition('fulltextContent', 'contains', 'nomatchphrase');
andSearch.addCondition('groupEnd', 'true', '');
assert.sameMembers(await andSearch.search(), [fooItem.id]);
});
it("should compose a grouped fulltextContent in doesNotContain and regexp modes", async function () {
// doesNotContain: (full-text doesNotContain "foo" AND title is baz.pdf) -> bazItem
var neg = new Zotero.Search();
neg.libraryID = userLibraryID;
neg.addCondition('groupStart', 'true', '');
neg.addCondition('joinMode', 'all');
neg.addCondition('fulltextContent', 'doesNotContain', 'foo');
neg.addCondition('title', 'is', bazItem.getField('title'));
neg.addCondition('groupEnd', 'true', '');
assert.sameMembers(await neg.search(), [bazItem.id]);
// regexp: (title is foo.html OR full-text regexp "foo.+bar") -> both
var re = new Zotero.Search();
re.libraryID = userLibraryID;
re.addCondition('groupStart', 'true', '');
re.addCondition('joinMode', 'any');
re.addCondition('title', 'is', fooItem.getField('title'));
re.addCondition('fulltextContent/regexp', 'contains', 'foo.+bar');
re.addCondition('groupEnd', 'true', '');
assert.sameMembers(await re.search(), [fooItem.id, foobarItem.id]);
});
});
describe("annotationText", function () {
it("should return matches for annotation text", async function () {
var attachment = await importPDFAttachment();
var annotation = await createAnnotation('highlight', attachment);
var str = annotation.annotationText.substr(0, 7);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('annotationText', 'contains', str);
var matches = await s.search();
assert.sameMembers(matches, [annotation.id]);
});
});
describe("annotationComment", function () {
it("should return matches for annotation comment", async function () {
var attachment = await importPDFAttachment();
var annotation = await createAnnotation('note', attachment);
var str = annotation.annotationComment.substr(0, 7);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('annotationComment', 'contains', str);
var matches = await s.search();
assert.sameMembers(matches, [annotation.id]);
});
});
describe("includeParentsAndChildren", function () {
it("should handle ANY search with no-op condition", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.name = "Test";
s.addCondition('joinMode', 'any');
s.addCondition('savedSearch', 'is', Zotero.Utilities.randomString());
s.addCondition('includeParentsAndChildren', 'true');
var matches = await s.search();
assert.lengthOf(matches, 0);
});
it("should handle ANY search with two no-op conditions", async function () {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.name = "Test";
s.addCondition('joinMode', 'any');
s.addCondition('savedSearch', 'is', Zotero.Utilities.randomString());
s.addCondition('savedSearch', 'is', Zotero.Utilities.randomString());
s.addCondition('includeParentsAndChildren', 'true');
var matches = await s.search();
assert.lengthOf(matches, 0);
});
it("should include a match's parents and children", async function () {
var itemTitle = 'zincp' + Zotero.Utilities.randomString();
var attTitle = 'zinca' + Zotero.Utilities.randomString();
var item = await createDataObject('item', { title: itemTitle });
var attachment = await importPDFAttachment(item);
attachment.setField('title', attTitle);
await attachment.saveTx();
let run = (value) => {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('title', 'is', value);
s.addCondition('includeParentsAndChildren', 'true');
return s.search();
};
// Matching the parent pulls in its child, and matching the child its parent
assert.sameMembers(await run(itemTitle), [item.id, attachment.id]);
assert.sameMembers(await run(attTitle), [item.id, attachment.id]);
await item.eraseTx();
});
});
describe("noChildren", function () {
it("should keep only top-level items", async function () {
var title = 'znochild' + Zotero.Utilities.randomString();
var item = await createDataObject('item', { title });
var attachment = await importPDFAttachment(item);
attachment.setField('title', title);
await attachment.saveTx();
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('title', 'contains', title);
s.addCondition('noChildren', 'true');
// The child attachment matches the title too, but noChildren excludes it
assert.sameMembers(await s.search(), [item.id]);
await item.eraseTx();
});
});
describe("key", function () {
it("should allow more than max bound parameters", async function () {
let s = new Zotero.Search();
let max = Zotero.DB.MAX_BOUND_PARAMETERS + 100;
for (let i = 0; i < max; i++) {
s.addCondition('key', 'is', Zotero.DataObjectUtilities.generateKey());
}
await s.search();
});
});
describe("anyField", function () {
it("should expand an 'any field' within its own group", async function () {
// (anyField contains "znestfoo" OR title contains "zzznomatch") -> the
// item matches via Any Field. If the Any Field expansion leaked to the
// top level (ANDed) instead of staying in this OR-group, the
// non-matching title would exclude it.
var item = await createDataObject('item', { title: "znestfoo" });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('groupStart', 'true', '');
s.addCondition('joinMode', 'any');
s.addCondition('anyField', 'contains', "znestfoo");
s.addCondition('title', 'contains', "zzznomatch");
s.addCondition('groupEnd', 'true', '');
var matches = await s.search();
assert.includeMembers(matches, [item.id]);
await item.eraseTx();
});
it("should return matches for multiple 'any field' conditions with joinMode=any", async function () {
var titleOne = 'zanyj' + Zotero.Utilities.randomString();
var titleTwo = 'zanyk' + Zotero.Utilities.randomString();
var itemOne = await createDataObject('item', { title: titleOne });
var itemTwo = await createDataObject('item', { title: titleTwo });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('anyField', 'contains', titleOne);
s.addCondition('anyField', 'contains', titleTwo);
var matches = await s.search();
assert.sameMembers(matches, [itemOne.id, itemTwo.id]);
});
it("should return matches for 'any field' and title condition with joinMode=any", async function () {
var titleOne = 'zanyt' + Zotero.Utilities.randomString();
var titleTwo = 'zanyu' + Zotero.Utilities.randomString();
var itemOne = await createDataObject('item', { title: titleOne });
var itemTwo = await createDataObject('item', { title: titleTwo });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('joinMode', 'any');
s.addCondition('anyField', 'contains', titleOne);
s.addCondition('title', 'contains', titleTwo);
var matches = await s.search();
assert.sameMembers(matches, [itemOne.id, itemTwo.id]);
});
it("should return matches for a single 'any field' condition", async function () {
var itemOne = await createDataObject('item', { title: 'zanys' + Zotero.Utilities.randomString() });
var itemTwo = await createDataObject('item');
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('anyField', 'contains', itemOne.getDisplayTitle());
var matches = await s.search();
assert.sameMembers(matches, [itemOne.id]);
});
it("should return matches for two 'any field' condition with joinMode=all", async function () {
var wordOne = 'zanyv' + Zotero.Utilities.randomString();
var wordTwo = 'zanyw' + Zotero.Utilities.randomString();
var itemOne = await createDataObject('item', { title: wordOne + '-' + wordTwo });
var itemTwo = await createDataObject('item', { title: wordTwo + '-' + wordOne });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('anyField', 'contains', wordOne);
s.addCondition('anyField', 'contains', wordTwo);
s.addCondition('joinMode', 'all');
var matches = await s.search();
assert.sameMembers(matches, [itemOne.id, itemTwo.id]);
});
it("should return matches for annotation text", async function () {
var attachment = await importPDFAttachment();
var annotation = await createAnnotation('highlight', attachment);
var str = annotation.annotationText.substr(0, 7);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('anyField', 'contains', str);
var matches = await s.search();
assert.include(matches, annotation.id);
});
it("should return matches for annotation comment", async function () {
var attachment = await importPDFAttachment();
var annotation = await createAnnotation('note', attachment);
var str = annotation.annotationComment.substr(0, 7);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('anyField', 'contains', str);
var matches = await s.search();
assert.include(matches, annotation.id);
});
});
describe("titleCreatorYear", function () {
it("should match title, creator, and year but not other fields", async function () {
var word = 'ztcy' + Zotero.Utilities.randomString();
// Matches via title
var byTitle = await createDataObject('item', { title: 'a ' + word + ' b' });
// Matches via creator
var byCreator = await createDataObject('item', {
creators: [{ creatorType: 'author', lastName: word, firstName: 'X' }]
});
// Has the word only in a field outside the title/creator/year set
var byOther = await createDataObject('item');
byOther.setField('abstractNote', 'a ' + word + ' b');
await byOther.saveTx();
var s = new Zotero.Search();
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]);
});
});
describe("savedSearch", function () {
it("should return items in the saved search", async function () {
var search = await createDataObject('search');
var itemTitle = search.getConditions()[0].value;
var item = await createDataObject('item', { title: itemTitle })
var s = new Zotero.Search;
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('savedSearch', 'is', search.key);
var matches = await s.search();
assert.deepEqual(matches, [item.id]);
});
it("should return items not in the saved search for isNot operator", async function () {
var search = await createDataObject('search');
var itemTitle = search.getConditions()[0].value;
var item = await createDataObject('item', { title: itemTitle })
var s = new Zotero.Search;
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('savedSearch', 'isNot', search.key);
var matches = await s.search();
assert.notInclude(matches, item.id);
});
it("should return no results for a search that doesn't exist", async function () {
var item = await createDataObject('item');
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.name = "Test";
s.addCondition('savedSearch', 'is', Zotero.DataObjectUtilities.generateKey());
var matches = await s.search();
assert.lengthOf(matches, 0);
});
});
describe("unfiled", function () {
it("shouldn't include items in My Publications", async function () {
var item1 = await createDataObject('item');
var item2 = await createDataObject('item', { inPublications: true });
var s = new Zotero.Search;
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('unfiled', 'true');
var matches = await s.search();
assert.include(matches, item1.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("Accent-insensitive matching", function () {
it("should match accented and unaccented text interchangeably", async function () {
// Unaccented term against accented text
var item = await createDataObject('item', { title: 'zdiaséance', libraryID: userLibraryID });
var s1 = new Zotero.Search();
s1.libraryID = userLibraryID;
s1.addCondition('title', 'contains', 'zdiaseance');
assert.sameMembers(await s1.search(), [item.id]);
// Accented term against unaccented text
var item2 = await createDataObject('item', { title: 'zdiaresume', libraryID: userLibraryID });
var s2 = new Zotero.Search();
s2.libraryID = userLibraryID;
s2.addCondition('title', 'contains', 'zdiarésumé');
assert.sameMembers(await s2.search(), [item2.id]);
});
it("should match an accented creator from an unaccented term", async function () {
var item = await createDataObject('item', {
libraryID: userLibraryID,
creators: [{ firstName: 'Étiennezdia', lastName: 'Müllerzdia', creatorType: 'author' }]
});
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('creator', 'contains', 'etiennezdia mullerzdia');
assert.sameMembers(await s.search(), [item.id]);
});
it("should match an accented tag from an unaccented term", async function () {
var item = await createDataObject('item', {
libraryID: userLibraryID,
tags: [{ tag: 'résumézdia' }]
});
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('tag', 'contains', 'resumezdia');
assert.sameMembers(await s.search(), [item.id]);
});
it("should match non-ASCII text case-insensitively", async function () {
// SQLite's default LIKE folds only ASCII case, so uppercase Greek/Cyrillic values
// have to be stored normalized (lowercased) to match a lowercase search term
var greek = await createDataObject('item', { title: 'zdiaΘΕΜΑ', libraryID: userLibraryID });
var cyrillic = await createDataObject('item', { title: 'zdiaПРИВЕТ', libraryID: userLibraryID });
var s1 = new Zotero.Search();
s1.libraryID = userLibraryID;
s1.addCondition('title', 'contains', 'zdiaθεμα');
assert.sameMembers(await s1.search(), [greek.id]);
var s2 = new Zotero.Search();
s2.libraryID = userLibraryID;
s2.addCondition('title', 'contains', 'zdiaпривет');
assert.sameMembers(await s2.search(), [cyrillic.id]);
});
it("should match ligatures, superscripts, fractions, and non-decomposing letters", async function () {
var item = await createDataObject('item', { title: 'zdiasøren œuvre file x² ½ straße', libraryID: userLibraryID });
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('title', 'contains', 'zdiasoren oeuvre file x2 1/2 strasse');
assert.sameMembers(await s.search(), [item.id]);
});
it("should ignore rich-text formatting tags in item fields", async function () {
// A word wrapped in supported formatting (here small-caps) -- the tags shouldn't
// break a phrase match across the word or be matchable themselves
var item = await createDataObject('item', {
title: 'The <span style="font-variant:small-caps;">zdiadrosophila</span> genome',
libraryID: userLibraryID
});
async function titleMatches(term) {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('title', 'contains', term);
return s.search();
}
// A phrase running from the formatted word into the next one matches
assert.sameMembers(await titleMatches('zdiadrosophila genome'), [item.id]);
// The tag markup itself isn't matched
assert.lengthOf(await titleMatches('small-caps'), 0);
});
it("should fold curly quotes and dashes to their ASCII forms", async function () {
// Word processors and translators produce curly quotes and en/em dashes; a
// search typed with straight quotes and hyphens should still match
var quoted = await createDataObject('item', {
title: 'zdia“pp. 1012”', // curly quotes + en dash: "pp. 10-12"
libraryID: userLibraryID
});
var emDash = await createDataObject('item', {
title: 'zdiamind—body problem', // em dash
libraryID: userLibraryID
});
async function titleMatches(term) {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('title', 'contains', term);
return s.search();
}
// Straight quotes and hyphen match the curly quotes and en dash
assert.sameMembers(await titleMatches('zdia"pp. 10-12"'), [quoted.id]);
// Em dash folds to a hyphen too
assert.sameMembers(await titleMatches('zdiamind-body'), [emDash.id]);
});
it("should distinguish voiced kana and composed Hangul", async function () {
// NFKD decomposes が into か + a combining mark and 한 into jamo; without
// recomposition, the base character would substring-match the composed one
var ja = await createDataObject('item', { title: 'zdiaがん', libraryID: userLibraryID });
var ko = await createDataObject('item', { title: 'zdia한국', libraryID: userLibraryID });
async function titleMatches(term) {
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('title', 'contains', term);
return s.search();
}
// The composed character matches itself
assert.sameMembers(await titleMatches('zdiaがん'), [ja.id]);
assert.sameMembers(await titleMatches('zdia한국'), [ko.id]);
// The base character must not match the composed/voiced one
assert.lengthOf(await titleMatches('zdiaかん'), 0);
assert.lengthOf(await titleMatches('zdia하'), 0);
});
it("should populate normalized columns for existing rows on backfill", async function () {
var item = await createDataObject('item', { title: 'zdiabörkbackfill', libraryID: userLibraryID });
// Simulate pre-migration data by clearing the normalized column
await Zotero.DB.queryAsync(
"UPDATE itemDataValues SET valueNormalized=NULL WHERE value=?",
'zdiabörkbackfill'
);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('title', 'contains', 'zdiaborkbackfill');
assert.lengthOf(await s.search(), 0);
await Zotero.DB.queryAsync("REPLACE INTO settings VALUES ('search', 'normalizeBackfill', 1)");
await Zotero.Schema.populateNormalizedSearchColumns();
assert.sameMembers(await s.search(), [item.id]);
});
});
describe("Quick search", function () {
describe("All Fields & Tags", function () {
it("should match annotation for tag search", async function () {
var attachment = await importPDFAttachment();
var annotation = await createAnnotation('highlight', attachment);
var tag = Zotero.Utilities.randomString();
annotation.addTag(tag);
await annotation.saveTx();
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('quicksearch-fields', 'contains', tag);
var matches = await s.search();
assert.sameMembers(matches, [annotation.id]);
});
it("should match annotation of top-level attachment within scope", async function () {
// Create collection with an attachment
var collection = await createDataObject('collection');
var attachment = await importPDFAttachment();
attachment.addToCollection(collection.id);
await attachment.saveTx();
// Add annotation with a tag to that attachment
var annotation = await createAnnotation('highlight', attachment);
var tag = Zotero.Utilities.randomString();
annotation.addTag(tag);
await annotation.saveTx();
// Search within the scope of that collection by tag
var scope = new Zotero.Search();
scope.libraryID = userLibraryID;
scope.addCondition('noChildren', 'true');
scope.addCondition('collectionID', 'is', collection.id);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('quicksearch-fields', 'contains', tag);
s.setScope(scope, true);
// Expect child annotation to be found
var matches = await s.search();
assert.sameMembers(matches, [annotation.id]);
});
it("should match annotation of a child attachment of an item within scope", async function () {
// Create collection with a top-level item and an attachment
var collection = await createDataObject('collection');
var item = await createDataObject('item');
var attachment = await importPDFAttachment(item);
item.addToCollection(collection.id);
await item.saveTx();
// Add annotation with a tag to the attachment
var annotation = await createAnnotation('highlight', attachment);
var tag = Zotero.Utilities.randomString();
annotation.addTag(tag);
await annotation.saveTx();
// Search within the scope of that collection by tag
var scope = new Zotero.Search();
scope.libraryID = userLibraryID;
scope.addCondition('noChildren', 'true');
scope.addCondition('collectionID', 'is', collection.id);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('quicksearch-fields', 'contains', tag);
s.setScope(scope, true);
// Expect child annotation to be found
var matches = await s.search();
assert.sameMembers(matches, [annotation.id]);
});
})
describe("Everything", function () {
it("should match parent attachment for annotation comment", async function () {
var attachment = await importPDFAttachment();
var annotation = await createAnnotation('highlight', attachment);
var comment = annotation.annotationComment;
var s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('quicksearch-everything', 'contains', comment);
var matches = await s.search();
assert.sameMembers(matches, [annotation.id]);
});
it("should not include items outside of scope during phrase search", async function () {
var col = await createDataObject('collection');
fooItem.addToCollection(col.id);
await fooItem.saveTx();
// Quicksearch from a collection
let collectionScope = new Zotero.Search();
collectionScope.libraryID = userLibraryID;
collectionScope.addCondition('noChildren', 'true');
collectionScope.addCondition('collectionID', 'is', col.id);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
// Phrase search
s.addCondition('quicksearch-everything', 'contains', '"foo"');
s.setScope(collectionScope, true);
var matches = await s.search();
// Only the item from the collection is returned
assert.equal(matches.length, 1);
assert.equal(matches[0], fooItem.id);
});
async function importTextContent(content) {
let path = OS.Path.join(await getTempDirectory(), Zotero.Utilities.randomString() + ".txt");
await Zotero.File.putContentsAsync(path, content);
return Zotero.Attachments.importFromFile({
file: path, contentType: 'text/plain', charset: 'utf-8', title: 'xxxx'
});
}
it("should match full-text content by word prefix", async function () {
let item = await importTextContent("zqs electrophoresis protocol");
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('quicksearch-everything', 'contains', 'electro');
assert.include(await s.search(), item.id);
});
it("should match unquoted words as prefixes anywhere in the content", async function () {
let item = await importTextContent("the climate of the region has seen many changes");
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('quicksearch-everything', 'contains', 'clim chang');
assert.include(await s.search(), item.id);
});
it("should require every word of a multi-word search", async function () {
let both = await importTextContent("zqsalpha zqsbeta zqsgamma");
let one = await importTextContent("zqsalpha zqsdelta");
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('quicksearch-everything', 'contains', 'zqsalpha zqsgamma');
let matches = await s.search();
assert.include(matches, both.id);
assert.notInclude(matches, one.id);
});
it("should skip content matching for a term too short for the index", async function () {
let item = await importTextContent("elephant");
let s = new Zotero.Search();
s.libraryID = userLibraryID;
// A 1-2-character unquoted term isn't matched against content (avoiding
// per-keystroke churn); the title is "xxxx", so nothing matches
s.addCondition('quicksearch-everything', 'contains', 'el');
assert.notInclude(await s.search(), item.id);
});
it("should skip note matching for a short term but honor a quoted one", async function () {
let note = new Zotero.Item('note');
note.libraryID = userLibraryID;
note.setNote('<p>zqsnote re content</p>');
await note.saveTx();
await Zotero.FullText.processNoteIndexQueue();
async function matches(term) {
let s = new Zotero.Search();
s.libraryID = userLibraryID;
s.addCondition('quicksearch-everything', 'contains', term);
return s.search();
}
// A long term matches the note's content
assert.include(await matches('zqsnote'), note.id);
// "re" is too short for the index, so an unquoted term doesn't scan note
// content (avoiding a per-keystroke scan) and nothing else matches it
assert.notInclude(await matches('re'), note.id);
// Quoting makes the quick search Enter-triggered, so the short term is matched
assert.include(await matches('"re"'), note.id);
});
});
});
describe("deleted", function () {
describe("if not present", function () {
it("should not match regular items in trash with annotated child attachments", async function () {
var item = await createDataObject('item');
item.deleted = true;
await item.saveTx();
var attachment = await importPDFAttachment(item);
await createAnnotation('highlight', attachment);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
var matches = await s.search();
assert.notInclude(matches, attachment.id);
});
it("should not match regular items with annotated child attachments in trash", async function () {
var item = await createDataObject('item');
var attachment = await importPDFAttachment(item);
attachment.deleted = true;
await attachment.saveTx();
await createAnnotation('highlight', attachment);
var s = new Zotero.Search();
s.libraryID = userLibraryID;
var matches = await s.search();
assert.notInclude(matches, attachment.id);
});
});
});
});
});
describe("#deleted", function () {
it("should set trash status", async function () {
var search = await createDataObject('search');
assert.isFalse(search.deleted);
search.deleted = true;
await search.saveTx();
assert.isTrue(search.deleted);
search.deleted = false;
await search.saveTx();
assert.isFalse(search.deleted);
});
it("should permanently delete", async function () {
var search = await createDataObject('search');
assert.isFalse(search.deleted);
await search.eraseTx();
search = await Zotero.Searches.getAsync(search.id);
assert.isFalse(search);
});
});
describe("#toJSON()", function () {
it("should output all data", async function () {
let s = new Zotero.Search();
s.name = "Test";
s.addCondition('joinMode', 'any');
s.addCondition('fulltextContent/regexp', 'contains', 's.+');
let json = s.toJSON();
assert.equal(json.name, "Test");
assert.lengthOf(json.conditions, 2);
assert.equal(json.conditions[0].condition, 'joinMode');
assert.equal(json.conditions[0].operator, 'any');
// TODO: Change to 'is' + 'any'?
assert.strictEqual(json.conditions[0].value, '');
assert.notProperty(json.conditions[0], 'id');
assert.notProperty(json.conditions[0], 'required');
assert.notProperty(json.conditions[0], 'mode');
assert.equal(json.conditions[1].condition, 'fulltextContent/regexp');
assert.equal(json.conditions[1].operator, 'contains');
assert.equal(json.conditions[1].value, 's.+');
assert.notProperty(json.conditions[1], 'mode');
});
});
describe("#fromJSON()", function () {
it("should migrate a `childNote` condition to `note` at the item level", async function () {
var text = 'zjsoncn' + Zotero.Utilities.randomString();
var item = await createDataObject('item', { title: 'zjsoncnitem' });
var note = new Zotero.Item('note');
note.libraryID = item.libraryID;
note.parentID = item.id;
note.setNote('<p>' + text + '</p>');
await note.saveTx();
var s = new Zotero.Search();
s.libraryID = item.libraryID;
s.fromJSON({
name: 'Test',
conditions: [{ condition: 'childNote', operator: 'contains', value: text }]
});
var conds = Object.values(s.getConditions());
assert.include(conds.map(c => c.condition), 'note');
assert.notInclude(conds.map(c => c.condition), 'childNote');
var resultLevel = conds.find(c => c.condition == 'resultLevel');
assert.ok(resultLevel);
assert.equal(resultLevel.operator, 'item');
assert.sameMembers(await s.search(), [item.id]);
await item.eraseTx();
});
it("should update all data", async function () {
let s = new Zotero.Search();
s.name = "Test";
s.addCondition('joinMode', 'any');
s.addCondition('title', 'isNot', 'foo');
let json = s.toJSON();
json.name = "Test 2";
json.conditions = [
{
condition: 'title',
operator: 'contains',
value: 'foo'
},
{
condition: 'year',
operator: 'is',
value: '2016'
}
];
s.fromJSON(json);
assert.equal(s.name, "Test 2");
var conditions = s.getConditions();
assert.lengthOf(Object.keys(conditions), 2);
assert.equal(conditions["0"].condition, 'title');
assert.equal(conditions["0"].operator, 'contains');
assert.equal(conditions["0"].value, 'foo');
assert.equal(conditions["1"].condition, 'year');
assert.equal(conditions["1"].operator, 'is');
assert.equal(conditions["1"].value, '2016');
});
it("should ignore unknown property in non-strict mode", function () {
var json = {
name: "Search",
conditions: [
{
condition: 'title',
operator: 'contains',
value: 'foo'
}
],
foo: "Bar"
};
var s = new Zotero.Search();
s.fromJSON(json);
});
it("should throw on unknown property in strict mode", function () {
var json = {
name: "Search",
conditions: [
{
condition: 'title',
operator: 'contains',
value: 'foo'
}
],
foo: "Bar"
};
var s = new Zotero.Search();
var f = () => {
s.fromJSON(json, { strict: true });
};
assert.throws(f, /^Unknown search property/);
});
});
});