diff --git a/chrome/content/zotero/customElements.js b/chrome/content/zotero/customElements.js
index 59b5b319bc..0730aaa214 100644
--- a/chrome/content/zotero/customElements.js
+++ b/chrome/content/zotero/customElements.js
@@ -51,6 +51,7 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['note-box', 'chrome://zotero/content/elements/noteBox.js'],
['note-editor', 'chrome://zotero/content/elements/noteEditor.js'],
['notes-box', 'chrome://zotero/content/elements/notesBox.js'],
+ ['query-textbox', 'chrome://zotero/content/elements/queryTextbox.js'],
['quick-search-textbox', 'chrome://zotero/content/elements/quickSearchTextbox.js'],
['related-box', 'chrome://zotero/content/elements/relatedBox.js'],
['search-textbox', 'chrome://zotero/content/elements/searchTextbox.js'],
diff --git a/chrome/content/zotero/elements/queryTextbox.js b/chrome/content/zotero/elements/queryTextbox.js
new file mode 100644
index 0000000000..e4a575d9b5
--- /dev/null
+++ b/chrome/content/zotero/elements/queryTextbox.js
@@ -0,0 +1,385 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2026 Corporation for Digital Scholarship
+ Vienna, Virginia, USA
+ https://www.zotero.org
+
+ This file is part of Zotero.
+
+ Zotero is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Zotero is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with Zotero. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+"use strict";
+
+{
+ // Enough of the library's tags or creators to scroll through without holding
+ // the list open on a one-letter prefix
+ const MAX_LOOKUP_VALUES = 100;
+
+ // The element scripts load on first use of their tag, so create one to get
+ // search-textbox defined before extending it
+ document.createXULElement("search-textbox");
+
+ /**
+ * A search box that shows the parts of a search query (see
+ * Zotero.SearchQuery) as they're typed: the field, operator, and value of
+ * each condition are colored, and everything else reads as plain text.
+ *
+ * An can't render styled text, so the colored copy is a layer
+ * behind it, drawn from the same tokens the parser produces and kept in
+ * step with the input's own scrolling. The input's text is transparent, so
+ * what's on screen is the layer, and everything the input does otherwise --
+ * selection, IME, accessibility -- is untouched.
+ */
+ class QueryTextbox extends customElements.get("search-textbox") {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.connected) {
+ return;
+ }
+ super.connectedCallback();
+
+ const stylesheet = document.createElement("link");
+ stylesheet.rel = "stylesheet";
+ stylesheet.href = "chrome://zotero/skin/query-textbox.css";
+ this.shadowRoot.prepend(stylesheet);
+
+ this._highlightLayer = document.createElement("div");
+ this._highlightLayer.className = "query-highlight";
+ this._highlightLayer.setAttribute("aria-hidden", "true");
+ this.inputField.before(this._highlightLayer);
+
+ // The layer is positioned over the input rather than laid out with
+ // it, so it has to follow the input's box
+ this._resizeObserver = new ResizeObserver(() => this._syncGeometry());
+ this._resizeObserver.observe(this.inputField);
+
+ this.inputField.addEventListener("input", () => {
+ this.updateHighlighting();
+ this.updateCompletions();
+ });
+ this.inputField.addEventListener("scroll", () => this._syncScroll());
+ // The caret moving is as much a reason to offer something else as
+ // the text changing
+ this.inputField.addEventListener("keyup", (event) => {
+ if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
+ this.updateCompletions();
+ }
+ });
+ this.inputField.addEventListener("click", () => this.updateCompletions());
+ this.inputField.addEventListener("blur", () => this.hideCompletions());
+ // Before the base class's own Enter and Escape handling, which the
+ // list takes over while it's open
+ this.addEventListener("keydown", event => this._handleCompletionKey(event), true);
+ // Rendering mid-composition would replace the text being composed
+ this.inputField.addEventListener("compositionstart", () => {
+ this._composing = true;
+ });
+ this.inputField.addEventListener("compositionend", () => {
+ this._composing = false;
+ this.updateHighlighting();
+ this.updateCompletions();
+ });
+ this.updateHighlighting();
+ }
+
+ set value(val) {
+ super.value = val;
+ // Setting the value doesn't fire input
+ this.updateHighlighting();
+ this.hideCompletions();
+ }
+
+ get value() {
+ return this.inputField.value;
+ }
+
+ /**
+ * Redraw the layer from the current value
+ */
+ updateHighlighting() {
+ if (!this._highlightLayer || this._composing) {
+ return;
+ }
+ let value = this.inputField.value;
+ let tokens = value ? Zotero.SearchQuery.tokenize(value) : [];
+ // Recognized conditions are what the coloring is for, so a query
+ // without any leaves the input to draw its own text
+ let hasCondition = tokens.some(token => token.type === 'field');
+ this.toggleAttribute("highlighted", hasCondition);
+ this._syncGeometry();
+ this._highlightLayer.replaceChildren();
+ if (hasCondition) {
+ for (let token of tokens) {
+ let span = document.createElement("span");
+ span.className = "query-token-" + token.type;
+ span.textContent = value.slice(token.start, token.end);
+ this._highlightLayer.append(span);
+ }
+ }
+ this._syncScroll();
+ }
+
+ /**
+ * Show completions for what's being typed at the caret, or hide the
+ * list if there's nothing to offer (see
+ * Zotero.SearchQuery.getCompletions())
+ */
+ async updateCompletions() {
+ if (this._composing) {
+ return;
+ }
+ let value = this.inputField.value;
+ let completions = value
+ ? Zotero.SearchQuery.getCompletions(value, this.inputField.selectionStart)
+ : null;
+ // A query that changes while a lookup is in flight makes its results
+ // obsolete
+ let generation = this._completionGeneration = (this._completionGeneration || 0) + 1;
+ if (completions && completions.lookup) {
+ completions = {
+ ...completions,
+ completions: await this._lookupValues(completions)
+ };
+ if (generation !== this._completionGeneration) {
+ return;
+ }
+ }
+ if (!completions || !completions.completions.length) {
+ this.hideCompletions();
+ return;
+ }
+ this._completions = completions;
+ // The getter builds the list along with the popup that holds it
+ let popup = this._completionPopup;
+ let list = this._completionList;
+ list.replaceChildren();
+ for (let completion of completions.completions) {
+ let row = document.createXULElement("richlistitem");
+ let label = document.createElement("span");
+ label.className = "completion-label";
+ if (completion.color) {
+ let swatch = document.createElement("span");
+ swatch.className = "completion-swatch";
+ swatch.style.backgroundColor = completion.color;
+ label.append(swatch);
+ label.classList.add("colored");
+ }
+ label.append(completion.label);
+ row.append(label);
+ // The description explains what a name maps to ("by:" is the
+ // Creator condition), so a name that already says it doesn't
+ // need it: "attachment tag:" is self-evidently about tags
+ if (completion.description && !completion.label.replace(/:$/, '').toLowerCase()
+ .includes(completion.description.toLowerCase())) {
+ let description = document.createElement("span");
+ description.className = "completion-description";
+ description.textContent = completion.description;
+ row.append(description);
+ }
+ row.completion = completion;
+ list.append(row);
+ }
+ // Nothing is selected until the user moves into the list, so Enter
+ // runs the search it would have run
+ list.clearSelection();
+ if (popup.state === "closed") {
+ popup.openPopup(this.inputField, "after_start");
+ }
+ }
+
+ // Values a condition takes that come from the library, from the same
+ // search the Advanced Search fields use
+ _lookupValues({ lookup, prefix }) {
+ let params = { ...lookup };
+ // Values from every library the search covers
+ let libraryIDs = Zotero.getActiveZoteroPane()?.getSelectedLibraryIDs() || [];
+ if (libraryIDs.length) {
+ params.libraryIDs = libraryIDs;
+ }
+ // A tag with an assigned color shows it
+ let colors = new Map();
+ if (lookup.fieldName === 'tag') {
+ for (let id of libraryIDs.length ? libraryIDs : [Zotero.Libraries.userLibraryID]) {
+ for (let [name, data] of Zotero.Tags.getColors(id)) {
+ if (!colors.has(name)) {
+ colors.set(name, data.color);
+ }
+ }
+ }
+ }
+ let search = Cc["@mozilla.org/autocomplete/search;1?name=zotero"]
+ .createInstance(Ci.nsIAutoCompleteSearch);
+ return new Promise((resolve) => {
+ search.startSearch(prefix, JSON.stringify(params), null, {
+ onSearchResult: (_, result) => {
+ // Results arrive in batches as the query runs
+ if (result.searchResult === Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING
+ || result.searchResult
+ === Ci.nsIAutoCompleteResult.RESULT_NOMATCH_ONGOING) {
+ return;
+ }
+ let values = [];
+ for (let i = 0; i < result.matchCount && i < MAX_LOOKUP_VALUES; i++) {
+ values.push(result.getValueAt(i));
+ }
+ resolve(values.map(value => ({
+ text: Zotero.SearchQuery.formatValue(value),
+ label: value,
+ color: colors.get(value)
+ })));
+ }
+ });
+ });
+ }
+
+ hideCompletions() {
+ // Results from a lookup still in flight would reopen the list
+ this._completionGeneration = (this._completionGeneration || 0) + 1;
+ if (this._popup && this._popup.state !== "closed") {
+ this._popup.hidePopup();
+ }
+ }
+
+ get _completionPopup() {
+ if (!this._popup) {
+ this._popup = document.createXULElement("panel");
+ this._popup.className = "query-completions";
+ this._popup.setAttribute("noautofocus", "true");
+ this._popup.setAttribute("ignorekeys", "true");
+ this._popup.setAttribute("consumeoutsideclicks", "never");
+ this._completionList = document.createXULElement("richlistbox");
+ // On mousedown rather than click: focus never leaves the
+ // input, and the blur that clicking would cause won't close
+ // the list before the click can land
+ this._completionList.addEventListener("mousedown", (event) => {
+ let row = event.button === 0 && event.target.closest("richlistitem");
+ if (row) {
+ event.preventDefault();
+ this._acceptCompletion(row);
+ }
+ });
+ this._popup.append(this._completionList);
+ // A popup in a shadow root gets none of the window's styles, so
+ // it goes in the document alongside the other popups
+ let popupset = document.querySelector("popupset");
+ (popupset || document.documentElement).append(this._popup);
+ }
+ return this._popup;
+ }
+
+ // Replace what's being typed with the given or selected completion,
+ // leaving the caret after it so the next part can be typed or
+ // completed in turn
+ _acceptCompletion(row) {
+ row = row || this._completionList.selectedItem || this._completionList.firstChild;
+ if (!row || !this._completions) {
+ return false;
+ }
+ let { type, start, end } = this._completions;
+ let value = this.inputField.value;
+ this.inputField.value = value.slice(0, start) + row.completion.text + value.slice(end);
+ let caret = start + row.completion.text.length;
+ this.inputField.setSelectionRange(caret, caret);
+ this.hideCompletions();
+ // Search and redraw as though it had been typed, and offer what
+ // comes next -- the values of the condition just completed
+ this.inputField.dispatchEvent(new Event("input", { bubbles: true }));
+ // A completed value finishes the clause: there's nothing to offer
+ // next, so don't reopen the list with the value itself
+ if (type === 'value') {
+ this.hideCompletions();
+ }
+ return true;
+ }
+
+ _handleCompletionKey(event) {
+ if (!this._popup || this._popup.state === "closed" || event.altKey) {
+ return;
+ }
+ let list = this._completionList;
+ switch (event.key) {
+ case "ArrowDown":
+ case "ArrowUp": {
+ let rows = list.itemCount;
+ let index = list.selectedIndex;
+ list.selectedIndex = event.key === "ArrowDown"
+ ? (index + 1) % rows
+ : (index <= 0 ? rows - 1 : index - 1);
+ // Moving the selection with the list unfocused doesn't
+ // scroll it into view
+ list.ensureIndexIsVisible(list.selectedIndex);
+ break;
+ }
+ case "Enter":
+ // Only once the user has moved into the list -- otherwise
+ // Enter is the search it has always been, with the list
+ // out of its way
+ if (list.selectedIndex === -1 || !this._acceptCompletion()) {
+ this.hideCompletions();
+ return;
+ }
+ break;
+ case "Tab":
+ if (!this._acceptCompletion()) {
+ return;
+ }
+ break;
+ case "Escape":
+ this.hideCompletions();
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._resizeObserver?.disconnect();
+ this._popup?.remove();
+ }
+
+ _syncScroll() {
+ if (this._highlightLayer) {
+ this._highlightLayer.scrollLeft = this.inputField.scrollLeft;
+ }
+ }
+
+ // Match the input's box and text metrics, so the two render in the
+ // same place
+ _syncGeometry() {
+ if (!this._highlightLayer) {
+ return;
+ }
+ let input = this.inputField;
+ let style = window.getComputedStyle(input);
+ let layer = this._highlightLayer.style;
+ layer.left = input.offsetLeft + "px";
+ layer.top = input.offsetTop + "px";
+ layer.width = input.offsetWidth + "px";
+ layer.height = input.offsetHeight + "px";
+ for (let property of ['paddingInlineStart', 'paddingInlineEnd', 'paddingTop',
+ 'paddingBottom', 'font', 'letterSpacing', 'textIndent']) {
+ layer[property] = style[property];
+ }
+ this._syncScroll();
+ }
+ }
+
+ customElements.define("query-textbox", QueryTextbox);
+}
diff --git a/chrome/content/zotero/elements/quickSearchTextbox.js b/chrome/content/zotero/elements/quickSearchTextbox.js
index 5cbde4150d..79f51869e1 100644
--- a/chrome/content/zotero/elements/quickSearchTextbox.js
+++ b/chrome/content/zotero/elements/quickSearchTextbox.js
@@ -93,7 +93,7 @@
dropmarkerShadow.append(s1, s2, dropmarker);
- let searchBox = document.createXULElement("search-textbox");
+ let searchBox = document.createXULElement("query-textbox");
searchBox.id = "zotero-tb-search-textbox";
// Enable applying styles to the input field
searchBox.inputField.setAttribute("part", "search-input");
diff --git a/chrome/content/zotero/xpcom/searchQuery.js b/chrome/content/zotero/xpcom/searchQuery.js
new file mode 100644
index 0000000000..c0805f767c
--- /dev/null
+++ b/chrome/content/zotero/xpcom/searchQuery.js
@@ -0,0 +1,1490 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2026 Corporation for Digital Scholarship
+ Vienna, Virginia, USA
+ https://www.zotero.org
+
+ This file is part of Zotero.
+
+ Zotero is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Zotero is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with Zotero. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+/**
+ * Zotero.SearchQuery -- a text form of the search conditions the Advanced
+ * Search edits, for typing searches into a search box:
+ *
+ * by:smith after:2020 tag:"to read" crispr
+ * creator is smith and (tag is foo or bar)
+ *
+ * A clause is a field, an operator, and a value. Clauses can be grouped with
+ * parentheses and joined with `and` or `or`, with `and` binding tighter, so
+ * `a or b and c` means `a or (b and c)`. Anything that isn't a clause is free
+ * text, returned separately for the caller to match however its search mode
+ * matches text.
+ *
+ * Parsing never fails: text that doesn't look like a clause is just text, so
+ * a DOI, a URL, or a title with a colon in it searches as typed. The same
+ * goes for anything the search itself would reject -- an unknown item type or
+ * an empty value -- since searching for the text finds too much, while a
+ * malformed condition finds nothing.
+ */
+Zotero.SearchQuery = new function () {
+ // Short names a locale can offer for a condition, each message a
+ // comma-separated list. A locale can give a condition as many as it likes;
+ // one that collides with a condition name is skipped.
+ const KEYWORDS = {
+ 'search-query-keyword-creator': { condition: 'creator' },
+ 'search-query-keyword-publication': { condition: 'publicationTitle' },
+ 'search-query-keyword-item-type': { condition: 'itemType' },
+ 'search-query-keyword-language': { condition: 'language' },
+ 'search-query-keyword-abstract': { condition: 'abstractNote' },
+ 'search-query-keyword-fulltext': { condition: 'fulltextContent' },
+ 'search-query-keyword-date': { condition: 'date' },
+ 'search-query-keyword-date-before': { condition: 'date', operator: 'isBefore' },
+ 'search-query-keyword-date-after': { condition: 'date', operator: 'isAfter' },
+ 'search-query-keyword-date-added': { condition: 'dateAdded' },
+ 'search-query-keyword-date-modified': { condition: 'dateModified' }
+ };
+
+ // The same short names in English, which keep working in every locale so
+ // that a query written anywhere can be read anywhere. An operator here
+ // replaces the default the colon form would use, so `after:2020` means
+ // dated after 2020 rather than `date is 2020`.
+ const ALIASES = {
+ by: { condition: 'creator' },
+ 'number of tags': { condition: 'numTags' },
+ 'number of notes': { condition: 'numNotes' },
+ 'number of attachments': { condition: 'numAttachments' },
+ 'number of annotations': { condition: 'numAnnotations' },
+ 'in': { condition: 'publicationTitle' },
+ publication: { condition: 'publicationTitle' },
+ journal: { condition: 'publicationTitle' },
+ year: { condition: 'date' },
+ before: { condition: 'date', operator: 'isBefore' },
+ after: { condition: 'date', operator: 'isAfter' },
+ since: { condition: 'date', operator: 'isAfter' },
+ added: { condition: 'dateAdded' },
+ modified: { condition: 'dateModified' },
+ lang: { condition: 'language' },
+ type: { condition: 'itemType' },
+ fulltext: { condition: 'fulltextContent' },
+ text: { condition: 'fulltextContent' },
+ abstract: { condition: 'abstractNote' },
+ // The only colored thing that's searchable, so `type is annotation
+ // and color is red` reads the way people write it
+ color: { condition: 'annotationColor' }
+ };
+
+ // Conditions matched against an item's children, beyond the ones whose own
+ // definition says which level they match at
+ const CHILD_FIELDS = {
+ 'annotation tag': { condition: 'tag', level: 'annotation' },
+ 'note tag': { condition: 'tag', level: 'note' },
+ 'attachment tag': { condition: 'tag', level: 'attachment' },
+ 'note text': { condition: 'note', level: 'note' },
+ 'attachment title': { condition: 'title', level: 'attachment' }
+ };
+
+ // Conditions that are plumbing rather than something to type, plus those
+ // whose values are keys or ids no one can type from memory: a collection
+ // name isn't unique and a saved search's key is opaque, so those need
+ // completion that keeps the value behind the label.
+ const EXCLUDED = new Set([
+ 'joinMode', 'groupStart', 'groupEnd', 'resultLevel', 'bestMatch',
+ 'includeParentsAndChildren', 'includeParents', 'includeChildren',
+ 'recursive', 'noChildren', 'includeDeleted', 'deleted', 'tempTable',
+ 'libraryID', 'key', 'itemID', 'savedSearchID', 'collectionID', 'tagID',
+ 'itemTypeID', 'fileTypeID',
+ 'collection', 'savedSearch', 'annotationAuthor',
+ // Quick search modes, which exist as conditions so that opening the
+ // Advanced Search from the quick search can show what it searched
+ 'titleCreatorYear', 'anyField'
+ ]);
+
+ // Conditions stored as internal names, ids, or codes, keyed by the names
+ // shown in the interface, which are what a query uses. A value that isn't
+ // in the list isn't a value for that condition at all.
+ const VALUE_LOOKUPS = {
+ itemType: () => {
+ let values = {};
+ for (let type of Zotero.ItemTypes.getTypes()) {
+ values[Zotero.ItemTypes.getLocalizedString(type.name).toLowerCase()] = type.name;
+ }
+ return values;
+ },
+ annotationColor: () => {
+ let values = {};
+ for (let [l10nID, hex] of Zotero.Annotations.COLORS) {
+ values[Zotero.ftl.formatValueSync(l10nID).toLowerCase()] = hex;
+ }
+ return values;
+ },
+ annotationType: () => {
+ let values = {};
+ for (let prop of Object.keys(Zotero.Annotations)) {
+ let match = /^ANNOTATION_TYPE_(.+)$/.exec(prop);
+ if (!match) {
+ continue;
+ }
+ let name = match[1].toLowerCase();
+ values[Zotero.getString('reader-' + name + '-annotation-short').toLowerCase()]
+ = String(Zotero.Annotations[prop]);
+ }
+ return values;
+ },
+ attachmentStorageType: () => {
+ let values = {};
+ for (let type of ['storedFile', 'linkedFile', 'webLink']) {
+ values[Zotero.getString('attachment-storage-type-' + type).toLowerCase()] = type;
+ }
+ return values;
+ }
+ };
+
+ // A name condition compares against the full name, so `creator is okonkwo`
+ // would match only a creator with no first name. In a typed query that
+ // reads as "the creator is Okonkwo", so match within the name instead --
+ // the same as the quick search does.
+ const NAME_OPERATOR_OVERRIDES = { is: 'contains', isNot: 'doesNotContain' };
+
+ // A condition that matches a creator, which searching itemCreators gives
+ // away
+ function _isNameCondition(condition) {
+ return Zotero.SearchConditions.get(condition)?.table == 'itemCreators';
+ }
+
+ // Operators can be several words, matched longest first so that "is not
+ // empty" wins over "is not" and "is". A comparison also reads with an
+ // extra "is" ("year is before 2020"), which only a condition that
+ // compares takes that way -- `title is before` matches the word.
+ const OPERATOR_WORDS = {
+ 'is': 'is',
+ 'is not': 'isNot',
+ 'is empty': 'isEmpty',
+ 'is not empty': 'isNotEmpty',
+ 'contains': 'contains',
+ 'does not contain': 'doesNotContain',
+ 'begins with': 'beginsWith',
+ 'starts with': 'beginsWith',
+ 'before': 'isBefore',
+ 'is before': 'isBefore',
+ 'after': 'isAfter',
+ 'is after': 'isAfter',
+ 'since': 'isAfter',
+ 'in the last': 'isInTheLast',
+ 'is in the last': 'isInTheLast',
+ 'within the last': 'isInTheLast',
+ 'greater than': 'isGreaterThan',
+ 'is greater than': 'isGreaterThan',
+ 'more than': 'isGreaterThan',
+ 'is more than': 'isGreaterThan',
+ 'less than': 'isLessThan',
+ 'is less than': 'isLessThan',
+ '>': 'isGreaterThan',
+ '<': 'isLessThan'
+ };
+
+ // Operators that take no value
+ const UNARY = new Set(['isEmpty', 'isNotEmpty']);
+
+ // Operators that come before a condition instead of after it, reading
+ // the way they're said: `no doi`, `has doi`
+ const PREFIX_OPERATORS = { no: 'isEmpty', has: 'isNotEmpty' };
+
+ // Things an item can have some or none of, which the prefix operators
+ // test as counts: `no annotation` means no annotations at all, not an
+ // annotation with an empty field
+ const PREFIX_COUNT_CONDITIONS = {
+ annotation: 'numAnnotations',
+ annotations: 'numAnnotations',
+ note: 'numNotes',
+ notes: 'numNotes',
+ tag: 'numTags',
+ tags: 'numTags',
+ attachment: 'numAttachments',
+ attachments: 'numAttachments'
+ };
+
+ // How each prefix operator compares a count
+ const PREFIX_COUNT_COMPARISONS = {
+ isEmpty: { operator: 'is', value: '0' },
+ isNotEmpty: { operator: 'isGreaterThan', value: '0' }
+ };
+
+ // The count tests as whole phrases a locale can offer, each message a
+ // comma-separated list: where English has `no notes`, German can offer
+ // "keine notizen". The English forms above work in every locale.
+ const COUNT_PHRASE_KEYWORDS = {
+ 'search-query-keyword-no-annotations':
+ { condition: 'numAnnotations', operator: 'is', value: '0' },
+ 'search-query-keyword-has-annotations':
+ { condition: 'numAnnotations', operator: 'isGreaterThan', value: '0' },
+ 'search-query-keyword-no-notes':
+ { condition: 'numNotes', operator: 'is', value: '0' },
+ 'search-query-keyword-has-notes':
+ { condition: 'numNotes', operator: 'isGreaterThan', value: '0' },
+ 'search-query-keyword-no-tags':
+ { condition: 'numTags', operator: 'is', value: '0' },
+ 'search-query-keyword-has-tags':
+ { condition: 'numTags', operator: 'isGreaterThan', value: '0' },
+ 'search-query-keyword-no-attachments':
+ { condition: 'numAttachments', operator: 'is', value: '0' },
+ 'search-query-keyword-has-attachments':
+ { condition: 'numAttachments', operator: 'isGreaterThan', value: '0' }
+ };
+
+ const JOIN_WORDS = { and: 'all', or: 'any' };
+
+ const QUOTES = ['"', "'", '“', '‘'];
+ const CLOSING_QUOTES = { '"': '"', "'": "'", '“': '”', '‘': '’' };
+
+ // "3 days", "2 weeks" -- the value an isInTheLast condition stores. Weeks
+ // are counted in days, which is what the date comparison understands.
+ const DURATION_RE = /^(\d+)\s*(day|week|month|year)s?\b/i;
+
+ var _fields = null;
+ var _fieldPhrases = null;
+ var _operatorPhrases = null;
+ var _prefixOperatorPhrases = null;
+ var _prefixCountPhrases = null;
+ var _countPhrases = null;
+ var _countPhrasePhrases = null;
+ var _prefixContextRE = null;
+ var _values = {};
+ var _valuePhrases = {};
+ var _operatorRE = null;
+
+ // Every condition the Advanced Search offers is typeable, by its
+ // localized name and by any alias above
+ function _getFields() {
+ if (_fields) {
+ return _fields;
+ }
+ _fields = {};
+ // Conditions by their stored name, which the tables below refer to
+ // but the syntax doesn't expose
+ let byCondition = {};
+ let add = (name, field) => {
+ let key = (name || '').toLowerCase();
+ if (key && !_fields[key]) {
+ _fields[key] = field;
+ }
+ };
+ for (let { name, localized, operators } of Zotero.SearchConditions.getStandardConditions()) {
+ if (EXCLUDED.has(name)) {
+ continue;
+ }
+ let field = {
+ condition: name,
+ operators: Object.keys(operators || { contains: true })
+ };
+ // A condition that matches at one particular level is scoped to
+ // it, so `annotation text:foo` finds items with a matching
+ // annotation. An array of levels means it matches natively at
+ // each of them.
+ let level = Zotero.SearchConditions.get(name).level;
+ if (typeof level == 'string' && !['item', 'any'].includes(level)) {
+ field.level = level;
+ }
+ byCondition[name] = field;
+ add(localized, field);
+ }
+ // Aliases win over the condition names they shadow: `type` is far more
+ // likely to mean the item type than the report/thesis Type field
+ for (let [alias, { condition, operator }] of Object.entries(ALIASES)) {
+ let field = byCondition[condition];
+ if (field) {
+ _fields[alias] = operator ? { ...field, operator } : field;
+ }
+ }
+ for (let [id, { condition, operator }] of Object.entries(KEYWORDS)) {
+ let field = byCondition[condition];
+ if (!field) {
+ continue;
+ }
+ let translated;
+ try {
+ translated = Zotero.ftl.formatValueSync(id);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ for (let keyword of (translated || '').split(',')) {
+ keyword = keyword.trim().toLowerCase();
+ // A keyword that collides with a condition name would shadow
+ // it, so only add ones that are free
+ if (keyword && !_fields[keyword]) {
+ _fields[keyword] = operator ? { ...field, operator } : field;
+ }
+ }
+ }
+ for (let [name, { condition, level }] of Object.entries(CHILD_FIELDS)) {
+ let base = byCondition[condition];
+ if (base) {
+ add(name, { ...base, level });
+ }
+ }
+ return _fields;
+ }
+
+ /**
+ * Field names available for completion.
+ *
+ * @return {String[]}
+ */
+ this.getFieldNames = function () {
+ return Object.keys(_getFields());
+ };
+
+ /**
+ * The search condition a field name maps to, or false if it isn't one.
+ *
+ * @param {String} name
+ * @return {Object|false}
+ */
+ this.getField = function (name) {
+ return _getFields()[(name || '').toLowerCase()] || false;
+ };
+
+ /**
+ * The values a condition accepts, keyed by what can be typed for each, or
+ * null if it takes any value.
+ *
+ * @param {String} condition
+ * @return {Object|null}
+ */
+ this.getValues = function (condition) {
+ if (!VALUE_LOOKUPS[condition]) {
+ return null;
+ }
+ if (!_values[condition]) {
+ _values[condition] = VALUE_LOOKUPS[condition]();
+ }
+ return _values[condition];
+ };
+
+ /**
+ * Completions for what's being typed at the caret: a condition name
+ * partway through a word, or a value for a condition that takes
+ * particular ones. Free text, dates, and numbers have nothing to offer.
+ *
+ * @param {String} text
+ * @param {Number} [caret=text.length] - Offset of the caret
+ * @return {Object|null} - { type, start, end, completions }, where type is
+ * 'field' or 'value', start and end are the offsets the completion
+ * replaces, and each completion is { text, label, description }
+ */
+ this.getCompletions = function (text, caret = text.length) {
+ // Completing partway through a word would leave its tail behind
+ if (caret < text.length && !/[\s)]/.test(text[caret])) {
+ return null;
+ }
+ let head = text.slice(0, caret);
+ let openQuote = _openQuoteAt(head);
+ let clause = _activeClause(this, head);
+ return _trailingValueCompletions(this, head, caret, openQuote)
+ || _valueCompletions(this, clause, head, caret, openQuote)
+ || _fieldCompletions(this, clause, head, caret, openQuote);
+ };
+
+ // Values for `type:jour`, where the condition takes particular ones
+ function _valueCompletions(self, clause, head, caret, openQuote) {
+ // A unary operator's clause is complete, so what follows isn't a value
+ if (!clause || !clause.supported || UNARY.has(clause.operatorName)) {
+ return null;
+ }
+ return _valuesFor(self, clause.field.condition, head, clause.valueStart, caret, openQuote);
+ }
+
+ // The clause whose value ends at the caret, if the name before the last
+ // colon or operator is a condition that takes that operator. The name can
+ // be several words ("publication title"), and what precedes it is text,
+ // so try the longest name that's a condition.
+ function _activeClause(self, head) {
+ let split = _valueSplit(head);
+ if (!split) {
+ return null;
+ }
+ let words = head.slice(0, split.fieldEnd).trim().split(/\s+/);
+ let field = null;
+ for (let i = 0; i < words.length && !field; i++) {
+ field = self.getField(words.slice(i).join(' '));
+ }
+ if (!field) {
+ return null;
+ }
+ let operatorName = split.operator
+ ? OPERATOR_WORDS[split.operator]
+ : (field.operator || _defaultOperator(field));
+ let valueStart = split.valueStart + /^\s*/.exec(head.slice(split.valueStart))[0].length;
+ return {
+ field,
+ operatorName,
+ valueStart,
+ // Whether the condition takes the operator: `type before boo`
+ // names a condition and an operator but doesn't make a clause
+ supported: field.operators.includes(operatorName)
+ };
+ }
+
+ // Completions for what the tokenizer already reads as a value ending at
+ // the caret -- a clause's own value (`tag:zz`) or one being typed after
+ // `or`, which repeats the field of the clause before it
+ function _trailingValueCompletions(self, head, caret, openQuote) {
+ let tokens = self.tokenize(head).filter(token => token.type !== 'space');
+ let last = tokens[tokens.length - 1];
+ if (!last) {
+ return null;
+ }
+ // A value the tokenizer already read ("tag:foo or zz")
+ if (last.type === 'value' && last.field && last.end === head.length) {
+ let field = self.getField(last.field);
+ let start = last.start + /^\s*/.exec(head.slice(last.start))[0].length;
+ return _valuesFor(self, field.condition, head, start, caret, openQuote);
+ }
+ // A value not started yet ("type:book or ")
+ if (last.type === 'join' && JOIN_WORDS[last.value.toLowerCase()] === 'any'
+ && last.end < head.length) {
+ let previous = tokens[tokens.length - 2];
+ if (previous && previous.type === 'value' && previous.field) {
+ let field = self.getField(previous.field);
+ return _valuesFor(self, field.condition, head, caret, caret, openQuote);
+ }
+ return null;
+ }
+ // A value that doesn't read as one yet ("annotation color:red or bl")
+ if (last.type === 'text' && last.end === head.length && tokens.length >= 3) {
+ let join = tokens[tokens.length - 2];
+ let previous = tokens[tokens.length - 3];
+ if (join.type === 'join' && JOIN_WORDS[join.value.toLowerCase()] === 'any'
+ && previous.type === 'value' && previous.field) {
+ let field = self.getField(previous.field);
+ return _valuesFor(self, field.condition, head, last.start, caret, openQuote);
+ }
+ }
+ return null;
+ }
+
+ // The parameters the autocomplete search takes for a condition whose values
+ // come from the library rather than a fixed list (see
+ // zotero-autocomplete.mjs), or false for one that has neither. A creator
+ // condition looks itself up, since its name is the creator type to match,
+ // and takes both one- and two-field creators.
+ function _valueLookup(condition) {
+ if (condition == 'tag') {
+ return { fieldName: 'tag' };
+ }
+ if (_isNameCondition(condition)) {
+ return { fieldName: condition, fieldMode: 2 };
+ }
+ return false;
+ }
+
+ // Completions for a condition's value being typed at `start`, replacing
+ // through the caret. An opening quote is replaced along with what it
+ // quotes, so the completion's own quoting isn't doubled; a quote opened
+ // anywhere else means the caret is inside quoted text, which is being
+ // typed, not completed.
+ function _valuesFor(self, condition, head, start, caret, openQuote) {
+ if (openQuote !== -1 && openQuote !== start) {
+ return null;
+ }
+ let typed = head.slice(start);
+ if (QUOTES.includes(typed[0])) {
+ typed = typed.slice(1);
+ }
+ let values = self.getValues(condition);
+ if (!values) {
+ // Values the library supplies, which the caller looks up (they take
+ // a query) and turns into completions. Every tag or creator in the
+ // library is a list to scroll, not a suggestion, so they need
+ // something to match.
+ let lookup = typed && _valueLookup(condition);
+ return lookup
+ ? { type: 'value', lookup, prefix: typed, start, end: caret, completions: [] }
+ : null;
+ }
+ let names = Object.keys(values)
+ .filter(name => name.startsWith(typed.toLowerCase()))
+ .sort((a, b) => a.localeCompare(b));
+ // What's typed isn't any of the values, so it may be something else
+ // being typed after the clause, like a new condition name
+ if (!names.length) {
+ return null;
+ }
+ return {
+ type: 'value',
+ start,
+ end: caret,
+ completions: names.map(name => ({
+ text: _quoteIfNeeded(name),
+ label: name,
+ // A value that is a color shows itself
+ color: /^#[0-9a-f]{6}$/i.test(values[name]) ? values[name] : undefined
+ }))
+ };
+ }
+
+ // Condition names for `ta`, offered by the shortest name each one has, so
+ // that a condition appears once however many ways it can be written
+ function _fieldCompletions(self, clause, head, caret, openQuote) {
+ // Inside an unfinished quote everything is the value being typed, and
+ // a word that could be continuing an operator ("year is b...") isn't
+ // the start of a new field
+ if (openQuote !== -1 || _continuesOperator(self, head)) {
+ return null;
+ }
+ // A name can be several words ("annotation type"), so try the longest
+ // phrase before the caret first: `annotation ty` completes to
+ // `annotation type:`, not to `type:`
+ for (let word of head.matchAll(/[^\s()]+/g)) {
+ // The word where a clause's value starts is that value being
+ // typed, not a new field: `title is ta` isn't heading for `tag:`.
+ // The same goes for an operator the condition doesn't take --
+ // `type before boo` isn't heading for `book author:`.
+ if (clause && !UNARY.has(clause.operatorName) && word.index === clause.valueStart) {
+ continue;
+ }
+ // After `no` or `has`, a name alone makes a clause (`no doi`), so
+ // offer only names the operator resolves with, and without the
+ // colon
+ let prefixOperator = _prefixOperatorBefore(head.slice(0, word.index));
+ let typed = head.slice(word.index).toLowerCase();
+ let byCondition = new Map();
+ let names = self.getFieldNames();
+ if (prefixOperator) {
+ names = names.concat(Object.keys(PREFIX_COUNT_CONDITIONS)
+ .filter(name => !names.includes(name)));
+ }
+ for (let name of names) {
+ if (!name.startsWith(typed)) {
+ continue;
+ }
+ if (prefixOperator) {
+ let clause = _resolvePrefixClause(self, prefixOperator, name);
+ if (!clause) {
+ continue;
+ }
+ // A count (`no annotation`) rather than a field test
+ if (PREFIX_COUNT_CONDITIONS[name]) {
+ let best = byCondition.get(clause.condition);
+ if (!best || _isPreferredFieldName(name, best)) {
+ byCondition.set(clause.condition, name);
+ }
+ continue;
+ }
+ }
+ let field = self.getField(name);
+ let key = field.condition + '/' + (field.operator || '') + '/' + (field.level || '');
+ let best = byCondition.get(key);
+ if (!best || _isPreferredFieldName(name, best)) {
+ byCondition.set(key, name);
+ }
+ }
+ if (!byCondition.size) {
+ continue;
+ }
+ return {
+ type: 'field',
+ start: word.index,
+ end: caret,
+ completions: _sorted([...byCondition.values()]).map(name => ({
+ text: prefixOperator ? name : name + ':',
+ label: prefixOperator ? name : name + ':',
+ // A count name ('annotation') says what it means itself
+ description: prefixOperator && PREFIX_COUNT_CONDITIONS[name]
+ ? undefined
+ : Zotero.SearchConditions.getLocalizedName(self.getField(name).condition)
+ }))
+ };
+ }
+ return null;
+ }
+
+ // The offset of an opening quote that hasn't been closed, or -1. A quote
+ // only opens at the start of a word, so an apostrophe inside one doesn't
+ // count.
+ function _openQuoteAt(head) {
+ let open = -1;
+ for (let i = 0; i < head.length; i++) {
+ if (open === -1) {
+ if (QUOTES.includes(head[i]) && (i === 0 || /[\s(:]/.test(head[i - 1]))) {
+ open = i;
+ }
+ }
+ else if (head[i] === CLOSING_QUOTES[head[open]]) {
+ open = -1;
+ }
+ }
+ return open;
+ }
+
+ // The prefix operator the word being completed follows, if any: in
+ // `no d` the `d` is a prefix target, not a new field
+ function _prefixOperatorBefore(head) {
+ if (!_prefixContextRE) {
+ let words = Object.keys(PREFIX_OPERATORS)
+ .sort((a, b) => b.length - a.length)
+ .map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .join('|');
+ _prefixContextRE = new RegExp('(?:^|[\\s(])(' + words + ')\\s+$', 'i');
+ }
+ let match = _prefixContextRE.exec(head);
+ return match ? PREFIX_OPERATORS[match[1].toLowerCase()] : null;
+ }
+
+ // Whether the words at the end could be an operator still being typed
+ // after a condition name, as in `year is b` on its way to
+ // `year is before 2020`
+ function _continuesOperator(self, head) {
+ _initPhrases(self);
+ for (let word of head.matchAll(/[^\s()]+/g)) {
+ let field = _matchPhrase(head, word.index, _fieldPhrases);
+ if (!field) {
+ continue;
+ }
+ let rest = head.slice(field.end).trim()
+ .replace(/\s+/g, ' ')
+ .toLowerCase();
+ if (rest && Object.keys(OPERATOR_WORDS)
+ .some(name => name.length > rest.length && name.startsWith(rest))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Where the value being typed starts, and where the condition naming it
+ // ends: after a colon (`tag:foo`, operator null) or after an operator
+ // (`tag is foo`). The last one in the query is the one being typed.
+ function _valueSplit(head) {
+ let split = null;
+ let colon = head.lastIndexOf(':');
+ if (colon !== -1) {
+ split = { fieldEnd: colon, valueStart: colon + 1, operator: null };
+ }
+ let pattern = _operatorPattern();
+ pattern.lastIndex = 0;
+ let match;
+ while ((match = pattern.exec(head))) {
+ let valueStart = match.index + match[0].length;
+ if (!split || valueStart > split.valueStart) {
+ split = {
+ fieldEnd: match.index,
+ valueStart,
+ operator: match[1].replace(/\s+/g, ' ').toLowerCase()
+ };
+ }
+ }
+ return split;
+ }
+
+ // Operators as they're written between a condition and its value, matched
+ // longest first so that `is not empty` doesn't read as `is`
+ function _operatorPattern() {
+ if (!_operatorRE) {
+ let operators = Object.keys(OPERATOR_WORDS)
+ .sort((a, b) => b.length - a.length)
+ .map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s+'))
+ .join('|');
+ _operatorRE = new RegExp('\\s+(' + operators + ')\\s+', 'gi');
+ }
+ return _operatorRE;
+ }
+
+ // The shortest of a condition's names, counting a name and its spaced
+ // form as the same length and preferring the spaced one, so `t` offers
+ // 'type' rather than 'item type'
+ function _isPreferredFieldName(name, other) {
+ let compact = name.replace(/\s+/g, '');
+ let otherCompact = other.replace(/\s+/g, '');
+ if (compact.length !== otherCompact.length) {
+ return compact.length < otherCompact.length;
+ }
+ let spaced = name.includes(' ');
+ if (spaced !== other.includes(' ')) {
+ return spaced;
+ }
+ return name < other;
+ }
+
+ /**
+ * A value as it has to be written in a query to read back as one value.
+ *
+ * @param {String} value
+ * @return {String}
+ */
+ this.formatValue = function (value) {
+ return _quoteIfNeeded(value);
+ };
+
+ /**
+ * Whether the text has an opening quotation mark whose phrase hasn't
+ * been closed yet, so what's quoted is still being typed.
+ *
+ * @param {String} text
+ * @return {Boolean}
+ */
+ this.hasOpenQuote = function (text) {
+ return _openQuoteAt(text || '') !== -1;
+ };
+
+ // A value with spaces or parentheses in it has to be quoted to read back
+ // as one value, as does one starting with a quotation mark. There are no
+ // escapes, so quote with a character the value doesn't contain.
+ function _quoteIfNeeded(value) {
+ if (!/[\s()]/.test(value) && !QUOTES.includes(value[0])) {
+ return value;
+ }
+ for (let open of QUOTES) {
+ let close = CLOSING_QUOTES[open];
+ if (!value.includes(open) && !value.includes(close)) {
+ return open + value + close;
+ }
+ }
+ // A value containing every kind of quotation mark can't be written
+ return '"' + value + '"';
+ }
+
+ function _sorted(names) {
+ return names.sort((a, b) => a.length - b.length || a.localeCompare(b));
+ }
+
+ /**
+ * Split a query into tokens, for highlighting and completion as the user
+ * types as well as for parse(). Every character of the input belongs to
+ * exactly one token, so the tokens can be rendered in place.
+ *
+ * @param {String} text
+ * @return {Object[]} - [{ type, value, start, end }], where type is
+ * 'field', 'operator', 'value', 'join', 'paren', 'text', or 'space'
+ */
+ this.tokenize = function (text) {
+ _initPhrases(this);
+ let tokens = [];
+ let pos = 0;
+ while (pos < text.length) {
+ if (/\s/.test(text[pos])) {
+ let start = pos;
+ while (pos < text.length && /\s/.test(text[pos])) {
+ pos++;
+ }
+ tokens.push({ type: 'space', value: text.slice(start, pos), start, end: pos });
+ continue;
+ }
+ if (text[pos] === '(' || text[pos] === ')') {
+ tokens.push({ type: 'paren', value: text[pos], start: pos, end: pos + 1 });
+ pos++;
+ continue;
+ }
+
+ // A field name only counts as one when a colon or an operator
+ // follows it, so "Vol 2: The Return" stays text
+ let field = _matchPhrase(text, pos, _fieldPhrases);
+ let clause = field && _readClauseTokens(this, text, pos, field);
+ if (!clause) {
+ clause = _readPrefixClause(this, text, pos);
+ }
+ if (!clause) {
+ clause = _readCountPhrase(text, pos);
+ }
+ if (clause) {
+ tokens.push(...clause.tokens);
+ pos = clause.end;
+ continue;
+ }
+
+ let { value, end, quoted } = _readWord(text, pos);
+ let isJoin = !quoted && JOIN_WORDS[value.toLowerCase()];
+ // `tag is foo or bar` repeats the field of the clause the `or`
+ // directly follows, and the repeated value is read the same way
+ // the first one was
+ if (isJoin && JOIN_WORDS[value.toLowerCase()] === 'any') {
+ let previous = tokens.filter(token => token.type !== 'space').pop();
+ let repeated = previous && previous.type === 'value' && previous.field
+ && _readRepeatedValue(this, text, end, previous);
+ if (repeated) {
+ tokens.push({ type: 'join', value, start: pos, end });
+ tokens.push(repeated);
+ pos = repeated.end;
+ continue;
+ }
+ }
+ tokens.push({ type: isJoin ? 'join' : 'text', value, start: pos, end, quoted });
+ pos = end;
+ }
+ return tokens;
+ };
+
+ // The tokens for a whole clause, or null if what follows the field name
+ // doesn't make one
+ function _readClauseTokens(self, text, start, field) {
+ let definition = self.getField(field.name);
+ // A colon may be spaced away from its field ("tag : foo")
+ let afterField = field.end + /^\s*/.exec(text.slice(field.end))[0].length;
+ let operator = null;
+ if (text[afterField] === ':') {
+ operator = { name: ':', end: afterField + 1 };
+ }
+ else {
+ // The longest reading the condition supports, so `date is before
+ // 2020` compares dates while `title is before` matches a title
+ // against "before"
+ for (let match of _matchPhrases(text, afterField, _operatorPhrases)) {
+ if (definition.operators.includes(OPERATOR_WORDS[match.name])) {
+ operator = match;
+ break;
+ }
+ }
+ // A colon after a word operator ("year before:2020") is
+ // punctuation, not part of the value
+ if (operator) {
+ let colon = /^\s*:/.exec(text.slice(operator.end));
+ if (colon) {
+ operator = { ...operator, end: operator.end + colon[0].length };
+ }
+ }
+ }
+ if (!operator) {
+ return null;
+ }
+ let operatorName = operator.name === ':'
+ ? (definition.operator || _defaultOperator(definition))
+ : OPERATOR_WORDS[operator.name];
+ if (!operatorName || !definition.operators.includes(operatorName)) {
+ return null;
+ }
+ let tokens = [
+ {
+ type: 'field',
+ value: text.slice(start, field.end),
+ name: field.name,
+ start,
+ end: field.end
+ },
+ {
+ type: 'operator',
+ value: text.slice(field.end, operator.end).trim(),
+ name: operatorName,
+ start: field.end,
+ end: operator.end
+ }
+ ];
+ if (UNARY.has(operatorName)) {
+ return { tokens, end: operator.end };
+ }
+ let valueStart = operator.end + /^\s*/.exec(text.slice(operator.end))[0].length;
+ if (valueStart >= text.length) {
+ // The value is still to come, as after completing a condition name.
+ // The clause is recognized -- so it's highlighted, and it isn't text
+ // to search for -- but it has nothing to match on yet.
+ tokens[tokens.length - 1].pending = true;
+ return { tokens, end: operator.end };
+ }
+ if (/[()]/.test(text[valueStart])) {
+ return null;
+ }
+ let value = _readValue(self, text, valueStart, definition, operatorName);
+ if (!value || !value.value) {
+ return null;
+ }
+ tokens.push({
+ type: 'value',
+ value: value.value,
+ field: field.name,
+ operator: operatorName,
+ start: operator.end,
+ end: value.end,
+ quoted: value.quoted
+ });
+ return { tokens, end: value.end };
+ }
+
+ // A value is a word, a quoted string, a duration ("3 days"), or one of the
+ // values the condition accepts, which can run to several words
+ // ("book section")
+ function _readValue(self, text, pos, definition, operatorName) {
+ if (operatorName === 'isInTheLast') {
+ let duration = DURATION_RE.exec(text.slice(pos));
+ if (!duration) {
+ return null;
+ }
+ let count = parseInt(duration[1]);
+ let unit = duration[2].toLowerCase();
+ if (unit === 'week') {
+ count *= 7;
+ unit = 'day';
+ }
+ return { value: count + ' ' + unit + 's', end: pos + duration[0].length };
+ }
+ let values = self.getValues(definition.condition);
+ if (values) {
+ // A condition with a fixed set of values doesn't take any other,
+ // so anything else isn't a clause at all
+ if (QUOTES.includes(text[pos])) {
+ let quoted = _readWord(text, pos);
+ let stored = values[quoted.value.toLowerCase()];
+ return stored ? { value: stored, end: quoted.end, quoted: true } : null;
+ }
+ let phrase = _matchPhrase(text, pos, _getValuePhrases(self, definition.condition));
+ return phrase ? { value: values[phrase.name], end: phrase.end } : null;
+ }
+ // An unterminated quote is an unfinished value, not a value that
+ // starts with a quotation mark
+ if (QUOTES.includes(text[pos]) && !_readWord(text, pos).quoted) {
+ return null;
+ }
+ let word = _readWord(text, pos);
+ if (word.end <= pos) {
+ return null;
+ }
+ // Conditions that filter their own values (a count has to be a number)
+ // reject anything else, so it isn't a clause
+ let filter = Zotero.SearchConditions.get(definition.condition).inlineFilter;
+ if (filter && filter(word.value) === false) {
+ return null;
+ }
+ return { value: word.value, end: word.end, quoted: word.quoted };
+ }
+
+ // The value after an `or`, read for the same field and operator as the
+ // clause the `or` follows
+ function _readRepeatedValue(self, text, pos, previous) {
+ let definition = self.getField(previous.field);
+ let valueStart = pos + /^\s*/.exec(text.slice(pos))[0].length;
+ if (valueStart >= text.length || /[()]/.test(text[valueStart])) {
+ return null;
+ }
+ // Something written as a clause of its own is one, even if it doesn't
+ // name a condition or take the value it was given -- it's text, not a
+ // value for the previous field
+ if (_looksLikeClause(text, valueStart) || _readPrefixClause(self, text, valueStart)
+ || _readCountPhrase(text, valueStart)) {
+ return null;
+ }
+ let value = _readValue(self, text, valueStart, definition, previous.operator);
+ if (!value || !value.value) {
+ return null;
+ }
+ return {
+ type: 'value',
+ value: value.value,
+ field: previous.field,
+ operator: previous.operator,
+ start: pos,
+ end: value.end,
+ quoted: value.quoted
+ };
+ }
+
+ // The clause a prefix operator and its target make: a count comparison
+ // for something countable (`no annotation` -> numAnnotations is 0), or
+ // the operator itself on a condition that takes it (`no doi` -> DOI
+ // isEmpty). Null if the target is neither.
+ function _resolvePrefixClause(self, operatorName, targetName) {
+ let count = PREFIX_COUNT_CONDITIONS[targetName];
+ if (count) {
+ let comparison = PREFIX_COUNT_COMPARISONS[operatorName];
+ return comparison ? { condition: count, ...comparison } : null;
+ }
+ let field = self.getField(targetName);
+ if (!field || !field.operators.includes(operatorName)) {
+ return null;
+ }
+ // On a child-scoped field, an empty-field test matches items that have
+ // a child with the field empty, which isn't what `no` says, so `no`
+ // doesn't apply there
+ if (field.level && operatorName === 'isEmpty') {
+ return null;
+ }
+ let clause = { condition: field.condition, operator: operatorName, value: '' };
+ if (field.level) {
+ clause.level = field.level;
+ }
+ return clause;
+ }
+
+ // `no doi` or `has annotation` -- an operator written before what it
+ // tests. A target that doesn't resolve keeps the operator word as text.
+ function _readPrefixClause(self, text, pos) {
+ let prefix = _matchPhrase(text, pos, _prefixOperatorPhrases);
+ if (!prefix || !/\s/.test(text[prefix.end] || '')) {
+ return null;
+ }
+ let operatorEnd = prefix.end;
+ let targetStart = operatorEnd + /^\s*/.exec(text.slice(operatorEnd))[0].length;
+ let target = _matchPhrase(text, targetStart, _fieldPhrases)
+ || _matchPhrase(text, targetStart, _prefixCountPhrases);
+ if (!target) {
+ return null;
+ }
+ let clause = _resolvePrefixClause(self, PREFIX_OPERATORS[prefix.name], target.name);
+ if (!clause) {
+ return null;
+ }
+ // Followed by a colon or an operator, the target starts a clause of
+ // its own, and the prefix is text
+ let after = target.end + /^\s*/.exec(text.slice(target.end))[0].length;
+ if (text[after] === ':' || _matchPhrase(text, after, _operatorPhrases)) {
+ return null;
+ }
+ return {
+ tokens: [
+ {
+ type: 'operator',
+ value: text.slice(pos, operatorEnd),
+ name: clause.operator,
+ // The parser takes the whole clause from here rather than
+ // reinterpreting the target name
+ clause,
+ start: pos,
+ end: operatorEnd
+ },
+ {
+ type: 'field',
+ value: text.slice(operatorEnd, target.end),
+ name: target.name,
+ start: operatorEnd,
+ end: target.end
+ }
+ ],
+ end: target.end
+ };
+ }
+
+ // A whole phrase a locale offers for a count test, read as a single token
+ // that is a clause by itself
+ function _readCountPhrase(text, pos) {
+ let phrase = _matchPhrase(text, pos, _countPhrasePhrases);
+ if (!phrase) {
+ return null;
+ }
+ return {
+ tokens: [{
+ type: 'field',
+ value: text.slice(pos, phrase.end),
+ name: phrase.name,
+ clause: { ..._countPhrases[phrase.name] },
+ start: pos,
+ end: phrase.end
+ }],
+ end: phrase.end
+ };
+ }
+
+ // Whether a field name followed by a colon or an operator starts here,
+ // whether or not it makes a usable clause
+ function _looksLikeClause(text, pos) {
+ let field = _matchPhrase(text, pos, _fieldPhrases);
+ if (!field) {
+ return false;
+ }
+ let after = field.end + /^\s*/.exec(text.slice(field.end))[0].length;
+ return text[after] === ':' || !!_matchPhrase(text, after, _operatorPhrases);
+ }
+
+ function _initPhrases(self) {
+ if (!_fieldPhrases) {
+ _fieldPhrases = _phrasesByLength(self.getFieldNames());
+ _operatorPhrases = _phrasesByLength(Object.keys(OPERATOR_WORDS));
+ _prefixOperatorPhrases = _phrasesByLength(Object.keys(PREFIX_OPERATORS));
+ _prefixCountPhrases = _phrasesByLength(Object.keys(PREFIX_COUNT_CONDITIONS));
+ _countPhrases = {};
+ for (let [id, clause] of Object.entries(COUNT_PHRASE_KEYWORDS)) {
+ let translated;
+ try {
+ translated = Zotero.ftl.formatValueSync(id);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ for (let phrase of (translated || '').split(',')) {
+ phrase = phrase.trim().toLowerCase().replace(/\s+/g, ' ');
+ if (phrase && !_countPhrases[phrase]) {
+ _countPhrases[phrase] = clause;
+ }
+ }
+ }
+ _countPhrasePhrases = _phrasesByLength(Object.keys(_countPhrases));
+ }
+ }
+
+ function _getValuePhrases(self, condition) {
+ if (!_valuePhrases[condition]) {
+ _valuePhrases[condition] = _phrasesByLength(Object.keys(self.getValues(condition)));
+ }
+ return _valuePhrases[condition];
+ }
+
+ /**
+ * Parse a query into a condition tree and the free text left over.
+ *
+ * @param {String} text
+ * @return {Object} - { tree, text }, where tree is
+ * { joinMode, children: [...] } with children being clauses
+ * ({ condition, operator, value }) or nested trees, and text is the
+ * free text, in input order
+ */
+ this.parse = function (text) {
+ let tokens = this.tokenize(text).filter(token => token.type !== 'space');
+ let state = { tokens, pos: 0, ranges: [], text, pending: false };
+ let group = _parseSequence(state, false);
+ // Without a clause anywhere, the whole query is text as typed
+ if (!group.children.length && !state.pending) {
+ return { tree: null, text: text.trim().replace(/\s+/g, ' ') };
+ }
+ return {
+ tree: group.children.length
+ ? { joinMode: group.joinMode, children: group.children }
+ : null,
+ text: _freeText(state)
+ };
+ };
+
+ /**
+ * Build a search from a query, or false if the query has no clauses and so
+ * is just text to search for.
+ *
+ * Free text is matched the way the given quick search mode matches text, so
+ * `by:smith crispr` filters by creator and searches the rest for "crispr"
+ * in whatever fields the mode covers.
+ *
+ * @param {String} query
+ * @param {Object} [options]
+ * @param {Number} [options.libraryID]
+ * @param {String} [options.mode] - A quick search mode
+ * @return {Zotero.Search|false}
+ */
+ this.getSearch = function (query, { libraryID, mode } = {}) {
+ let { tree, text } = this.parse(query);
+ if (!tree) {
+ return false;
+ }
+ let search = new Zotero.Search();
+ if (libraryID) {
+ search.libraryID = libraryID;
+ }
+ // The query describes items, so return items: a condition that
+ // matches on a child -- attachment content, a tag on an attachment --
+ // maps up to the item that owns it, rather than each condition
+ // filtering at whatever level it happens to match, which would give
+ // `type:book attachment content:crypto` nothing.
+ // But a child type names the rows themselves, so it sets the result
+ // level instead of being a condition: the level is the type filter,
+ // and the other conditions map to it -- `type:attachment by:smith`
+ // means Smith's attachments.
+ let resultLevel = 'item';
+ if (tree.joinMode === 'all') {
+ let pins = tree.children.filter(child => !child.children
+ && child.condition === 'itemType' && child.operator === 'is'
+ && ['attachment', 'note', 'annotation'].includes(child.value));
+ // Conflicting types stay conditions, and honestly match nothing
+ if (pins.length && new Set(pins.map(pin => pin.value)).size === 1) {
+ resultLevel = pins[0].value;
+ tree = { ...tree, children: tree.children.filter(child => !pins.includes(child)) };
+ }
+ }
+ search.addCondition('resultLevel', resultLevel);
+ // Free text is joined to the clauses with "all", so an "any" query
+ // becomes a group rather than something the text is OR'd into
+ this.addToSearch(search, text && tree.joinMode === 'any'
+ ? { joinMode: 'all', children: [tree] }
+ : tree);
+ if (text) {
+ if (mode === 'bestMatch' && Zotero.Embeddings?.isEnabled()) {
+ search.addCondition('bestMatch', 'contains', text);
+ }
+ else {
+ let quicksearchMode = ['titleCreatorYear', 'everything'].includes(mode)
+ ? mode
+ : 'fields';
+ search.addCondition('quicksearch-' + quicksearchMode, 'contains', text);
+ }
+ }
+ return search;
+ };
+
+ /**
+ * Add a parsed query's conditions to a search.
+ *
+ * @param {Zotero.Search} search
+ * @param {Object} tree - From parse()
+ */
+ this.addToSearch = function (search, tree) {
+ if (!tree) {
+ return;
+ }
+ if (tree.joinMode && tree.joinMode !== 'all') {
+ search.addCondition('joinMode', tree.joinMode);
+ }
+ for (let child of tree.children) {
+ _addNode(search, child);
+ }
+ };
+
+ function _addNode(search, node) {
+ if (node.children) {
+ search.addCondition('groupStart', 'true', '');
+ search.addCondition('joinMode', node.joinMode);
+ for (let child of node.children) {
+ _addNode(search, child);
+ }
+ search.addCondition('groupEnd', 'true', '');
+ return;
+ }
+ // A condition that matches at a child level is scoped to it, so the
+ // match maps up to the item the search returns
+ if (node.level) {
+ search.addCondition('groupStart', 'true', '');
+ search.addCondition('resultLevel', node.level);
+ search.addCondition(node.condition, node.operator, node.value);
+ search.addCondition('groupEnd', 'true', '');
+ return;
+ }
+ search.addCondition(node.condition, node.operator, node.value);
+ }
+
+ // Read a bare or quoted word, returning where it ends
+ function _readWord(text, pos) {
+ if (QUOTES.includes(text[pos])) {
+ let end = text.indexOf(CLOSING_QUOTES[text[pos]], pos + 1);
+ if (end !== -1) {
+ return { value: text.slice(pos + 1, end), end: end + 1, quoted: true };
+ }
+ }
+ let end = pos;
+ while (end < text.length && !/[\s()]/.test(text[end])) {
+ end++;
+ }
+ return { value: text.slice(pos, end), end, quoted: false };
+ }
+
+ function _phrasesByLength(names) {
+ return names
+ .map(name => ({ name, words: name.split(' ') }))
+ .sort((a, b) => b.words.length - a.words.length);
+ }
+
+ // Match the longest phrase from `phrases` at `pos`, allowing any run of
+ // whitespace between words. Returns { name, end } or null.
+ function _matchPhrase(text, pos, phrases) {
+ for (let phrase of phrases) {
+ let end = _matchWords(text, pos, phrase.words);
+ if (end !== -1) {
+ return { name: phrase.name, end };
+ }
+ }
+ return null;
+ }
+
+ // Every phrase that matches at `pos`, longest first
+ function _matchPhrases(text, pos, phrases) {
+ let matches = [];
+ for (let phrase of phrases) {
+ let end = _matchWords(text, pos, phrase.words);
+ if (end !== -1) {
+ matches.push({ name: phrase.name, end });
+ }
+ }
+ return matches;
+ }
+
+ function _matchWords(text, pos, words) {
+ let at = pos;
+ for (let i = 0; i < words.length; i++) {
+ if (i) {
+ let space = /^\s+/.exec(text.slice(at));
+ if (!space) {
+ return -1;
+ }
+ at += space[0].length;
+ }
+ let word = text.substr(at, words[i].length);
+ if (word.toLowerCase() !== words[i]) {
+ return -1;
+ }
+ at += word.length;
+ // The phrase has to end at a word boundary
+ if (i === words.length - 1 && at < text.length && !/[\s():]/.test(text[at])) {
+ return -1;
+ }
+ }
+ return at;
+ }
+
+ function _defaultOperator(field) {
+ // A condition with a list of values to choose from, or a tag, has
+ // discrete values rather than text to match within, so `tag:foo` means
+ // the tag foo rather than tags containing "foo"
+ let discrete = field.condition == 'tag' || !!VALUE_LOOKUPS[field.condition];
+ if (discrete && field.operators.includes('is')) {
+ return 'is';
+ }
+ for (let operator of ['contains', 'is']) {
+ if (field.operators.includes(operator)) {
+ return operator;
+ }
+ }
+ return field.operators[0];
+ }
+
+ // Parse a run of clauses, groups, and join words, stopping at a closing
+ // paren when inside a group. Anything else is free text, kept as the text
+ // that was typed rather than as reassembled tokens.
+ function _parseSequence(state, inGroup) {
+ let children = [];
+ // The join word before each child, so `and` can bind tighter than `or`
+ let joins = [];
+ let closeRange = null;
+ while (state.pos < state.tokens.length) {
+ let token = state.tokens[state.pos];
+ if (token.type === 'paren' && token.value === ')') {
+ state.pos++;
+ if (inGroup) {
+ closeRange = [token.start, token.end];
+ break;
+ }
+ state.ranges.push([token.start, token.end]);
+ continue;
+ }
+ if (token.type === 'paren' && token.value === '(') {
+ state.pos++;
+ let group = _parseSequence(state, true);
+ if (group.children.length) {
+ children.push({ joinMode: group.joinMode, children: group.children });
+ }
+ else {
+ // Parentheses around no clauses are part of the text
+ state.ranges.push([token.start, token.end]);
+ if (group.closeRange) {
+ state.ranges.push(group.closeRange);
+ }
+ }
+ continue;
+ }
+ if (token.type === 'join') {
+ let joinMode = JOIN_WORDS[token.value.toLowerCase()];
+ if (children.length) {
+ joins[children.length] = joinMode;
+ }
+ state.pos++;
+ // A value alone after `or` repeats the field of the clause
+ // before it (see the tokenizer), so `tag is foo or bar` means
+ // two tag conditions
+ let next = state.tokens[state.pos];
+ let previous = children[children.length - 1];
+ if (next && next.type === 'value' && previous && !previous.children) {
+ children.push({ ...previous, value: next.value });
+ state.pos++;
+ }
+ continue;
+ }
+ // A clause whose value is still to come has nothing to match on, and
+ // isn't text to search for either (see _readClauseTokens())
+ if (token.type === 'field' && state.tokens[state.pos + 1]?.pending) {
+ state.pending = true;
+ state.pos += 2;
+ continue;
+ }
+ let clause = _readClause(state);
+ if (clause) {
+ children.push(clause);
+ continue;
+ }
+ state.ranges.push([token.start, token.end]);
+ state.pos++;
+ }
+ return { ..._join(children, joins), closeRange };
+ }
+
+ // `and` binds tighter than `or`, as it does everywhere else, so
+ // `a or b and c` is `a or (b and c)`. Runs of and-joined children become
+ // nested groups, which the search supports.
+ function _join(children, joins) {
+ if (children.length < 2) {
+ return { joinMode: 'all', children };
+ }
+ let runs = [[children[0]]];
+ for (let i = 1; i < children.length; i++) {
+ if (joins[i] === 'any') {
+ runs.push([children[i]]);
+ }
+ else {
+ runs[runs.length - 1].push(children[i]);
+ }
+ }
+ if (runs.length === 1) {
+ return { joinMode: 'all', children };
+ }
+ return {
+ joinMode: 'any',
+ children: runs.map(run => run.length === 1
+ ? run[0]
+ : { joinMode: 'all', children: run })
+ };
+ }
+
+ function _readClause(state) {
+ let token = state.tokens[state.pos];
+ if (!token) {
+ return null;
+ }
+ // A token carrying its resolved clause: a prefix operator, whose target
+ // follows it, or a count phrase that is a clause by itself (see the
+ // tokenizer)
+ if (token.clause) {
+ state.pos += token.type === 'operator' ? 2 : 1;
+ return { ...token.clause };
+ }
+ if (token.type !== 'field') {
+ return null;
+ }
+ let field = Zotero.SearchQuery.getField(token.name);
+ let operator = state.tokens[state.pos + 1].name;
+ if (NAME_OPERATOR_OVERRIDES[operator] && _isNameCondition(field.condition)) {
+ operator = NAME_OPERATOR_OVERRIDES[operator];
+ }
+ let clause = { condition: field.condition, operator, value: '' };
+ if (UNARY.has(operator)) {
+ state.pos += 2;
+ }
+ else {
+ state.pos += 3;
+ clause.value = state.tokens[state.pos - 1].value;
+ }
+ if (field.level) {
+ clause.level = field.level;
+ }
+ return clause;
+ }
+
+ // The text that was typed for everything that wasn't a clause, with runs
+ // that were adjacent in the input kept together
+ function _freeText(state) {
+ let merged = [];
+ for (let range of state.ranges.slice().sort((a, b) => a[0] - b[0])) {
+ let last = merged[merged.length - 1];
+ if (last && last[1] === range[0]) {
+ last[1] = range[1];
+ }
+ else {
+ merged.push(range.slice());
+ }
+ }
+ return merged.map(([start, end]) => state.text.slice(start, end)).join(' ');
+ }
+};
diff --git a/chrome/content/zotero/zotero.mjs b/chrome/content/zotero/zotero.mjs
index 2af2386810..b71bc7cbf1 100644
--- a/chrome/content/zotero/zotero.mjs
+++ b/chrome/content/zotero/zotero.mjs
@@ -129,6 +129,7 @@ const xpcomFilesLocal = [
'retractions',
'router',
'schema',
+ 'searchQuery',
'server/server',
'server/server_integration',
'server/server_connector',
diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js
index c192f0084e..a90234d3a5 100644
--- a/chrome/content/zotero/zoteroPane.js
+++ b/chrome/content/zotero/zoteroPane.js
@@ -2040,9 +2040,12 @@ var ZoteroPane = new function () {
* @param {String} [mode='fields'] - The quick search mode to reproduce
*/
this.openAdvancedSearchFromQuickSearch = async function (searchText, mode = 'fields') {
- // Split into words (keeping quoted phrases intact), as the quick search does
- let parts = Zotero.SearchConditions.parseSearchString(searchText);
- if (!parts.length) {
+ // Conditions written in the query ("tag:foo") become conditions in the
+ // search; what's left is split into words (keeping quoted phrases
+ // intact), as the quick search does
+ let { tree, text } = Zotero.SearchQuery.parse(searchText);
+ let parts = Zotero.SearchConditions.parseSearchString(text);
+ if (!parts.length && !tree) {
await this.toggleAdvancedSearchState('open');
return;
}
@@ -2058,6 +2061,13 @@ var ZoteroPane = new function () {
// with "all": Title/Creator/Year and All Fields & Tags each map to a single
// condition, Everything to an "any" group of Any Field plus full-text.
// Title/Creator/Year matches only top-level items, so set the result level to item.
+ if (tree) {
+ // The words are joined to the conditions with "all", so an "any"
+ // query becomes a group rather than something they're OR'd into
+ Zotero.SearchQuery.addToSearch(search, parts.length && tree.joinMode === 'any'
+ ? { joinMode: 'all', children: [tree] }
+ : tree);
+ }
if (mode === 'titleCreatorYear') {
search.addCondition('resultLevel', 'item');
}
@@ -3194,15 +3204,38 @@ var ZoteroPane = new function () {
}
var search = document.getElementById('zotero-tb-search');
var searchVal = search.searchTextbox.value;
- // An unclosed quotation mark means a phrase is still being typed, so
- // wait for the closing quote, or an explicit Enter, to search
- if (!runAdvanced && (searchVal.match(/"/g) || []).length % 2) {
+ if (!runAdvanced && Zotero.SearchQuery.hasOpenQuote(searchVal)) {
return;
}
var spinner = document.getElementById('zotero-tb-search-spinner');
spinner.setAttribute("status", "animate");
spinner.style.visibility = 'visible';
- await this.itemsView.setFilter('search', searchVal);
+ // A query with conditions in it ("by:smith crispr") filters by those
+ // and matches whatever text is left over the way the current mode
+ // does; anything else is text to search for, as typed
+ // Each selected row scopes the query to its own library, so the query
+ // carries none of its own -- with one, a multi-library selection would
+ // match only in that library
+ let query = Zotero.SearchQuery.getSearch(searchVal, {
+ mode: Zotero.Prefs.get('search.quicksearch-mode')
+ });
+ if (query) {
+ this._quickSearchIsQuery = true;
+ await this.itemsView.setFilter('search', '');
+ await this.itemsView.setFilter('advanced-search', query);
+ }
+ else {
+ // Only clear a search this set, so an open Advanced Search keeps
+ // its own
+ if (this._quickSearchIsQuery) {
+ this._quickSearchIsQuery = false;
+ await this.itemsView.setFilter('advanced-search', null);
+ }
+ // A condition whose value hasn't been typed yet is left out of the
+ // text, so completing a condition name doesn't search for it
+ await this.itemsView.setFilter('search',
+ Zotero.SearchQuery.parse(searchVal).text);
+ }
spinner.style.removeProperty("visibility");
spinner.removeAttribute("status");
};
diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl
index a1a778299c..9f3b98a3d5 100644
--- a/chrome/locale/en-US/zotero/zotero.ftl
+++ b/chrome/locale/en-US/zotero/zotero.ftl
@@ -993,6 +993,35 @@ search-conditions-tooltip-fields = Fields:
search-conditions-collection = Collection
search-conditions-savedSearch = Saved Search
search-conditions-itemTypeID = Item Type
+
+# Words that can be typed before a colon in the search box to search a
+# particular field, as in "by:smith" or "year:2020". Comma-separated; keep them
+# short and lowercase, and don't reuse a word that means something else here.
+search-query-keyword-creator = by
+search-query-keyword-publication = in, publication, journal
+search-query-keyword-item-type = type
+search-query-keyword-language = lang
+search-query-keyword-abstract = abstract
+search-query-keyword-fulltext = fulltext, text
+search-query-keyword-date = year
+# Typed as "before:2020", meaning items dated before 2020
+search-query-keyword-date-before = before
+# Typed as "after:2020" or "since:2020", meaning items dated after 2020
+search-query-keyword-date-after = after, since
+# Typed as "added:", meaning when the item was saved to the library
+search-query-keyword-date-added = added
+search-query-keyword-date-modified = modified
+# Whole phrases that can be typed to find items that have none or some of
+# something. Each is a comma-separated list, and the phrases are matched
+# exactly as written, so include every form someone would type.
+search-query-keyword-no-annotations = no annotations
+search-query-keyword-has-annotations = has annotations
+search-query-keyword-no-notes = no notes
+search-query-keyword-has-notes = has notes
+search-query-keyword-no-tags = no tags
+search-query-keyword-has-tags = has tags
+search-query-keyword-no-attachments = no attachments
+search-query-keyword-has-attachments = has attachments
search-conditions-tag = Tag
search-conditions-numTags = # of Tags
search-conditions-numNotes = # of Notes
diff --git a/chrome/skin/default/zotero/query-textbox.css b/chrome/skin/default/zotero/query-textbox.css
new file mode 100644
index 0000000000..cb0581a8e4
--- /dev/null
+++ b/chrome/skin/default/zotero/query-textbox.css
@@ -0,0 +1,49 @@
+/* The colored copy of a search query, drawn behind the input (see
+ queryTextbox.js). The input's own text is transparent while a condition is
+ recognized, so what's read on screen is this layer. */
+
+:host {
+ position: relative;
+}
+
+.query-highlight {
+ position: absolute;
+ overflow: hidden;
+ pointer-events: none;
+ box-sizing: border-box;
+ color: FieldText;
+ /* An input centers its line in its content box, so the layer has to too */
+ display: flex;
+ align-items: center;
+ white-space: pre;
+}
+
+:host([highlighted]) input {
+ color: transparent;
+ caret-color: FieldText;
+}
+
+/* A translucent selection lets the layer show through, since the text painted
+ over it is transparent */
+:host([highlighted]) input::selection {
+ background-color: color-mix(in srgb, Highlight 35%, transparent);
+ color: transparent;
+}
+
+.query-token-field {
+ color: var(--accent-blue, #2ea8e5);
+}
+
+.query-token-operator {
+ color: var(--fill-secondary, #6b6b6b);
+}
+
+.query-token-value {
+ color: var(--accent-green, #5fb236);
+}
+
+.query-token-join,
+.query-token-paren {
+ color: var(--fill-secondary, #6b6b6b);
+ font-weight: 600;
+}
diff --git a/scss/components/_search.scss b/scss/components/_search.scss
index 36529955e5..4cdc3c18b6 100644
--- a/scss/components/_search.scss
+++ b/scss/components/_search.scss
@@ -54,7 +54,87 @@ input::-moz-search-clear-button {
}
}
-search-textbox {
+// The list of completions for what's being typed in a query (see
+// queryTextbox.js)
+.query-completions {
+ --panel-padding: 4px 0;
+
+ richlistbox {
+ appearance: none;
+ background: transparent;
+ border: none;
+ margin: 0;
+ padding: 0;
+ min-width: 16em;
+ // Long lists scroll rather than being cut off
+ max-height: 20em;
+ }
+
+ richlistitem {
+ // A row is the name and, at the far end, what it searches, each as wide
+ // as its text, so the popup is as wide as its widest row
+ display: grid;
+ grid-template-columns: auto auto;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 3px 8px;
+
+ > * {
+ overflow: visible;
+ text-overflow: clip;
+ white-space: nowrap;
+ width: auto;
+ }
+
+ &:hover {
+ background-color: var(--fill-quinary);
+ }
+
+ // Focus stays in the search field while the list is open, so the
+ // selected row is the active one
+ &[selected] {
+ background-color: var(--color-accent);
+ color: var(--color-accent-text);
+
+ .completion-description {
+ color: inherit;
+ opacity: 0.8;
+ }
+
+ // A ring in the text color, so the swatch reads on the accent
+ // background whatever colors the two are
+ .completion-swatch {
+ box-shadow: 0 0 0 1px var(--color-accent-text);
+ }
+ }
+ }
+
+ .completion-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ // Colored values read as they do in the tag selector
+ &.colored {
+ font-weight: 600;
+ }
+ }
+
+ .completion-swatch {
+ flex: 0 0 auto;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
+ }
+
+ .completion-description {
+ color: var(--fill-secondary);
+ }
+}
+
+search-textbox,
+query-textbox {
appearance: none;
background: var(--material-background);
border-radius: 5px;
@@ -77,8 +157,8 @@ search-textbox {
}
}
-:is(search-textbox)::part(search-sign),
-:is(search-textbox)::part(search-icon) {
+:is(search-textbox, query-textbox)::part(search-sign),
+:is(search-textbox, query-textbox)::part(search-icon) {
@include svgicon-menu("magnifier", "universal", "16");
color: var(--fill-secondary);
// right: 2px padding + 8px dropmarker width + 4px padding + 6px padding
@@ -89,7 +169,7 @@ search-textbox {
display: block; // override fx115 display:none on macOS
}
-:is(search-textbox):-moz-locale-dir(rtl)::part(search-sign),
-:is(search-textbox):-moz-locale-dir(rtl)::part(search-icon) {
+:is(search-textbox, query-textbox):-moz-locale-dir(rtl)::part(search-sign),
+:is(search-textbox, query-textbox):-moz-locale-dir(rtl)::part(search-icon) {
transform: scaleX(-1);
}
diff --git a/scss/elements/_quickSearchTextbox.scss b/scss/elements/_quickSearchTextbox.scss
index 0dd5f3e37a..7d27be970f 100644
--- a/scss/elements/_quickSearchTextbox.scss
+++ b/scss/elements/_quickSearchTextbox.scss
@@ -51,8 +51,8 @@ quick-search-textbox {
font-size: 11px;
}
- :is(search-textbox)::part(search-sign),
- :is(search-textbox)::part(search-icon) {
+ :is(search-textbox, query-textbox)::part(search-sign),
+ :is(search-textbox, query-textbox)::part(search-icon) {
@include svgicon-menu("magnifier", "universal", "16");
color: var(--fill-secondary);
// right: 4px gap + 2px padding + 8px dropmarker width + 4px padding
@@ -62,7 +62,7 @@ quick-search-textbox {
margin: 0;
}
- :is(search-textbox)::part(search-input) {
+ :is(search-textbox, query-textbox)::part(search-input) {
padding: 0;
}
}
diff --git a/scss/win/components/_input.scss b/scss/win/components/_input.scss
index f29687b43e..5646034100 100644
--- a/scss/win/components/_input.scss
+++ b/scss/win/components/_input.scss
@@ -59,6 +59,7 @@
),
textbox,
search-textbox,
+ query-textbox,
textarea
):where(:not([no-native])) {
appearance: none;
diff --git a/test/tests/advancedSearchTest.js b/test/tests/advancedSearchTest.js
index 52471d59af..5fe3990f4b 100644
--- a/test/tests/advancedSearchTest.js
+++ b/test/tests/advancedSearchTest.js
@@ -68,6 +68,36 @@ describe("Advanced Search", function () {
assert.isFalse(row.setAdvancedSearch(null));
});
+ it("should seed conditions written in the quick search query", async function () {
+ var match = await createDataObject('item', { title: "alpha beta" });
+ match.addTag('zztag');
+ await match.saveTx();
+ var wrongTag = await createDataObject('item', { title: "alpha beta" });
+ await wrongTag.saveTx();
+
+ await zp.openAdvancedSearchFromQuickSearch('tag:zztag alpha', 'fields');
+ var iv = zp.itemsView;
+ await iv.waitForLoad();
+
+ var conditions = Object.values(deck.pane.search.getConditions());
+ var tag = conditions.find(c => c.condition === 'tag');
+ assert.isDefined(tag);
+ assert.equal(tag.value, 'zztag');
+ assert.equal(tag.operator, 'is');
+ // The rest of the query is still words to match
+ assert.sameMembers(
+ conditions.filter(c => c.condition === 'anyField').map(c => c.value),
+ ['alpha']
+ );
+
+ assert.equal(iv.rowCount, 1);
+ assert.isNumber(iv.getRowIndexByID(match.id));
+
+ await zp.setAdvancedSearchState('closed');
+ await iv.waitForLoad();
+ await Zotero.Items.erase([match.id, wrongTag.id]);
+ });
+
it("should seed from the quick search text and reset on close", async function () {
var match = await createDataObject('item', { title: "alpha beta" });
var partial = await createDataObject('item', { title: "alpha gamma" });
diff --git a/test/tests/queryTextboxTest.js b/test/tests/queryTextboxTest.js
new file mode 100644
index 0000000000..53a48457a2
--- /dev/null
+++ b/test/tests/queryTextboxTest.js
@@ -0,0 +1,159 @@
+"use strict";
+
+describe("query-textbox", function () {
+ var win, textbox;
+
+ before(async function () {
+ win = await loadZoteroPane();
+ textbox = win.document.getElementById('zotero-tb-search').searchTextbox;
+ });
+
+ after(async function () {
+ await Zotero.Tags.setColor(Zotero.Libraries.userLibraryID, 'zzcompletion', false);
+ textbox.value = '';
+ win.close();
+ });
+
+ it("should render the query in spans covering the whole value", function () {
+ textbox.value = 'by:smith crispr';
+ let layer = textbox.shadowRoot.querySelector('.query-highlight');
+ assert.equal(layer.textContent, 'by:smith crispr');
+ assert.deepEqual(
+ [...layer.children].map(span => span.className),
+ ['query-token-field', 'query-token-operator', 'query-token-value',
+ 'query-token-space', 'query-token-text']
+ );
+ assert.isTrue(textbox.hasAttribute('highlighted'));
+ });
+
+ it("should leave a query without conditions to the input", function () {
+ textbox.value = 'just some words';
+ assert.isFalse(textbox.hasAttribute('highlighted'));
+ assert.equal(
+ textbox.shadowRoot.querySelector('.query-highlight').textContent, ''
+ );
+ });
+
+ it("should offer tags and creators from the library", async function () {
+ let item = await createDataObject('item', { tags: [{ tag: 'zzcompletion' }] });
+ item.setCreator(0, { firstName: 'Zora', lastName: 'Zzcompletion', creatorType: 'author' });
+ // A two-field creator with only a last name, whose value has no
+ // leading space to quote
+ item.setCreator(1, { firstName: '', lastName: 'Zzlastonly', creatorType: 'author' });
+ await item.saveTx();
+
+ // updateCompletions() resolves once the library lookup has filled
+ // the list
+ let offered = async (query, value) => {
+ textbox.value = query;
+ textbox.inputField.setSelectionRange(query.length, query.length);
+ await textbox.updateCompletions();
+ let list = win.document.querySelector('.query-completions richlistbox');
+ assert.isTrue(
+ [...list.children].some(row => row.completion.label === value), value
+ );
+ };
+ await Zotero.Tags.setColor(Zotero.Libraries.userLibraryID, 'zzcompletion', '#FF6666');
+ await offered('tag:zzcomp', 'zzcompletion');
+ // A colored tag carries its color, for the swatch in the list
+ let list = win.document.querySelector('.query-completions richlistbox');
+ let row = [...list.children].find(r => r.completion.label === 'zzcompletion');
+ assert.equal(row.completion.color, '#FF6666');
+ await offered('by:zzcomp', 'Zora Zzcompletion');
+ await offered('by:zzlast', 'Zzlastonly');
+ });
+
+ it("should offer completions for a condition name and then its values", async function () {
+ textbox.value = 'ty';
+ textbox.inputField.setSelectionRange(2, 2);
+ textbox.updateCompletions();
+ let list = win.document.querySelector('.query-completions richlistbox');
+ // The list is shown the first time there's something to offer
+ assert.notEqual(win.document.querySelector('.query-completions').state, 'closed');
+ let labels = [...list.children].map(row => row.completion.label);
+ assert.include(labels, 'type:');
+
+ // Completing a condition name offers what it takes next
+ list.selectedIndex = labels.indexOf('type:');
+ textbox._acceptCompletion();
+ assert.equal(textbox.value, 'type:');
+ assert.equal(textbox.inputField.selectionStart, 'type:'.length);
+ labels = [...list.children].map(row => row.completion.label);
+ assert.include(labels, 'journal article');
+
+ // Arrowing up from nothing selected starts at the end of the list
+ textbox._handleCompletionKey(new win.KeyboardEvent('keydown', { key: 'ArrowUp' }));
+ assert.equal(list.selectedIndex, list.itemCount - 1);
+
+ list.selectedIndex = labels.indexOf('journal article');
+ textbox._acceptCompletion();
+ assert.equal(textbox.value, 'type:"journal article"');
+ assert.deepEqual(
+ Zotero.SearchQuery.parse(textbox.value).tree.children,
+ [{ condition: 'itemType', operator: 'is', value: 'journalArticle' }]
+ );
+
+ textbox.value = 'crispr ';
+ textbox.inputField.setSelectionRange(7, 7);
+ textbox.updateCompletions();
+ assert.equal(win.document.querySelector('.query-completions').state, 'closed');
+ });
+
+ it("should accept the completion under the mouse", async function () {
+ textbox.value = 'ty';
+ textbox.inputField.setSelectionRange(2, 2);
+ await textbox.updateCompletions();
+ let list = win.document.querySelector('.query-completions richlistbox');
+ let row = [...list.children].find(r => r.completion.label === 'type:');
+ row.dispatchEvent(new win.MouseEvent('mousedown', { bubbles: true }));
+ assert.equal(textbox.value, 'type:');
+ });
+
+ it("should not reopen the list after accepting a value", async function () {
+ await createDataObject('item', { tags: [{ tag: 'zzaccepted' }] });
+ textbox.value = 'tag:zzaccep';
+ textbox.inputField.setSelectionRange(11, 11);
+ await textbox.updateCompletions();
+ let list = win.document.querySelector('.query-completions richlistbox');
+ let index = [...list.children].findIndex(row => row.completion.label === 'zzaccepted');
+ assert.isAbove(index, -1);
+ list.selectedIndex = index;
+ // Control the follow-up lookup, so the result demonstrably arrives
+ // only after the acceptance has hidden the list
+ let resolveLookup;
+ let stub = sinon.stub(textbox, '_lookupValues').returns(new Promise((resolve) => {
+ resolveLookup = resolve;
+ }));
+ try {
+ textbox._acceptCompletion();
+ assert.equal(textbox.value, 'tag:zzaccepted');
+ resolveLookup([{ text: 'zzaccepted', label: 'zzaccepted' }]);
+ await Zotero.Promise.delay(0);
+ assert.include(['closed', 'hiding'],
+ win.document.querySelector('.query-completions').state);
+ }
+ finally {
+ stub.restore();
+ }
+ });
+
+ it("should close the completion list on Enter without a selection", async function () {
+ textbox.value = 'ty';
+ textbox.inputField.setSelectionRange(2, 2);
+ await textbox.updateCompletions();
+ assert.notEqual(win.document.querySelector('.query-completions').state, 'closed');
+ textbox._handleCompletionKey(new win.KeyboardEvent('keydown', { key: 'Enter' }));
+ assert.include(['closed', 'hiding'],
+ win.document.querySelector('.query-completions').state);
+ });
+
+ it("should hide completions when the value is set", async function () {
+ textbox.value = 'ty';
+ textbox.inputField.setSelectionRange(2, 2);
+ await textbox.updateCompletions();
+ assert.notEqual(win.document.querySelector('.query-completions').state, 'closed');
+ textbox.value = 'something else';
+ assert.include(['closed', 'hiding'],
+ win.document.querySelector('.query-completions').state);
+ });
+});
diff --git a/test/tests/searchQueryTest.js b/test/tests/searchQueryTest.js
new file mode 100644
index 0000000000..73fefcfc5a
--- /dev/null
+++ b/test/tests/searchQueryTest.js
@@ -0,0 +1,912 @@
+"use strict";
+
+describe("Zotero.SearchQuery", function () {
+ function clauses(query) {
+ let { tree } = Zotero.SearchQuery.parse(query);
+ return tree ? tree.children : [];
+ }
+
+ describe("#parse()", function () {
+ it("should parse a field, operator, and value", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("by:smith");
+ assert.equal(text, "");
+ assert.deepEqual(tree.children, [
+ { condition: 'creator', operator: 'contains', value: 'smith' }
+ ]);
+ });
+
+ it("should separate free text from clauses, in either order", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("by:smith crispr screens");
+ assert.equal(text, "crispr screens");
+ assert.lengthOf(tree.children, 1);
+ assert.equal(Zotero.SearchQuery.parse("crispr by:smith").text, "crispr");
+ });
+
+ it("should treat text that only looks like a clause as text", function () {
+ for (let query of ["10.1234/foo:bar", "Vol 2: The Return", "https://example.com/a:b"]) {
+ let { tree, text } = Zotero.SearchQuery.parse(query);
+ assert.isNull(tree, query);
+ assert.equal(text, query.replace(/\s+/g, ' '), query);
+ }
+ });
+ });
+
+ describe("operators and comparisons", function () {
+ it("should accept operator words as the Advanced Search reads", function () {
+ assert.deepEqual(clauses("title is grounded"), [
+ { condition: 'title', operator: 'is', value: 'grounded' }
+ ]);
+ assert.deepEqual(clauses("date before 2015"), [
+ { condition: 'date', operator: 'isBefore', value: '2015' }
+ ]);
+ assert.deepEqual(clauses("year since 2020"), [
+ { condition: 'date', operator: 'isAfter', value: '2020' }
+ ]);
+ // Every date-type field compares as a date
+ assert.deepEqual(clauses("filing date is before 2020"), [
+ { condition: 'filingDate', operator: 'isBefore', value: '2020' }
+ ]);
+ assert.deepEqual(clauses("original date is before 2020"), [
+ { condition: 'originalDate', operator: 'isBefore', value: '2020' }
+ ]);
+ });
+
+ it("should parse an operator of several words, preferring the longest", function () {
+ assert.deepEqual(clauses("title begins with grounded"), [
+ { condition: 'title', operator: 'beginsWith', value: 'grounded' }
+ ]);
+ assert.deepEqual(clauses("type is not book"), [
+ { condition: 'itemType', operator: 'isNot', value: 'book' }
+ ]);
+ });
+
+ it("should compare dates with before, after, and since as fields", function () {
+ assert.deepEqual(clauses("after:2020"), [
+ { condition: 'date', operator: 'isAfter', value: '2020' }
+ ]);
+ assert.deepEqual(clauses("before:2015"), [
+ { condition: 'date', operator: 'isBefore', value: '2015' }
+ ]);
+ });
+
+ it("should keep the unit in a relative date", async function () {
+ assert.deepEqual(clauses("added in the last 3 days"), [
+ { condition: 'dateAdded', operator: 'isInTheLast', value: '3 days' }
+ ]);
+ assert.equal(Zotero.SearchQuery.parse("added in the last 3 days").text, "");
+
+ let item = await createDataObject('item');
+ let search = Zotero.SearchQuery.getSearch("added in the last 3 days", {
+ libraryID: Zotero.Libraries.userLibraryID
+ });
+ assert.include(await search.search(), item.id);
+ });
+
+ it("should count weeks in days, which the date comparison understands", function () {
+ assert.deepEqual(clauses("added in the last 2 weeks"), [
+ { condition: 'dateAdded', operator: 'isInTheLast', value: '14 days' }
+ ]);
+ });
+
+ it("should read natural comparisons written with 'is'", function () {
+ assert.deepEqual(clauses("year is before 2020"), [
+ { condition: 'date', operator: 'isBefore', value: '2020' }
+ ]);
+ assert.deepEqual(clauses("added is in the last 2 weeks"), [
+ { condition: 'dateAdded', operator: 'isInTheLast', value: '14 days' }
+ ]);
+ assert.deepEqual(clauses("number of tags is greater than 5"), [
+ { condition: 'numTags', operator: 'isGreaterThan', value: '5' }
+ ]);
+ // A condition that doesn't compare still matches the word
+ assert.deepEqual(clauses("title is before"), [
+ { condition: 'title', operator: 'is', value: 'before' }
+ ]);
+ });
+
+ it("should accept 'starts with' and 'within the last'", function () {
+ assert.deepEqual(clauses("title starts with grounded"), [
+ { condition: 'title', operator: 'beginsWith', value: 'grounded' }
+ ]);
+ assert.deepEqual(clauses("added within the last 3 days"), [
+ { condition: 'dateAdded', operator: 'isInTheLast', value: '3 days' }
+ ]);
+ });
+
+ it("should compare numbers", function () {
+ assert.deepEqual(clauses("number of tags > 5"), [
+ { condition: 'numTags', operator: 'isGreaterThan', value: '5' }
+ ]);
+ assert.deepEqual(clauses("number of notes greater than 2"), [
+ { condition: 'numNotes', operator: 'isGreaterThan', value: '2' }
+ ]);
+ });
+
+ it("should read a colon after a word operator as punctuation", function () {
+ assert.deepEqual(clauses("year before:2020"), [
+ { condition: 'date', operator: 'isBefore', value: '2020' }
+ ]);
+ assert.deepEqual(clauses("year is before:2020"), [
+ { condition: 'date', operator: 'isBefore', value: '2020' }
+ ]);
+ assert.deepEqual(clauses("tag is:foo"), [
+ { condition: 'tag', operator: 'is', value: 'foo' }
+ ]);
+ });
+
+ it("should support empty and non-empty conditions", function () {
+ assert.deepEqual(clauses("creator is empty"), [
+ { condition: 'creator', operator: 'isEmpty', value: '' }
+ ]);
+ assert.deepEqual(clauses("abstract is not empty"), [
+ { condition: 'abstractNote', operator: 'isNotEmpty', value: '' }
+ ]);
+ });
+ });
+
+ describe("grouping and precedence", function () {
+ it("should group with parentheses and join modes", function () {
+ let { tree } = Zotero.SearchQuery.parse('by:smith and (tag:foo or tag:bar)');
+ assert.equal(tree.joinMode, 'all');
+ assert.lengthOf(tree.children, 2);
+ assert.equal(tree.children[0].condition, 'creator');
+ assert.equal(tree.children[1].joinMode, 'any');
+ assert.deepEqual(tree.children[1].children, [
+ { condition: 'tag', operator: 'is', value: 'foo' },
+ { condition: 'tag', operator: 'is', value: 'bar' }
+ ]);
+ });
+
+ it("should bind 'and' tighter than 'or'", function () {
+ let { tree } = Zotero.SearchQuery.parse("by:smith or by:jones and tag:foo");
+ assert.equal(tree.joinMode, 'any');
+ assert.lengthOf(tree.children, 2);
+ assert.equal(tree.children[0].value, 'smith');
+ assert.equal(tree.children[1].joinMode, 'all');
+ assert.deepEqual(tree.children[1].children.map(c => c.value), ['jones', 'foo']);
+ });
+
+ it("should find what 'a or b and c' means", async function () {
+ let smith = await createDataObject('item');
+ smith.setCreators([{ firstName: "A", lastName: "Smith", creatorType: "author" }]);
+ await smith.saveTx();
+ let jonesTagged = await createDataObject('item');
+ jonesTagged.setCreators([{ firstName: "B", lastName: "Jones", creatorType: "author" }]);
+ jonesTagged.addTag("zzfoo");
+ await jonesTagged.saveTx();
+ // Jones without the tag is excluded, since `and` binds tighter
+ let jonesOnly = await createDataObject('item');
+ jonesOnly.setCreators([{ firstName: "C", lastName: "Jones", creatorType: "author" }]);
+ await jonesOnly.saveTx();
+
+ let search = Zotero.SearchQuery.getSearch("by:smith or by:jones and tag:zzfoo", {
+ libraryID: Zotero.Libraries.userLibraryID
+ });
+ let ids = await search.search();
+ assert.include(ids, smith.id);
+ assert.include(ids, jonesTagged.id);
+ assert.notInclude(ids, jonesOnly.id);
+ });
+
+ it("should treat adjacency as 'and' for precedence", function () {
+ let tree = Zotero.SearchQuery.parse("by:smith tag:a or tag:b").tree;
+ assert.equal(tree.joinMode, 'any');
+ assert.equal(tree.children[0].joinMode, 'all');
+ assert.deepEqual(tree.children[0].children.map(c => c.value), ['smith', 'a']);
+ assert.equal(tree.children[1].value, 'b');
+
+ tree = Zotero.SearchQuery.parse("by:smith or by:jones tag:foo").tree;
+ assert.equal(tree.joinMode, 'any');
+ assert.equal(tree.children[0].value, 'smith');
+ assert.deepEqual(tree.children[1].children.map(c => c.value), ['jones', 'foo']);
+ });
+
+ it("should keep parentheses that aren't grouping clauses", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("by:smith The (Dis)United States");
+ assert.lengthOf(tree.children, 1);
+ assert.equal(text, "The (Dis)United States");
+ });
+ });
+
+ describe("repeated values after 'or'", function () {
+ it("should repeat the preceding field for a bare value", function () {
+ let { tree } = Zotero.SearchQuery.parse('creator is "smith" and (tag is foo or bar)');
+ assert.deepEqual(tree.children[1].children, [
+ { condition: 'tag', operator: 'is', value: 'foo' },
+ { condition: 'tag', operator: 'is', value: 'bar' }
+ ]);
+ });
+
+ it("should read a repeated value the same way as the first", function () {
+ assert.deepEqual(clauses('type:book or "journal article"').map(c => c.value),
+ ['book', 'journalArticle']);
+ assert.deepEqual(clauses("annotation color:red or blue").map(c => c.value),
+ ['#ff6666', '#2ea8e5']);
+ // A value the condition doesn't accept isn't a clause at all
+ let { tree, text } = Zotero.SearchQuery.parse("type:book or widget");
+ assert.lengthOf(tree.children, 1);
+ assert.equal(tree.children[0].value, 'book');
+ // The join word itself isn't text to search for
+ assert.equal(text, "widget");
+ });
+
+ it("should repeat the operator along with the field", function () {
+ assert.deepEqual(clauses("added in the last 2 days or 3 days"), [
+ { condition: 'dateAdded', operator: 'isInTheLast', value: '2 days' },
+ { condition: 'dateAdded', operator: 'isInTheLast', value: '3 days' }
+ ]);
+ assert.equal(
+ Zotero.SearchQuery.parse("added in the last 2 days or 3 days").text, ""
+ );
+ });
+
+ it("should repeat only the clause the 'or' follows", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("by:smith research or development");
+ assert.deepEqual(tree.children, [
+ { condition: 'creator', operator: 'contains', value: 'smith' }
+ ]);
+ assert.equal(text, "research development");
+ });
+
+ it("should not repeat the preceding field after 'and'", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("by:smith and crispr");
+ assert.deepEqual(tree.children, [
+ { condition: 'creator', operator: 'contains', value: 'smith' }
+ ]);
+ assert.equal(text, "crispr");
+ });
+
+ it("should not repeat a field into something written as a clause", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("tag:foo or type:widget");
+ assert.deepEqual(tree.children, [
+ { condition: 'tag', operator: 'is', value: 'foo' }
+ ]);
+ assert.equal(text, "type:widget");
+ });
+ });
+
+ describe("quoting", function () {
+ it("should keep quoted values together", function () {
+ assert.deepEqual(clauses('tag:"to read"'), [
+ { condition: 'tag', operator: 'is', value: 'to read' }
+ ]);
+ });
+
+ it("should accept curly quotes and a spaced colon", function () {
+ assert.deepEqual(clauses("by:“Mary Ann Test”"), [
+ { condition: 'creator', operator: 'contains', value: 'Mary Ann Test' }
+ ]);
+ assert.deepEqual(clauses("tag : foo"), [
+ { condition: 'tag', operator: 'is', value: 'foo' }
+ ]);
+ });
+
+ it("should read an unterminated quote as text", function () {
+ let { tree, text } = Zotero.SearchQuery.parse('by:"Mary Ann');
+ assert.isNull(tree);
+ assert.equal(text, 'by:"Mary Ann');
+ });
+
+ it("should format a value so it reads back as one value", function () {
+ for (let value of ['to read', '"Death is different"', 'it\'s "fine" (really)']) {
+ let query = 'tag:' + Zotero.SearchQuery.formatValue(value);
+ assert.deepEqual(clauses(query), [
+ { condition: 'tag', operator: 'is', value }
+ ], query);
+ }
+ });
+ });
+
+ describe("invalid input", function () {
+ it("should ignore an unknown field name", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("colour:blue");
+ assert.isNull(tree);
+ assert.equal(text, "colour:blue");
+ });
+
+ it("should ignore an operator the field doesn't take", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("type before book");
+ assert.isNull(tree);
+ assert.include(text, "type");
+ });
+
+ it("should read an unusable clause as text", function () {
+ for (let query of ["annotation color:chartreuse", 'title:""', "tag: ("]) {
+ let { tree } = Zotero.SearchQuery.parse(query);
+ assert.isNull(tree, query);
+ }
+ });
+
+ it("should reject a value a condition's own filter rejects", function () {
+ for (let query of ["number of tags > five", "number of tags:foo"]) {
+ assert.isNull(Zotero.SearchQuery.parse(query).tree, query);
+ }
+ assert.deepEqual(clauses("number of tags > 5"), [
+ { condition: 'numTags', operator: 'isGreaterThan', value: '5' }
+ ]);
+ });
+ });
+
+ describe("values shown in the interface", function () {
+ it("should accept an item type by its displayed name", function () {
+ for (let query of ["type:\"journal article\"", "type is journal article"]) {
+ assert.equal(clauses(query)[0].value, 'journalArticle', query);
+ }
+ assert.equal(clauses("type is Book Section")[0].value, 'bookSection');
+ // The stored name is Zotero's, not something to type
+ assert.isEmpty(clauses("type:journalArticle"));
+ });
+
+ it("should accept an annotation color by name", function () {
+ assert.deepEqual(clauses("annotation color:yellow"), [
+ {
+ condition: 'annotationColor',
+ operator: 'is',
+ value: '#ffd400',
+ level: 'annotation'
+ }
+ ]);
+ // The stored hex is Zotero's, not something to type
+ assert.isEmpty(clauses("annotation color:#5fb236"));
+ });
+
+ it("should accept 'color' for annotation color", function () {
+ let { tree } = Zotero.SearchQuery.parse("type is annotation and color is red");
+ assert.deepEqual(tree.children[1], {
+ condition: 'annotationColor',
+ operator: 'is',
+ value: '#ff6666',
+ level: 'annotation'
+ });
+ assert.deepEqual(clauses("color:yellow"), [
+ {
+ condition: 'annotationColor',
+ operator: 'is',
+ value: '#ffd400',
+ level: 'annotation'
+ }
+ ]);
+ });
+
+ it("should store an annotation type as its id", function () {
+ assert.deepEqual(clauses("annotation type:highlight"), [
+ {
+ condition: 'annotationType',
+ operator: 'is',
+ value: String(Zotero.Annotations.ANNOTATION_TYPE_HIGHLIGHT),
+ level: 'annotation'
+ }
+ ]);
+ });
+
+ it("should accept storage types, including web links", function () {
+ assert.deepEqual(clauses('attachment storage type:"Web Link"').map(c => c.value),
+ ['webLink']);
+ assert.deepEqual(clauses('attachment storage type:"Linked File"').map(c => c.value),
+ ['linkedFile']);
+ });
+
+ it("should read an unknown value as text", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("type:widget");
+ assert.isNull(tree);
+ assert.equal(text, "type:widget");
+ });
+ });
+
+ describe("name conditions", function () {
+ it("should match within a name for 'is', which compares the full name", function () {
+ assert.deepEqual(clauses("creator is okonkwo"), [
+ { condition: 'creator', operator: 'contains', value: 'okonkwo' }
+ ]);
+ assert.deepEqual(clauses("author is not okonkwo"), [
+ { condition: 'author', operator: 'doesNotContain', value: 'okonkwo' }
+ ]);
+ });
+
+ it("should find an item by a creator's last name", async function () {
+ let item = await createDataObject('item');
+ item.setCreators([
+ { firstName: "Adaeze", lastName: "Okonkwo", creatorType: "author" }
+ ]);
+ await item.saveTx();
+
+ for (let query of ["creator is okonkwo", "by:okonkwo", "creator is Adaeze"]) {
+ let search = Zotero.SearchQuery.getSearch(query, {
+ libraryID: Zotero.Libraries.userLibraryID
+ });
+ assert.include(await search.search(), item.id, query);
+ }
+ });
+ });
+
+ describe("multi-word names", function () {
+ it("should parse a field name of several words", function () {
+ assert.deepEqual(clauses("annotation comment:unclear"), [
+ {
+ condition: 'annotationComment',
+ operator: 'contains',
+ value: 'unclear',
+ level: 'annotation'
+ }
+ ]);
+ });
+
+ it("should accept a condition by the name shown in the interface", function () {
+ let localized = Zotero.SearchConditions.getLocalizedName('publicationTitle');
+ assert.deepEqual(clauses(localized + ":Nature"), [
+ { condition: 'publicationTitle', operator: 'contains', value: 'Nature' }
+ ]);
+ // The stored name is Zotero's, not something to type
+ assert.isFalse(Zotero.SearchQuery.getField('publicationTitle'));
+ });
+
+ it("should scope a child-item field to its level", function () {
+ assert.deepEqual(clauses("attachment tag:important"), [
+ { condition: 'tag', operator: 'is', value: 'important', level: 'attachment' }
+ ]);
+ // A condition with a level of its own is scoped to it as well
+ assert.deepEqual(clauses("annotation text:important"), [
+ {
+ condition: 'annotationText',
+ operator: 'contains',
+ value: 'important',
+ level: 'annotation'
+ }
+ ]);
+ // Bare `annotation` could mean the text or the comment, so it's
+ // neither
+ assert.isNull(Zotero.SearchQuery.parse("annotation:important").tree);
+ });
+ });
+
+ describe("'has' and 'no' shorthand", function () {
+ it("should read 'has ' and 'no ' as non-empty and empty conditions", function () {
+ assert.deepEqual(clauses('type is "journal article" and no doi'), [
+ { condition: 'itemType', operator: 'is', value: 'journalArticle' },
+ { condition: 'DOI', operator: 'isEmpty', value: '' }
+ ]);
+ assert.deepEqual(clauses("has doi"), [
+ { condition: 'DOI', operator: 'isNotEmpty', value: '' }
+ ]);
+ // A word that isn't a condition keeps the `no` as text
+ let { tree, text } = Zotero.SearchQuery.parse("no dice");
+ assert.isNull(tree);
+ assert.equal(text, "no dice");
+ // Before a clause of its own, the `no` is text as well
+ ({ tree, text } = Zotero.SearchQuery.parse("no title:foo"));
+ assert.deepEqual(tree.children, [
+ { condition: 'title', operator: 'contains', value: 'foo' }
+ ]);
+ assert.equal(text, "no");
+ });
+
+ it("should compare counts for a count-backed target", async function () {
+ assert.deepEqual(clauses("no annotation"), [
+ { condition: 'numAnnotations', operator: 'is', value: '0' }
+ ]);
+ assert.deepEqual(clauses("no tag"), [
+ { condition: 'numTags', operator: 'is', value: '0' }
+ ]);
+ assert.deepEqual(clauses("has attachment"), [
+ { condition: 'numAttachments', operator: 'isGreaterThan', value: '0' }
+ ]);
+
+ // An image annotation with no text is still an annotation
+ let item = await createDataObject('item');
+ let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
+ await createAnnotation('image', attachment);
+ let bare = await createDataObject('item');
+
+ let ids = await Zotero.SearchQuery.getSearch("has annotation", {
+ libraryID: Zotero.Libraries.userLibraryID
+ }).search();
+ assert.include(ids, item.id);
+ assert.notInclude(ids, bare.id);
+
+ ids = await Zotero.SearchQuery.getSearch("no annotation", {
+ libraryID: Zotero.Libraries.userLibraryID
+ }).search();
+ assert.include(ids, bare.id);
+ assert.notInclude(ids, item.id);
+ });
+
+ it("should read the plural forms as counts", function () {
+ assert.deepEqual(clauses("no tags"), [
+ { condition: 'numTags', operator: 'is', value: '0' }
+ ]);
+ assert.deepEqual(clauses("has attachments"), [
+ { condition: 'numAttachments', operator: 'isGreaterThan', value: '0' }
+ ]);
+ });
+
+ it("should keep 'no' on a child-scoped field as text", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("no attachment title");
+ assert.isNull(tree);
+ assert.equal(text, "no attachment title");
+ // `has` on one means a child with the field filled in
+ assert.deepEqual(clauses("has attachment title"), [
+ { condition: 'title', operator: 'isNotEmpty', value: '', level: 'attachment' }
+ ]);
+ });
+
+ it("should read 'no' after 'or' as a condition, not a repeated value", function () {
+ let { tree, text } = Zotero.SearchQuery.parse("tag:foo or no doi");
+ assert.equal(tree.joinMode, 'any');
+ assert.deepEqual(tree.children, [
+ { condition: 'tag', operator: 'is', value: 'foo' },
+ { condition: 'DOI', operator: 'isEmpty', value: '' }
+ ]);
+ assert.equal(text, "");
+ });
+ });
+
+ describe("#getSearch()", function () {
+ it("should return false for a query with no clauses", function () {
+ assert.isFalse(Zotero.SearchQuery.getSearch("crispr screens"));
+ });
+
+ it("should match free text the way the mode does", function () {
+ // A quicksearch condition expands into the fields it covers
+ let conditions = mode => Object.values(
+ Zotero.SearchQuery.getSearch("by:smith crispr", { mode }).getConditions()
+ ).map(condition => condition.condition);
+ assert.notInclude(conditions('fields'), 'fulltextContent');
+ assert.include(conditions('fields'), 'tag');
+ assert.include(conditions('everything'), 'fulltextContent');
+ // Best Match with no index falls back to matching text
+ assert.notInclude(conditions('bestMatch'), 'bestMatch');
+ });
+
+ it("should find items matching both the clauses and the text", async function () {
+ let match = await createDataObject('item', { title: "Glucose monitoring in the wild" });
+ match.setCreators([{ firstName: "Ada", lastName: "Nakamura", creatorType: "author" }]);
+ await match.saveTx();
+ let wrongText = await createDataObject('item', { title: "Something else entirely" });
+ wrongText.setCreators([{ firstName: "Ada", lastName: "Nakamura", creatorType: "author" }]);
+ await wrongText.saveTx();
+
+ let search = Zotero.SearchQuery.getSearch("by:nakamura glucose", {
+ libraryID: Zotero.Libraries.userLibraryID,
+ mode: 'fields'
+ });
+ let ids = await search.search();
+ assert.include(ids, match.id);
+ assert.notInclude(ids, wrongText.id);
+ });
+
+ it("should keep an 'any' query separate from the free text", async function () {
+ let tagged = await createDataObject('item', { title: "Glucose sensing" });
+ tagged.addTag("wearables");
+ await tagged.saveTx();
+ let otherTag = await createDataObject('item', { title: "Glucose sensing" });
+ otherTag.addTag("clinical");
+ await otherTag.saveTx();
+ let noMatch = await createDataObject('item', { title: "Unrelated" });
+ noMatch.addTag("wearables");
+ await noMatch.saveTx();
+
+ let search = Zotero.SearchQuery.getSearch("(tag:wearables or tag:clinical) glucose", {
+ libraryID: Zotero.Libraries.userLibraryID,
+ mode: 'fields'
+ });
+ let ids = await search.search();
+ assert.include(ids, tagged.id);
+ assert.include(ids, otherTag.id);
+ assert.notInclude(ids, noMatch.id);
+ });
+
+ it("should match items by conditions on their children", async function () {
+ // The tag lives on the attachment, the type on the item
+ let book = await createDataObject('item', { itemType: 'book' });
+ let attachment = await importFileAttachment('test.pdf', { parentItemID: book.id });
+ attachment.addTag('zzchildtag');
+ await attachment.saveTx();
+ let untagged = await createDataObject('item', { itemType: 'book' });
+
+ let ids = await Zotero.SearchQuery.getSearch("type:book tag:zzchildtag", {
+ libraryID: Zotero.Libraries.userLibraryID
+ }).search();
+ assert.include(ids, book.id);
+ assert.notInclude(ids, untagged.id);
+ assert.notInclude(ids, attachment.id);
+
+ // Attachment content alongside an item-level condition
+ let article = await createDataObject('item', { itemType: 'journalArticle' });
+ await importFileAttachment('search/foobar.html', { parentItemID: article.id });
+ ids = await Zotero.SearchQuery.getSearch(
+ 'type:"journal article" attachment content:"foo bar"',
+ { libraryID: Zotero.Libraries.userLibraryID }
+ ).search();
+ assert.include(ids, article.id);
+ assert.notInclude(ids, book.id);
+ });
+
+ it("should scope a condition that matches at a child level", async function () {
+ let item = await createDataObject('item');
+ item.setCreators([{ firstName: "Ada", lastName: "Ferrer", creatorType: "author" }]);
+ await item.saveTx();
+ let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
+ let annotation = await createAnnotation('highlight', attachment);
+ annotation.annotationText = 'zzmarker';
+ await annotation.saveTx();
+
+ let search = Zotero.SearchQuery.getSearch("by:ferrer annotation text:zzmarker", {
+ libraryID: Zotero.Libraries.userLibraryID
+ });
+ assert.sameMembers(await search.search(), [item.id]);
+ });
+
+ it("should return child rows for a child item type", async function () {
+ let item = await createDataObject('item');
+ item.setCreators([{ firstName: 'Ada', lastName: 'Zzpin', creatorType: 'author' }]);
+ await item.saveTx();
+ let attachment = await importFileAttachment('test.pdf', { parentItemID: item.id });
+ let other = await createDataObject('item');
+ let otherAttachment = await importFileAttachment('test.pdf', { parentItemID: other.id });
+
+ // The type names the rows to return; other conditions map to them
+ let ids = await Zotero.SearchQuery.getSearch("type:attachment by:zzpin", {
+ libraryID: Zotero.Libraries.userLibraryID
+ }).search();
+ assert.sameMembers(ids, [attachment.id]);
+
+ // Alone, every attachment
+ ids = await Zotero.SearchQuery.getSearch("type:attachment", {
+ libraryID: Zotero.Libraries.userLibraryID
+ }).search();
+ assert.include(ids, attachment.id);
+ assert.include(ids, otherAttachment.id);
+ assert.notInclude(ids, item.id);
+
+ // Annotations are rows too
+ let annotation = await createAnnotation('highlight', attachment);
+ ids = await Zotero.SearchQuery.getSearch("type:annotation by:zzpin", {
+ libraryID: Zotero.Libraries.userLibraryID
+ }).search();
+ assert.sameMembers(ids, [annotation.id]);
+
+ // Negated, it's an ordinary condition on items
+ ids = await Zotero.SearchQuery.getSearch("type is not attachment", {
+ libraryID: Zotero.Libraries.userLibraryID
+ }).search();
+ assert.include(ids, item.id);
+ assert.notInclude(ids, attachment.id);
+ });
+ });
+
+ describe("#addToSearch()", function () {
+ it("should build a search that matches the right items", async function () {
+ let match = await createDataObject('item', {
+ itemType: 'book',
+ title: "Grounded theory and its discontents"
+ });
+ match.setCreators([{ firstName: "Alice", lastName: "Smith", creatorType: "author" }]);
+ match.addTag("methodology");
+ await match.saveTx();
+
+ let wrongCreator = await createDataObject('item', { itemType: 'book' });
+ wrongCreator.addTag("methodology");
+ await wrongCreator.saveTx();
+
+ let wrongTag = await createDataObject('item', { itemType: 'book' });
+ wrongTag.setCreators([{ firstName: "Alice", lastName: "Smith", creatorType: "author" }]);
+ await wrongTag.saveTx();
+
+ let { tree } = Zotero.SearchQuery.parse('by:smith and (tag:methodology or tag:missing)');
+ let search = new Zotero.Search();
+ search.libraryID = Zotero.Libraries.userLibraryID;
+ Zotero.SearchQuery.addToSearch(search, tree);
+ let ids = await search.search();
+
+ assert.include(ids, match.id);
+ assert.notInclude(ids, wrongCreator.id);
+ assert.notInclude(ids, wrongTag.id);
+ });
+ });
+
+ describe("#tokenize()", function () {
+ it("should cover every character of the input", function () {
+ let query = 'by:smith and (tag:"to read" or bar) crispr';
+ let tokens = Zotero.SearchQuery.tokenize(query);
+ assert.equal(tokens[0].start, 0);
+ assert.equal(tokens[tokens.length - 1].end, query.length);
+ for (let i = 1; i < tokens.length; i++) {
+ assert.equal(tokens[i].start, tokens[i - 1].end);
+ }
+ });
+
+ it("should recognize a condition before its value is typed", function () {
+ let types = Zotero.SearchQuery.tokenize("tag:")
+ .map(token => token.type);
+ assert.deepEqual(types, ['field', 'operator']);
+
+ // Nothing to match on yet, and not text to search for
+ let { tree, text } = Zotero.SearchQuery.parse("crispr tag:");
+ assert.isNull(tree);
+ assert.equal(text, "crispr");
+ assert.isFalse(Zotero.SearchQuery.getSearch("tag:"));
+ });
+
+ it("should label field, operator, and value tokens for highlighting", function () {
+ let types = Zotero.SearchQuery.tokenize("by:smith crispr")
+ .filter(token => token.type !== 'space')
+ .map(token => token.type);
+ assert.deepEqual(types, ['field', 'operator', 'value', 'text']);
+ });
+ });
+
+ describe("multiple libraries", function () {
+ it("should match in whatever library the results come from", async function () {
+ let group = await createGroup();
+ let groupItem = await createDataObject('item',
+ { libraryID: group.libraryID, tags: [{ tag: 'zzlib' }] });
+
+ let query = Zotero.SearchQuery.getSearch('tag:zzlib');
+ assert.isNull(query.libraryID);
+
+ // Scoped to a row the way CollectionTreeRow scopes a filter search
+ let scope = new Zotero.Search();
+ scope.libraryID = group.libraryID;
+ scope.addCondition('noChildren', 'true');
+ let scoped = new Zotero.Search();
+ scoped.fromJSON(query.toJSON());
+ scoped.setScope(scope, true);
+ assert.sameMembers(await scoped.search(), [groupItem.id]);
+ });
+ });
+
+ describe("#getCompletions()", function () {
+ let labels = (query, caret) => (Zotero.SearchQuery.getCompletions(query, caret)
+ || { completions: [] }).completions.map(c => c.label);
+
+ it("should complete a condition name partway through a word", function () {
+ let completions = Zotero.SearchQuery.getCompletions("ta");
+ assert.equal(completions.type, 'field');
+ assert.equal(completions.start, 0);
+ assert.equal(completions.end, 2);
+ assert.include(completions.completions.map(c => c.label), "tag:");
+ // One row per condition, by its shortest name
+ assert.notInclude(completions.completions.map(c => c.label), "tag name:");
+ });
+
+ it("should complete a condition name of several words", function () {
+ let completions = Zotero.SearchQuery.getCompletions("annotation ty");
+ assert.deepEqual(completions.completions.map(c => c.label), ["annotation type:"]);
+ assert.equal(completions.start, 0);
+
+ // The words before it are still text
+ completions = Zotero.SearchQuery.getCompletions("crispr annotation ty");
+ assert.deepEqual(completions.completions.map(c => c.label), ["annotation type:"]);
+ assert.equal(completions.start, "crispr ".length);
+ });
+
+ it("should complete a condition name after other text", function () {
+ let completions = Zotero.SearchQuery.getCompletions("crispr ta");
+ assert.equal(completions.start, 7);
+ assert.include(completions.completions.map(c => c.label), "tag:");
+ });
+
+ it("should complete the values a condition takes", function () {
+ assert.include(labels("type:"), "journal article");
+ assert.include(labels("type:jour"), "journal article");
+ // Inserted so that the parser reads it back
+ let completions = Zotero.SearchQuery.getCompletions("type:jour");
+ let article = completions.completions.find(c => c.label === "journal article");
+ assert.equal(article.text, '"journal article"');
+ assert.equal(completions.start, "type:".length);
+
+ assert.include(labels("annotation color:"), "yellow");
+ // A color value carries its color, for the swatch in the list
+ let yellow = Zotero.SearchQuery.getCompletions("color:").completions
+ .find(c => c.label === "yellow");
+ assert.equal(yellow.color, '#ffd400');
+ });
+
+ it("should replace an opening quote along with the value being completed", function () {
+ let head = 'type:"jour';
+ let completions = Zotero.SearchQuery.getCompletions(head);
+ let article = completions.completions.find(c => c.label === "journal article");
+ let result = head.slice(0, completions.start) + article.text;
+ assert.equal(result, 'type:"journal article"');
+ assert.equal(clauses(result)[0].value, 'journalArticle');
+ });
+
+ it("should defer to a lookup for values that come from the library", function () {
+ let completions = Zotero.SearchQuery.getCompletions("tag:zz");
+ assert.equal(completions.lookup.fieldName, 'tag');
+ assert.equal(completions.prefix, 'zz');
+ assert.equal(completions.start, "tag:".length);
+ assert.isEmpty(completions.completions);
+
+ // Creator conditions search both single- and two-field names
+ completions = Zotero.SearchQuery.getCompletions("by:smi");
+ assert.equal(completions.lookup.fieldName, 'creator');
+ assert.equal(completions.lookup.fieldMode, 2);
+
+ // The word form of an operator reads the same as the colon
+ completions = Zotero.SearchQuery.getCompletions("creator is a");
+ assert.equal(completions.lookup.fieldName, 'creator');
+ assert.equal(completions.prefix, 'a');
+ assert.equal(completions.start, "creator is ".length);
+ completions = Zotero.SearchQuery.getCompletions("creator is not a");
+ assert.equal(completions.prefix, 'a');
+ // The last condition in the query is the one being typed
+ completions = Zotero.SearchQuery.getCompletions("tag is x creator is a");
+ assert.equal(completions.lookup.fieldName, 'creator');
+ assert.equal(completions.prefix, 'a');
+ assert.include(labels("item type is boo"), "book");
+
+ // Nothing typed yet, so there's nothing to narrow the library to
+ assert.isNull(Zotero.SearchQuery.getCompletions("tag:"));
+ assert.isNull(Zotero.SearchQuery.getCompletions("by:"));
+ // A condition with a fixed set of values still offers all of them
+ assert.include(labels("type:"), "journal article");
+ });
+
+ it("should complete a value repeated with 'or'", function () {
+ let completions = Zotero.SearchQuery.getCompletions("type:book or jour");
+ assert.include(completions.completions.map(c => c.label), "journal article");
+ assert.equal(completions.start, "type:book or ".length);
+ // A library lookup, as for the first value
+ completions = Zotero.SearchQuery.getCompletions("tag:foo or zz");
+ assert.equal(completions.lookup.fieldName, 'tag');
+ assert.equal(completions.prefix, 'zz');
+ });
+
+ it("should complete a condition after 'has' or 'no' without a colon", function () {
+ let head = "type:book and no d";
+ let completions = Zotero.SearchQuery.getCompletions(head);
+ let doi = completions.completions.find(c => c.label === 'doi');
+ assert.isDefined(doi);
+ let result = head.slice(0, completions.start) + doi.text;
+ assert.deepEqual(clauses(result)[1],
+ { condition: 'DOI', operator: 'isEmpty', value: '' });
+ // Only conditions that can be empty are offered
+ let names = labels("no t");
+ assert.include(names, 'title');
+ assert.notInclude(names, 'type');
+ assert.notInclude(names, 'type:');
+ assert.include(labels("has d"), 'doi');
+ // Count-backed targets complete by their bare names, even ones
+ // that aren't conditions on their own
+ assert.include(labels("no att"), 'attachment');
+ // The plural forms work but complete by the shortest name
+ let tagNames = labels("no ta");
+ assert.include(tagNames, 'tag');
+ assert.notInclude(tagNames, 'tags');
+ });
+
+ it("should have nothing to offer for free text", function () {
+ assert.isNull(Zotero.SearchQuery.getCompletions("crispr "));
+ // Partway through a word, where a completion would leave a tail
+ assert.isNull(Zotero.SearchQuery.getCompletions("tag:foo", 2));
+ });
+
+ it("should offer nothing inside an unfinished quote", function () {
+ assert.isNull(Zotero.SearchQuery.getCompletions('"annotation t'));
+ assert.isNull(Zotero.SearchQuery.getCompletions('title:"annotation t'));
+ });
+
+ it("should not read a possible operator as a new field", function () {
+ assert.isNull(Zotero.SearchQuery.getCompletions("year is b"));
+ assert.isNull(Zotero.SearchQuery.getCompletions("title does not c"));
+ });
+
+ it("should offer nothing after an operator the condition doesn't take", function () {
+ // The word at the value position gets neither the values the
+ // operator can't apply to nor a new-field suggestion
+ assert.isNull(Zotero.SearchQuery.getCompletions("type before boo"));
+ assert.isNull(Zotero.SearchQuery.getCompletions("type is before boo"));
+ assert.isNull(Zotero.SearchQuery.getCompletions("annotation color greater than y"));
+ });
+
+ it("should not offer a field for the value being typed", function () {
+ assert.isNull(Zotero.SearchQuery.getCompletions("title is ta"));
+ assert.isNull(Zotero.SearchQuery.getCompletions("title contains ta"));
+ assert.isNull(Zotero.SearchQuery.getCompletions("title is before"));
+ // A word after a complete clause can still start one
+ let completions = Zotero.SearchQuery.getCompletions("title is grounded ta");
+ assert.include(completions.completions.map(c => c.label), "tag:");
+ });
+ });
+});