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:"); + }); + }); +});