mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
407 lines
13 KiB
JavaScript
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);
|
|
}
|