mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
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
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:
parent
766cb079a0
commit
77a3a8815e
14 changed files with 3186 additions and 16 deletions
|
|
@ -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'],
|
||||
|
|
|
|||
385
chrome/content/zotero/elements/queryTextbox.js
Normal file
385
chrome/content/zotero/elements/queryTextbox.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
1490
chrome/content/zotero/xpcom/searchQuery.js
Normal file
1490
chrome/content/zotero/xpcom/searchQuery.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -129,6 +129,7 @@ const xpcomFilesLocal = [
|
|||
'retractions',
|
||||
'router',
|
||||
'schema',
|
||||
'searchQuery',
|
||||
'server/server',
|
||||
'server/server_integration',
|
||||
'server/server_connector',
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
chrome/skin/default/zotero/query-textbox.css
Normal file
49
chrome/skin/default/zotero/query-textbox.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
),
|
||||
textbox,
|
||||
search-textbox,
|
||||
query-textbox,
|
||||
textarea
|
||||
):where(:not([no-native])) {
|
||||
appearance: none;
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
159
test/tests/queryTextboxTest.js
Normal file
159
test/tests/queryTextboxTest.js
Normal 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);
|
||||
});
|
||||
});
|
||||
912
test/tests/searchQueryTest.js
Normal file
912
test/tests/searchQueryTest.js
Normal 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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue