zotero/chrome/content/zotero/elements/advancedSearchPane.js

407 lines
13 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2025 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";
{
class AdvancedSearchPane extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<hbox class="saved-search-name-row">
<label control="saved-search-name" data-l10n-id="new-collection-name"/>
<html:input type="text" id="saved-search-name"/>
</hbox>
<zoterosearch/>
<hbox class="advanced-search-buttons">
<button class="cancel-button" data-l10n-id="cancel-button"/>
<button class="search-button" data-l10n-id="search-button" default="true"/>
<button class="clear-button" data-l10n-id="clear-button"/>
<button class="save-button" data-l10n-id="save-search-new-button"/>
</hbox>
`);
_active = false;
init() {
this._nameField = this.querySelector('#saved-search-name');
this._searchElem = this.querySelector('zoterosearch');
this._cancelButton = this.querySelector('.cancel-button');
this._searchButton = this.querySelector('.search-button');
this._clearButton = this.querySelector('.clear-button');
this._saveButton = this.querySelector('.save-button');
this._searchElem.addEventListener('input', () => this._searchElem.updateSearch());
this._searchElem.addEventListener('command', () => this._searchElem.updateSearch());
this._cancelButton.addEventListener('command', () => this.cancel());
this._searchButton.addEventListener('command', () => this.submit());
this._clearButton.addEventListener('command', () => this.clear());
this._saveButton.addEventListener('command', () => this.save());
if (!['temporary', 'saved'].includes(this.type)) {
throw new Error(`Invalid type: ${this.type}`);
}
if (this.type === 'saved') {
this._saveButton.setAttribute('default', 'true');
// Editing an existing search saves changes directly, so just "Save"
// (vs. "Save Search…" for creating a new one, which prompts for a name)
this._saveButton.setAttribute('data-l10n-id', 'save-search-edit-button');
}
this.addEventListener('keydown', this._handleKeyDown);
}
_handleKeyDown = async (event) => {
this._searchElem.updateSearch();
// Shift-Enter adds a new condition (handled by the search element), so
// don't run/save the search for it
if (event.key === 'Enter' && !event.shiftKey) {
// Enter on a focused button runs that button (which the platform only does
// for Space), not the pane's default action
let button = event.target.closest && event.target.closest('button, toolbarbutton');
if (button) {
button.click();
return;
}
if (this.type === 'temporary') {
await this.submit();
}
else {
await this.save();
}
}
};
/**
* @returns {'temporary' | 'saved'}
*/
get type() {
return this.getAttribute('type');
}
get search() {
return this._search;
}
/**
* The ID of the saved search being edited, for type "saved"
*/
get editedSearchID() {
return this._searchID;
}
/**
* Whether the search has been submitted and should filter the items list
*/
get active() {
return this._active;
}
set search(search) {
this._active = false;
if (this.type === 'saved') {
if (!search?.id) {
throw new Error('Cannot edit unsaved search');
}
this._searchID = search.id;
this._search = search.clone();
this._nameField.value = search.name;
}
else if (search) {
this._search = search.clone();
}
else {
this._search = new Zotero.Search();
// Default a fresh search to top-level items, so a condition on a child
// (e.g. attachment content) maps up to its item without any grouping
this._search.addCondition('resultLevel', 'item');
this._search.addCondition('title', 'contains', '');
}
this._searchElem.search = this._search;
this._loadedState = this.type === 'saved' ? this._getState() : null;
}
/**
* The editor's current name and conditions, for comparison against the state the
* search was loaded with
*
* The search is serialized through the editor, since rendering an existing search
* can normalize it (e.g., folding a legacy 'noChildren' condition into the result
* level), and the normalized form is what a save would write.
*/
_getState() {
this._searchElem.updateSearch();
return {
name: this._nameField.value,
conditions: this._search.toJSON().conditions
};
}
/**
* Whether the name or conditions have been edited since the search was loaded,
* for type "saved"
*/
get hasChanges() {
if (this.type !== 'saved') {
throw new Error('hasChanges is unsupported for temporary search');
}
if (!this._loadedState) {
return false;
}
let state = this._getState();
return state.name !== this._loadedState.name
|| state.conditions.length !== this._loadedState.conditions.length
|| state.conditions.some((condition, i) => !Zotero.Searches.conditionEquals(
condition, this._loadedState.conditions[i]
));
}
_ensureSearch() {
if (!this._search && this.type === 'temporary') {
this.search = null;
}
}
// Allow saving at an editable library or group root (but not a feed) or with
// collections and/or saved searches selected within a single editable library,
// in which case the selection is added to the saved search as scope conditions
// (see _addScopeConditions())
_canSaveInSelection() {
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
if (!collectionTreeRows.length) {
return false;
}
let libraryID = collectionTreeRows[0].ref?.libraryID;
return collectionTreeRows.every((row) => {
if (row.ref?.libraryID !== libraryID || !row.editable) {
return false;
}
return (row.isLibrary(true) && !row.isFeed())
|| row.isCollection()
|| row.isSearch();
});
}
refresh() {
this._ensureSearch();
let libraryID = ZoteroPane.getSelectedLibraryIDs()[0];
// Keep the previous library when the selected row doesn't have one (e.g., Feeds)
if (libraryID) {
this._search.libraryID = libraryID;
}
// Set the libraries the search applies to: a temporary search spans all
// selected libraries, a saved search just its own. Used to scope value
// autocomplete and, when more than one library is involved, to drop the
// Collection/Saved Search condition, which can only resolve within one library.
if (this.type === 'temporary') {
let libraryIDs = [];
for (let row of ZoteroPane.getCollectionTreeRows()) {
let id = row.ref && row.ref.libraryID;
if (id !== undefined && id !== null && !libraryIDs.includes(id)) {
libraryIDs.push(id);
}
}
this._searchElem.scopeLibraryIDs = libraryIDs;
}
else {
this._searchElem.scopeLibraryIDs = [this._search.libraryID];
}
this._searchElem.search = this._search;
this._saveButton.disabled = this.type === 'temporary' && !this._canSaveInSelection();
}
async cancel() {
await ZoteroPane.setSavedSearchEditorState('closed');
}
async submit() {
if (this.type === 'saved') {
throw new Error('submit() is unsupported for saved search');
}
this._searchElem.updateSearch();
this._active = true;
await ZoteroPane.itemsView.setFilter('advanced-search', this._search);
}
async clear() {
if (this.type === 'saved') {
throw new Error('clear() is unsupported for saved search');
}
this.search = null;
await ZoteroPane.itemsView.setFilter('advanced-search', null);
}
async save() {
this._searchElem.updateSearch();
if (this.type === 'saved') {
let search = Zotero.Searches.get(this._searchID);
if (!search) {
throw new Error('Missing search');
}
search.fromJSON(this._search.toJSON());
search.name = this._nameField.value;
await search.saveTx();
await ZoteroPane.setSavedSearchEditorState('closed');
Zotero_Tabs.rename('zotero-pane', search.name);
return;
}
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
if (!this._canSaveInSelection()) {
throw new Error('Can only save in an editable library, collection, or saved search');
}
this._ensureSearch();
let libraryID = collectionTreeRows[0].ref.libraryID;
let searches = await Zotero.Searches.getAll(libraryID);
let prefix = Zotero.getString('pane.collections.untitled');
let defaultName = Zotero.Utilities.Internal.getNextName(
prefix,
searches.map(s => s.name).filter(n => n.startsWith(prefix))
);
// Prompt for a name, defaulting to the next "Untitled" name
let [title, message] = await document.l10n.formatValues([
'save-search-name-title',
'save-search-name-message'
]);
let nameObj = { value: defaultName };
if (!Services.prompt.prompt(window, title, message, nameObj, null, {})) {
return;
}
let name = nameObj.value.trim() || defaultName;
let search = this._search.clone(libraryID);
search.name = name;
// If saving within collections or saved searches rather than at the library
// root, scope the search to the selection
if (!collectionTreeRows.some(row => row.isLibrary(true))) {
this._addScopeConditions(search, collectionTreeRows);
}
await search.saveTx();
await ZoteroPane.setAdvancedSearchState('closed');
}
// Scope a search to the given collection/saved search rows by adding a top-level
// collection/savedSearch condition for each -- an 'any' group of them when more
// than one row is selected. If the search's own join mode is 'any', its existing
// conditions move into an 'any' group of their own so the scope conditions apply
// to every result.
_addScopeConditions(search, collectionTreeRows) {
let conditions = Object.values(search.getConditions());
// Split the existing conditions into top-level markers/flags, which are
// position-independent and stay at the top level, and the rest, which may get
// wrapped in a group below. The search always comes from updateSearch(), so
// these are the only markers/flags that can appear at the top level.
const FLAGS = ['resultLevel', 'recursive', 'includeParentsAndChildren'];
let joinMode = 'all';
let flags = [];
let rest = [];
let depth = 0;
// Number of top-level conditions and groups -- a single one doesn't need wrapping
let units = 0;
for (let condition of conditions) {
if (condition.condition == 'groupStart') {
if (!depth) {
units++;
}
depth++;
rest.push(condition);
}
else if (condition.condition == 'groupEnd') {
depth--;
rest.push(condition);
}
else if (!depth && condition.condition == 'joinMode') {
joinMode = condition.operator;
}
else if (!depth && FLAGS.includes(condition.condition)) {
flags.push(condition);
}
else {
if (!depth) {
units++;
}
rest.push(condition);
}
}
let scope = collectionTreeRows.map(row => ({
condition: row.isCollection() ? 'collection' : 'savedSearch',
operator: 'is',
value: row.ref.key
}));
// Search subcollections when the collections view does
if (Zotero.Prefs.get('recursiveCollections')
&& scope.some(c => c.condition == 'collection')
&& !flags.some(c => c.condition == 'recursive')) {
flags.push({ condition: 'recursive', operator: 'true', value: null });
}
if (scope.length > 1) {
scope = [
{ condition: 'groupStart', operator: 'true', value: '' },
{ condition: 'joinMode', operator: 'any', value: null },
...scope,
{ condition: 'groupEnd', operator: 'true', value: '' }
];
}
if (joinMode == 'any' && units > 1) {
rest = [
{ condition: 'groupStart', operator: 'true', value: '' },
{ condition: 'joinMode', operator: 'any', value: null },
...rest,
{ condition: 'groupEnd', operator: 'true', value: '' }
];
}
// Rebuild the search's conditions in the new order. removeCondition()
// renumbers the remaining conditions, so 0 is always the next one to remove.
let count = conditions.length;
for (let i = 0; i < count; i++) {
search.removeCondition(0);
}
for (let condition of [...scope, ...rest, ...flags]) {
search.addCondition(
condition.condition + (condition.mode ? '/' + condition.mode : ''),
condition.operator,
condition.value
);
}
}
focus(options) {
let menu = this._searchElem.querySelector('#conditionsmenu');
// focusVisible so the focus ring shows even though we're focusing
// programmatically (in response to a click)
menu.focus({ focusVisible: true, ...options });
}
}
customElements.define("advanced-search-pane", AdvancedSearchPane);
}