Add a query syntax to the quick search
Some checks are pending
CI / Detect changes (push) Waiting to run
CI / Test () (push) Blocked by required conditions
CI / Test (macOS NFS) (push) Blocked by required conditions
CI / Test (Windows arm64) (push) Blocked by required conditions
CI / Test (Windows x64) (push) Blocked by required conditions
CI / Utilities Tests (push) Waiting to run
CI / Build, Upload (push) Waiting to run

Zotero.SearchQuery turns a query like `by:smith after:2020 tag:"to read"
crispr` or `creator is smith and (tag is foo or bar)` into a
Zotero.Search, matching whatever text is left over using the current
search mode. Anything that doesn't look like a clause is free text, so a
DOI or a title with a colon in it is matched literally.

The search box syntax-highlights the parts of recognized conditions and
offers autocomplete for condition names, for the values of conditions
that have a fixed set of them (like item type), and for tags and
creators from the selected libraries.
This commit is contained in:
Dan Stillman 2026-07-30 22:39:06 -04:00 committed by Dan Stillman
parent 766cb079a0
commit 77a3a8815e
14 changed files with 3186 additions and 16 deletions

View file

@ -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'],

View file

@ -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 <http://www.gnu.org/licenses/>.
***** 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 <input> 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);
}

View file

@ -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");

File diff suppressed because it is too large Load diff

View file

@ -129,6 +129,7 @@ const xpcomFilesLocal = [
'retractions',
'router',
'schema',
'searchQuery',
'server/server',
'server/server_integration',
'server/server_connector',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -59,6 +59,7 @@
),
textbox,
search-textbox,
query-textbox,
textarea
):where(:not([no-native])) {
appearance: none;

View file

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

View file

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

View file

@ -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 <condition>' and 'no <condition>' 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:");
});
});
});