From aec6e61cb3a6a3d464d51f3de2e7d93a884ca328 Mon Sep 17 00:00:00 2001 From: windingwind <33902321+windingwind@users.noreply.github.com> Date: Sun, 9 Jun 2024 22:25:37 +0800 Subject: [PATCH] Add ItemPaneManager.registerInfoRow API Unify plugin API classes Add info box custom row API tests Refactor itemBox.js create element Wrap hooks in API for safe call Add test for item tree api and hook error handling Remove try/catch from #4816 Move plugin API definitions to xpcom/pluginAPI --- chrome/content/zotero/elements/itemBox.js | 466 +++++++++++++--- chrome/content/zotero/elements/itemDetails.js | 9 +- chrome/content/zotero/itemTree.jsx | 3 +- chrome/content/zotero/itemTreeColumns.jsx | 1 - .../content/zotero/xpcom/itemPaneManager.js | 338 ------------ .../content/zotero/xpcom/itemTreeManager.js | 378 ------------- chrome/content/zotero/xpcom/notifier.js | 3 +- .../zotero/xpcom/pluginAPI/itemPaneManager.js | 347 ++++++++++++ .../zotero/xpcom/pluginAPI/itemTreeManager.js | 339 ++++++++++++ .../zotero/xpcom/pluginAPI/pluginAPIBase.mjs | 383 +++++++++++++ chrome/content/zotero/zotero.mjs | 4 +- test/tests/itemPaneTest.js | 17 +- test/tests/pluginAPITest.js | 515 ++++++++++++++++++ 13 files changed, 1986 insertions(+), 817 deletions(-) delete mode 100644 chrome/content/zotero/xpcom/itemPaneManager.js delete mode 100644 chrome/content/zotero/xpcom/itemTreeManager.js create mode 100644 chrome/content/zotero/xpcom/pluginAPI/itemPaneManager.js create mode 100644 chrome/content/zotero/xpcom/pluginAPI/itemTreeManager.js create mode 100644 chrome/content/zotero/xpcom/pluginAPI/pluginAPIBase.mjs create mode 100644 test/tests/pluginAPITest.js diff --git a/chrome/content/zotero/elements/itemBox.js b/chrome/content/zotero/elements/itemBox.js index 9eeaf0ff18..172b7c557c 100644 --- a/chrome/content/zotero/elements/itemBox.js +++ b/chrome/content/zotero/elements/itemBox.js @@ -63,6 +63,14 @@ this._selectFieldSelection = null; this._addCreatorRow = false; this._switchedModeOfCreator = null; + + this._lastUpdateCustomRows = ""; + // Keep in sync with itemPaneManager.js + this._customRowElemCache = { + start: [], + afterCreators: [], + end: [], + }; } get content() { @@ -250,7 +258,7 @@ } }); - this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'itemBox'); + this._notifierID = Zotero.Notifier.registerObserver(this, ['item', 'infobox'], 'itemBox'); Zotero.Prefs.registerObserver('fontSize', () => { this._forceRenderAll(); }); @@ -352,6 +360,11 @@ this._item = val; this.scrollToTop(); + + // Call custom row onItemChange hook + for (let rowElem of this._infoTable.querySelectorAll('.meta-row[data-custom-row-id]')) { + this.updateCustomRowProperty(rowElem); + } } // .ref is an alias for .item @@ -473,15 +486,13 @@ // // Methods // - notify(event, _type, ids) { - if (event != 'modify' || !this.item || !this.item.id) return; - for (let i = 0; i < ids.length; i++) { - let id = ids[i]; - if (id != this.item.id) { - continue; - } + notify(event, type, ids) { + if (event == 'refresh' && type == 'infobox' && this.item?.id) { + this.renderCustomRows(); + return; + } + if (event == 'modify' && this.item?.id && ids.includes(this.item.id)) { this._forceRenderAll(); - break; } } @@ -502,6 +513,11 @@ this._saveFieldFocus(); delete this._linkMenu.dataset.link; + + this.renderCustomRows(); + + // No need to recreate custom rows every time + this.cacheCustomRowElements(); // // Clear and rebuild metadata fields @@ -594,15 +610,15 @@ rowLabel.className = "meta-label"; rowLabel.setAttribute('fieldname', fieldName); - let valueElement = this.createValueElement( + let valueElement = this.createFieldValueElement( val, fieldName ); if (fieldName) { - let label = document.createElement('label'); - label.className = 'key'; - label.textContent = Zotero.ItemFields.getLocalizedString(fieldName); - label.setAttribute("id", `itembox-field-${fieldName}-label`); + let label = this.createLabelElement({ + text: Zotero.ItemFields.getLocalizedString(fieldName), + id: `itembox-field-${fieldName}-label`, + }); rowLabel.appendChild(label); valueElement.setAttribute('aria-labelledby', label.id); } @@ -825,35 +841,6 @@ }); this._showCreatorTypeGuidance = false; } - - // On click of the label, toggle the focus of the value field - for (let label of this.querySelectorAll(".meta-label > label")) { - if (!this.editable) { - break; - } - - label.addEventListener('mousedown', (event) => { - // Prevent default focus/blur behavior - we implement our own below - event.preventDefault(); - }); - - label.addEventListener('click', (event) => { - event.preventDefault(); - - let labelWrapper = label.closest(".meta-label"); - if (labelWrapper.nextSibling.contains(document.activeElement)) { - document.activeElement.blur(); - } - else { - let valueField = labelWrapper.nextSibling.firstChild; - if (valueField.id === "item-type-menu") { - valueField.querySelector("menupopup").openPopup(); - return; - } - labelWrapper.nextSibling.firstChild.focus(); - } - }); - } this._ensureButtonsFocusable(); this._updateCreatorButtonsStatus(); @@ -873,6 +860,196 @@ this.querySelectorAll("menupopup").forEach((popup) => { popup.hidePopup(); }); + + this.restoreCustomRowElements(); + + // Update custom row data + for (let rowElem of this._infoTable.querySelectorAll('.meta-row[data-custom-row-id]')) { + this.updateCustomRowData(rowElem); + } + + // Set focus on the last focused field + this._restoreFieldFocus(); + // Make sure that any opened popup closes + this.querySelectorAll("menupopup").forEach((popup) => { + popup.hidePopup(); + }); + } + + renderCustomRows() { + let { options: targetRows, updateID } = Zotero.ItemPaneManager.customInfoRowData; + if (this._lastUpdateCustomRows == updateID) return; + this._lastUpdateCustomRows = updateID; + + // Remove rows that are no longer in the target rows + for (let elem of this._infoTable.querySelectorAll('.meta-row[data-custom-row-id]')) { + let rowID = elem.dataset.customRowId; + if (targetRows.find(r => r.rowID == rowID)) continue; + elem.remove(); + } + + // Add rows that are in the target rows but not in the current rows + for (let row of targetRows) { + if (this._infoTable.querySelector(`[data-custom-row-id="${row.rowID}"]`)) continue; + let rowElem = document.createElement("div"); + rowElem.dataset.customRowId = row.rowID; + let position = row.position || "end"; + rowElem.dataset.position = position; + rowElem.classList.add("meta-row"); + + let labelElem = document.createElement("div"); + labelElem.classList.add("meta-label"); + + let labelID = `itembox-custom-row-${row.rowID}-label`; + let label = this.createLabelElement({ + id: labelID, + text: row.label.text, + }); + label.dataset.l10nId = row.label.l10nID; + + labelElem.appendChild(label); + + let dataElem = document.createElement("div"); + dataElem.classList.add("meta-data"); + + let editable = row.editable ?? true; + if (!this.editable) editable = false; + let valueElem = this.createValueElement({ + id: `itembox-custom-row-${row.rowID}-value`, + classList: ["custom-row-value"], + isMultiline: row.multiline, + isNoWrap: row.nowrap, + editable, + attributes: { + "aria-labelledby": labelID + } + }); + + dataElem.appendChild(valueElem); + + rowElem.append(labelElem, dataElem); + + this.insertCustomRow(rowElem, position); + + this.updateCustomRowProperty(rowElem); + + this.updateCustomRowData(rowElem); + } + } + + cacheCustomRowElements() { + for (let position of Object.keys(this._customRowElemCache)) { + this._customRowElemCache[position] = Array.from( + this._infoTable.querySelectorAll( + `.meta-row[data-custom-row-id][data-position="${position}"]` + ) + ); + } + } + + restoreCustomRowElements() { + if (!this._customRowElemCache) return; + for (let position of Object.keys(this._customRowElemCache)) { + this._customRowElemCache[position].forEach((rowElem) => { + this.insertCustomRow(rowElem, position); + }); + this._customRowElemCache[position] = []; + } + } + + insertCustomRow(rowElem, position = "end") { + switch (position) { + case "start": { + this._infoTable.prepend(rowElem); + break; + } + case "afterCreators": { + // The `_firstRowBeforeCreators` is actually the first row after creator rows + if (this._firstRowBeforeCreators) { + this._infoTable.insertBefore(rowElem, this._firstRowBeforeCreators); + // Update the anchor node for creator rows + this._firstRowBeforeCreators = rowElem; + } + else { + this._infoTable.append(rowElem); + } + break; + } + case "end": + default: { + let dateAddedRow = this._infoTable.querySelector(".meta-label[fieldname=dateAdded]")?.parentElement; + if (dateAddedRow) { + this._infoTable.insertBefore(rowElem, dateAddedRow); + } + else { + this._infoTable.append(rowElem); + } + break; + } + } + } + + updateCustomRowProperty(rowElem) { + if (!this.item) return; + let rowID = rowElem.dataset.customRowId; + if (!rowID) return; + let valueElem = rowElem.querySelector(".meta-data > .value"); + if (!this.editable) valueElem.toggleAttribute('readonly', true); + + let onItemChange = Zotero.ItemPaneManager.getInfoRowHook(rowID, "onItemChange"); + if (!onItemChange) return; + try { + onItemChange({ + rowID, + item: this.item, + tabType: this.tabType, + editable: this.editable, + setEnabled: (enabled) => { + rowElem.hidden = !enabled; + }, + setEditable: (editable) => { + if (!this.editable) editable = false; + rowElem.querySelector(".meta-data > .value").toggleAttribute('readonly', !editable); + } + }); + } + catch (e) { + Zotero.logError(e); + } + } + + updateCustomRowData(rowElem) { + if (!this.item) return; + let rowID = rowElem.dataset.customRowId; + let onGetData = Zotero.ItemPaneManager.getInfoRowHook(rowID, "onGetData"); + if (!onGetData) return; + let data = ""; + try { + data = onGetData({ + rowID, + item: this.item, + tabType: this.tabType, + editable: this.editable, + }); + if (typeof data !== "string") { + throw new Error("ItemPaneInfoRow onGetData must return a string"); + } + } + catch (e) { + Zotero.logError(e); + } + let valueElem = rowElem.querySelector(".meta-data > .value"); + valueElem.value = data; + + // Attempt to make bidi things work automatically: + // If we have text to work off of, let the layout engine try to guess the text direction + if (data) { + valueElem.dir = 'auto'; + } + // If not, assume it follows the locale's direction + else { + valueElem.dir = Zotero.dir; + } } addItemTypeMenu() { @@ -881,10 +1058,10 @@ var labelWrapper = document.createElement('div'); labelWrapper.className = "meta-label"; labelWrapper.setAttribute("fieldname", "itemType"); - var label = document.createElement("label"); - label.className = "key"; - label.id = "itembox-field-itemType-label"; - label.innerText = Zotero.getString("zotero.items.itemType"); + var label = this.createLabelElement({ + id: "itembox-field-itemType-label", + text: Zotero.getString("zotero.items.itemType") + }); labelWrapper.appendChild(label); var rowData = document.createElement('div'); rowData.className = "meta-data"; @@ -1014,10 +1191,10 @@ } rowLabel.appendChild(labelWrapper); - var label = document.createElement("label"); - label.setAttribute('id', 'creator-type-label-inner'); - label.className = 'key'; - label.textContent = Zotero.getString('creatorTypes.' + Zotero.CreatorTypes.getName(typeID)); + let label = this.createLabelElement({ + id: 'creator-type-label-inner', + text: Zotero.getString('creatorTypes.' + Zotero.CreatorTypes.getName(typeID)) + }); labelWrapper.appendChild(label); var rowData = document.createElement("div"); @@ -1029,7 +1206,7 @@ var fieldName = 'creator-' + rowIndex + '-lastName'; var lastNameElem = firstlast.appendChild( - this.createValueElement( + this.createFieldValueElement( lastName, fieldName, ) @@ -1038,7 +1215,7 @@ lastNameElem.placeholder = this._defaultLastName; fieldName = 'creator-' + rowIndex + '-firstName'; var firstNameElem = firstlast.appendChild( - this.createValueElement( + this.createFieldValueElement( firstName, fieldName, ) @@ -1238,16 +1415,16 @@ var rowLabel = document.createElement("div"); rowLabel.className = "meta-label"; rowLabel.setAttribute("fieldname", field); - var label = document.createElement('label'); - label.className = 'key'; - label.textContent = Zotero.ItemFields.getLocalizedString(field); - label.setAttribute("id", `itembox-field-${field}-label`); + let label = this.createLabelElement({ + text: Zotero.ItemFields.getLocalizedString(field), + id: `itembox-field-${field}-label` + }); rowLabel.appendChild(label); var rowData = document.createElement('div'); rowData.className = "meta-data date-box"; - var elem = this.createValueElement( + var elem = this.createFieldValueElement( Zotero.Date.multipartToStr(value), field ); @@ -1459,27 +1636,53 @@ return openLink; } - createValueElement(valueText, fieldName) { - valueText += ''; - - if (fieldName) { - var fieldID = Zotero.ItemFields.getID(fieldName); + createLabelElement({ text, id, attributes, classList }) { + let label = document.createElement('label'); + label.classList.add('key', ...classList || []); + if (text) label.textContent = text; + if (id) label.id = id; + if (attributes) { + for (let [key, value] of Object.entries(attributes)) { + label.setAttribute(key, value); + } } - - let isMultiline = Zotero.ItemFields.isMultiline(fieldName); - let isNoWrap = fieldName.startsWith('creator-'); - - var valueElement = document.createXULElement("editable-text"); - valueElement.className = 'value'; + // On click of the label, toggle the focus of the value field + if (this.editable) { + label.addEventListener('mousedown', (event) => { + // Prevent default focus/blur behavior - we implement our own below + event.preventDefault(); + }); + + label.addEventListener('click', (event) => { + event.preventDefault(); + + let labelWrapper = label.closest(".meta-label"); + if (labelWrapper.nextSibling.contains(document.activeElement)) { + document.activeElement.blur(); + } + else { + let valueField = labelWrapper.nextSibling.firstChild; + if (valueField.id === "item-type-menu") { + valueField.querySelector("menupopup").openPopup(); + return; + } + labelWrapper.nextSibling.firstChild.focus(); + } + }); + } + return label; + } + + createValueElement({ isMultiline, isNoWrap, editable, text, tooltipText, id, attributes, classList } = {}) { + let valueElement = document.createXULElement("editable-text"); + valueElement.classList.add('value', ...classList || []); if (isMultiline) { valueElement.setAttribute('multiline', true); } else if (isNoWrap) { valueElement.setAttribute("nowrap", true); } - - - if (this._fieldIsClickable(fieldName)) { + if (editable) { valueElement.addEventListener("focus", e => this.showEditor(e.target)); valueElement.addEventListener("blur", e => this.hideEditor(e.target)); } @@ -1487,13 +1690,51 @@ valueElement.setAttribute('readonly', true); } - valueElement.setAttribute('id', `itembox-field-value-${fieldName}`); - valueElement.setAttribute('fieldname', fieldName); + if (id) valueElement.id = id; + if (tooltipText) valueElement.tooltipText = tooltipText; + if (attributes) { + for (let [key, value] of Object.entries(attributes)) { + valueElement.setAttribute(key, value); + } + } valueElement.setAttribute('tight', true); + valueElement.value = text; + if (text) { + valueElement.dir = 'auto'; + } + // If not, assume it follows the locale's direction + else { + valueElement.dir = Zotero.dir; + } + + // Regardless, align the text in the label consistently, following the locale's direction + if (Zotero.rtl) { + valueElement.style.textAlign = 'right'; + } + else { + valueElement.style.textAlign = 'left'; + } + return valueElement; + } + + createFieldValueElement(valueText, fieldName) { + valueText += ''; + + if (fieldName) { + var fieldID = Zotero.ItemFields.getID(fieldName); + } + + let isMultiline = Zotero.ItemFields.isMultiline(fieldName); + let isNoWrap = fieldName.startsWith('creator-'); + + let attributes = { + fieldname: fieldName, + }; + switch (fieldName) { case 'itemType': - valueElement.setAttribute('itemTypeID', valueText); + attributes.itemTypeID = valueText; valueText = Zotero.ItemTypes.getLocalizedString(valueText); break; @@ -1512,15 +1753,24 @@ break; } + let tooltipText; if (fieldID) { // Display the SQL date as a tooltip for date fields // TEMP - filingDate if (Zotero.ItemFields.isFieldOfBase(fieldID, 'date') || fieldName == 'filingDate') { - valueElement.tooltipText = Zotero.Date.multipartToSQL(this.item.getField(fieldName, true)); + tooltipText = Zotero.Date.multipartToSQL(this.item.getField(fieldName, true)); } } - valueElement.value = valueText; + let valueElement = this.createValueElement({ + isMultiline, + isNoWrap, + editable: this._fieldIsClickable(fieldName), + text: valueText, + tooltipText, + id: `itembox-field-value-${fieldName}`, + attributes + }); if (lazy.BIDI_BROWSER_UI) { // Attempt to guess text direction automatically @@ -1605,13 +1855,25 @@ } async showEditor(elem) { - Zotero.debug(`Showing editor for ${elem.getAttribute('fieldname')}`); + let isCustomRow = elem.classList.contains("custom-row-value"); + var fieldName = elem.getAttribute('fieldname'); + let isMultiline = Zotero.ItemFields.isMultiline(fieldName); + if (isCustomRow) { + isMultiline = elem.hasAttribute("multiline"); + } // Multiline field will be at least 6 lines - if (Zotero.ItemFields.isMultiline(fieldName)) { + if (isMultiline) { elem.setAttribute("min-lines", 6); } + + if (isCustomRow) { + return; + } + + Zotero.debug(`Showing editor for ${fieldName}`); + var [field, creatorIndex, creatorField] = fieldName.split('-'); let value; if (field == 'creator') { @@ -1861,20 +2123,54 @@ if (this.ignoreBlur || !textbox) { return; } + + var fieldName = textbox.getAttribute('fieldname'); + + let isMultiline = Zotero.ItemFields.isMultiline(fieldName); + let isCustomRow = textbox.classList.contains("custom-row-value"); + + if (isCustomRow) { + isMultiline = textbox.hasAttribute("multiline"); + } + + if (isMultiline) { + textbox.setAttribute("min-lines", 1); + } + + if (isCustomRow) { + let rowID = textbox.closest(".meta-row").dataset.customRowId; + let onSetData = Zotero.ItemPaneManager.getInfoRowHook(rowID, "onSetData"); + if (onSetData) { + try { + onSetData({ + rowID, + item: this.item, + tabType: this.tabType, + editable: this.editable, + value: textbox.value + }); + } + catch (e) { + Zotero.logError(e); + } + } + + return; + } + // Handle cases where creator autocomplete doesn't trigger // the textentered and change events handled in showEditor - if (textbox.getAttribute('fieldname').startsWith('creator-')) { + if (fieldName.startsWith('creator-')) { this.handleCreatorAutoCompleteSelect(textbox); } - - Zotero.debug(`Hiding editor for ${textbox.getAttribute('fieldname')}`); + + Zotero.debug(`Hiding editor for ${fieldName}`); // Prevent autocomplete breakage in Firefox 3 if (textbox.mController) { textbox.mController.input = null; } - var fieldName = textbox.getAttribute('fieldname'); // Multiline fields go back to occupying as much space as needed if (Zotero.ItemFields.isMultiline(fieldName)) { @@ -2241,7 +2537,7 @@ return; } - let field = activeElement.closest("[fieldname], [tabindex], [focusable]"); + let field = activeElement.closest("[fieldname], [tabindex], [focusable], .custom-row-value"); let fieldID; // Special treatment for creator rows. When an unsaved row is just added, creator rows ids // do not correspond to their positioning to avoid shifting all creators in case new row is not saved. @@ -2283,7 +2579,7 @@ } let refocusField = this.querySelector(`#${CSS.escape(this._selectField)}:not([disabled="true"])`); - // For creator rows, if a focusable node with desired id does not exist, try to focus + // For creator rows, if a focusable node with desired id does not exist, try to focus // the same component from the last available creator row if (!refocusField && this._selectField.startsWith("creator-")) { let maybeLastCreatorID = this._selectField.replace(/\d+/g, Math.max(this._creatorCount - 1, 0)); diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js index 75a136dca2..c9eb70caad 100644 --- a/chrome/content/zotero/elements/itemDetails.js +++ b/chrome/content/zotero/elements/itemDetails.js @@ -226,7 +226,7 @@ this._disableScrollHandler = false; this._pinnedPaneMinScrollHeight = 0; - this._lastUpdateCustomSection = 0; + this._lastUpdateCustomSection = ""; // If true, will render on tab select this._pendingRender = false; @@ -320,11 +320,10 @@ } renderCustomSections() { - let lastUpdate = Zotero.ItemPaneManager.getUpdateTime(); - if (this._lastUpdateCustomSection == lastUpdate) return; - this._lastUpdateCustomSection = lastUpdate; + let { options: targetPanes, updateID } = Zotero.ItemPaneManager.customSectionData; + if (this._lastUpdateCustomSection == updateID) return; + this._lastUpdateCustomSection = updateID; - let targetPanes = Zotero.ItemPaneManager.getCustomSections(); let currentPaneElements = this.getCustomPanes(); // Remove for (let elem of currentPaneElements) { diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index b329388e6c..e2a1fcb91c 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -2955,7 +2955,8 @@ var ItemTree = class ItemTree extends LibraryTree { // Pass document to renderCell so that it can create elements cell = column.renderCell.apply(this, [...arguments, document]); // Ensure that renderCell returns an Element - if (!(cell instanceof Element)) { + if (!(cell instanceof window.Element)) { + cell = null; throw new Error('renderCell must return an Element'); } } diff --git a/chrome/content/zotero/itemTreeColumns.jsx b/chrome/content/zotero/itemTreeColumns.jsx index 8ad41638d5..bf30f1b033 100644 --- a/chrome/content/zotero/itemTreeColumns.jsx +++ b/chrome/content/zotero/itemTreeColumns.jsx @@ -48,7 +48,6 @@ const Icons = require('components/icons'); * @property {boolean} [showInColumnPicker=true] - Default: true. Set to true to show in column picker. * @property {boolean} [columnPickerSubMenu=false] - Default: false. Set to true to display the column in "More Columns" submenu of column picker. * @property {boolean} [primary] - Should only be one column at the time. Title is the primary column - * @property {boolean} [custom] - Set automatically to true when the column is added by the user * @property {(item: Zotero.Item, dataKey: string) => string} [dataProvider] - Custom data provider that is called when rendering cells * @property {(index: number, data: string, column: ItemTreeColumnOptions & {className: string}) => HTMLElement} [renderCell] - The cell renderer function * @property {string[]} [zoteroPersist] - Which column properties should be persisted between zotero close diff --git a/chrome/content/zotero/xpcom/itemPaneManager.js b/chrome/content/zotero/xpcom/itemPaneManager.js deleted file mode 100644 index daa96758fb..0000000000 --- a/chrome/content/zotero/xpcom/itemPaneManager.js +++ /dev/null @@ -1,338 +0,0 @@ -/* - ***** BEGIN LICENSE BLOCK ***** - - Copyright © 2024 Corporation for Digital Scholarship - Vienna, Virginia, USA - https://digitalscholar.org - - This file is part of Zotero. - - Zotero is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Zotero is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with Zotero. If not, see . - - ***** END LICENSE BLOCK ***** -*/ - - -/** - * @typedef SectionIcon - * @type {object} - * @property {string} icon - Icon URI - * @property {string} [darkIcon] - Icon URI in dark mode. If not set, use `icon` - * @typedef SectionL10n - * @type {object} - * @property {string} l10nID - data-l10n-id for localization of section header label - * @property {string} [l10nArgs] - data-l10n-args for localization - * @typedef SectionButton - * @type {object} - * @property {string} type - Button type, must be valid DOMString and without "," - * @property {string} icon - Icon URI - * @property {string} [darkIcon] - Icon URI in dark mode. If not set, use `icon` - * @property {string} [l10nID] - data-l10n-id for localization of button tooltiptext - * @property {(props: SectionEventHookArgs) => void} onClick - Button click callback - * @typedef SectionBasicHookArgs - * @type {object} - * @property {string} paneID - Registered pane id - * @property {Document} doc - Document of section - * @property {HTMLDivElement} body - Section body - * @typedef SectionUIHookArgs - * @type {object} - * @property {Zotero.Item} item - Current item - * @property {string} tabType - Current tab type - * @property {boolean} editable - Whether the section is in edit mode - * @property {(l10nArgs: string) => void} setL10nArgs - Set l10n args for section header - * @property {(l10nArgs: string) => void} setEnabled - Set pane enabled state - * @property {(summary: string) => void} setSectionSummary - Set pane section summary, - * the text shown in the section header when the section is collapsed. - * - * See the Abstract section as an example - * @property {(buttonType: string, options: {disabled?: boolean; hidden?: boolean}) => void} setSectionButtonStatus - Set pane section button status - * @typedef SectionHookArgs - * @type {SectionBasicHookArgs & SectionUIHookArgs} - * @typedef {SectionHookArgs & { refresh: () => Promise }} SectionInitHookArgs - * A `refresh` is exposed to plugins to allows plugins to refresh the section when necessary, - * e.g. item modify notifier callback. Note that calling `refresh` during initialization - * have no effect. - * @typedef {SectionHookArgs & { event: Event }} SectionEventHookArgs - * @typedef ItemDetailsSectionOptions - * @type {object} - * @property {string} paneID - Unique pane ID - * @property {string} pluginID - Set plugin ID to auto remove section when plugin is disabled/removed - * @property {SectionL10n & SectionIcon} header - Header options. Icon should be 16*16 and `label` need to be localized - * @property {SectionL10n & SectionIcon} sidenav - Sidenav options. Icon should be 20*20 and `tooltiptext` need to be localized - * @property {string} [bodyXHTML] - Pane body's innerHTML, default to XUL namespace - * @property {(props: SectionInitHookArgs) => void} [onInit] - * Lifecycle hook called when section is initialized. - * You can use destructuring assignment to get the props: - * ```js - * onInit({ paneID, doc, body, item, editable, tabType, setL10nArgs, setEnabled, - * setSectionSummary, setSectionButtonStatus, refresh }) { - * // Your code here - * } - * ``` - * - * Do: - * 1. Initialize data if necessary - * 2. Set up hooks, e.g. notifier callback - * - * Don't: - * 1. Render/refresh UI - * @property {(props: SectionBasicHookArgs) => void} [onDestroy] - * Lifecycle hook called when section is destroyed - * - * Do: - * 1. Remove data and release resource - * 2. Remove hooks, e.g. notifier callback - * - * Don't: - * 1. Render/refresh UI - * @property {(props: SectionHookArgs) => boolean} [onItemChange] - * Lifecycle hook called when section's item change received - * - * Do: - * 1. Update data (no need to render or refresh); - * 2. Update the section enabled state with `props.setEnabled`. For example, if the section - * is only enabled in the readers, you can use: - * ```js - * onItemChange({ setEnabled }) { - * setEnabled(newData.value === "reader"); - * } - * ``` - * - * Don't: - * 1. Render/refresh UI - * @property {(props: SectionHookArgs) => void} onRender - * Lifecycle hook called when section should do initial render. Cannot be async. - * - * Create elements and append them to `props.body`. - * - * If the rendering is slow, you should make the bottleneck async and move it to `onAsyncRender`. - * - * > Note that the rendering of section is fully controlled by Zotero to minimize resource usage. - * > Only render UI things when you are told to. - * @property {(props: SectionHookArgs) => void | Promise} [onAsyncRender] - * [Optional] Lifecycle hook called when section should do async render - * - * The best practice to time-consuming rendering with runtime decided section height is: - * 1. Compute height and create a box in sync `onRender`; - * 2. Render actual contents in async `onAsyncRender`. - * @property {(props: SectionEventHookArgs) => void} [onToggle] - Called when section is toggled - * @property {SectionButton[]} [sectionButtons] - Section button options - */ - - -class ItemPaneManager { - _customSections = {}; - - _lastUpdateTime = 0; - - /** - * Register a custom section in item pane. All registered sections must be valid, and must have a unique paneID. - * @param {ItemDetailsSectionOptions} options - section data - * @returns {string | false} - The paneID or false if no section were added - */ - registerSection(options) { - let registeredID = this._addSection(options); - if (!registeredID) { - return false; - } - this._addPluginShutdownObserver(); - this._notifyItemPane(); - return registeredID; - } - - /** - * Unregister a custom column. - * @param {string} paneID - The paneID of the section(s) to unregister - * @returns {boolean} true if the column(s) are unregistered - */ - unregisterSection(paneID) { - const success = this._removeSection(paneID); - if (!success) { - return false; - } - this._notifyItemPane(); - return true; - } - - getUpdateTime() { - return this._lastUpdateTime; - } - - /** - * @returns {ItemDetailsSectionOptions[]} - */ - getCustomSections() { - return Object.values(this._customSections).map(opt => Object.assign({}, opt)); - } - - /** - * @param {ItemDetailsSectionOptions} options - * @returns {string | false} - */ - _addSection(options) { - options = Object.assign({}, options); - options.paneID = this._namespacedDataKey(options); - if (!this._validateSectionOptions(options)) { - return false; - } - this._customSections[options.paneID] = options; - return options.paneID; - } - - _removeSection(paneID) { - // If any check fails, return check results and do not remove any section - if (!this._customSections[paneID]) { - Zotero.warn(`ItemPaneManager: Can't remove unknown section '${paneID}'`); - return false; - } - delete this._customSections[paneID]; - return true; - } - - /** - * @param {ItemDetailsSectionOptions} options - * @returns {boolean} - */ - _validateSectionOptions(options) { - let requiredParamsType = { - paneID: "string", - pluginID: "string", - header: (val) => { - if (typeof val != "object") { - return "ItemPaneManager: 'header' must be object"; - } - if (!val.l10nID || typeof val.l10nID != "string") { - return "ItemPaneManager: header.l10nID must be a non-empty string"; - } - if (!val.icon || typeof val.icon != "string") { - return "ItemPaneManager: header.icon must be a non-empty string"; - } - return true; - }, - sidenav: (val) => { - if (typeof val != "object") { - return "ItemPaneManager: 'sidenav' must be object"; - } - if (!val.l10nID || typeof val.l10nID != "string") { - return "ItemPaneManager: sidenav.l10nID must be a non-empty string"; - } - if (!val.icon || typeof val.icon != "string") { - return "ItemPaneManager: sidenav.icon must be a non-empty string"; - } - return true; - }, - }; - // Keep in sync with itemDetails.js - let builtInPaneIDs = [ - "info", - "abstract", - "attachments", - "notes", - "attachment-info", - "attachment-annotations", - "libraries-collections", - "tags", - "related" - ]; - for (let key of Object.keys(requiredParamsType)) { - let val = options[key]; - if (!val) { - Zotero.warn(`ItemPaneManager: Section options must have ${key}`); - return false; - } - let requiredType = requiredParamsType[key]; - if (typeof requiredType == "string" && typeof val != requiredType) { - Zotero.warn(`ItemPaneManager: Section option '${key}' must be ${requiredType}, got ${typeof val}`); - return false; - } - if (typeof requiredType == "function") { - let result = requiredType(val); - if (result !== true) { - Zotero.warn(result); - return false; - } - } - } - if (builtInPaneIDs.includes(options.paneID)) { - Zotero.warn(`ItemPaneManager: 'paneID' must not conflict with built-in paneID, got ${options.paneID}`); - return false; - } - if (this._customSections[options.paneID]) { - Zotero.warn(`ItemPaneManager: 'paneID' must be unique, got ${options.paneID}`); - return false; - } - - return true; - } - - /** - * Make sure the dataKey is namespaced with the plugin ID - * @param {ItemDetailsSectionOptions} options - * @returns {string} - */ - _namespacedDataKey(options) { - if (options.pluginID && options.paneID) { - // Make sure the return value is valid as class name or element id - return `${options.pluginID}-${options.paneID}`.replace(/[^a-zA-Z0-9-_]/g, "-"); - } - return options.paneID; - } - - async _notifyItemPane() { - this._lastUpdateTime = new Date().getTime(); - await Zotero.DB.executeTransaction(async function () { - Zotero.Notifier.queue( - 'refresh', - 'itempane', - [], - {}, - ); - }); - } - - /** - * Unregister all columns registered by a plugin - * @param {string} pluginID - Plugin ID - */ - async _unregisterSectionByPluginID(pluginID) { - let paneIDs = Object.keys(this._customSections).filter(id => this._customSections[id].pluginID == pluginID); - if (paneIDs.length === 0) { - return; - } - // Remove the columns one by one - // This is to ensure that the columns are removed and not interrupted by any non-existing columns - paneIDs.forEach(id => this._removeSection(id)); - Zotero.debug(`ItemPaneManager: Section for plugin ${pluginID} unregistered due to shutdown`); - await this._notifyItemPane(); - } - - /** - * Ensure that the shutdown observer is added - * @returns {void} - */ - _addPluginShutdownObserver() { - if (this._observerAdded) { - return; - } - - Zotero.Plugins.addObserver({ - shutdown: ({ id: pluginID }) => { - this._unregisterSectionByPluginID(pluginID); - } - }); - this._observerAdded = true; - } -} - -Zotero.ItemPaneManager = new ItemPaneManager(); diff --git a/chrome/content/zotero/xpcom/itemTreeManager.js b/chrome/content/zotero/xpcom/itemTreeManager.js deleted file mode 100644 index 59aca92c4d..0000000000 --- a/chrome/content/zotero/xpcom/itemTreeManager.js +++ /dev/null @@ -1,378 +0,0 @@ -/* - ***** BEGIN LICENSE BLOCK ***** - - Copyright © 2023 Corporation for Digital Scholarship - Vienna, Virginia, USA - https://digitalscholar.org - - This file is part of Zotero. - - Zotero is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Zotero is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with Zotero. If not, see . - - ***** END LICENSE BLOCK ***** -*/ - -const { COLUMNS: ITEMTREE_COLUMNS } = require("zotero/itemTreeColumns"); - -/** - * @typedef {import("../itemTreeColumns.jsx").ItemTreeColumnOptions} ItemTreeColumnOptions - * @typedef {"dataKey" | "label" | "pluginID"} RequiredCustomColumnOptionKeys - * @typedef {Required>} RequiredCustomColumnOptionsPartial - * @typedef {Omit} CustomColumnOptionsPartial - * @typedef {RequiredCustomColumnOptionsPartial & CustomColumnOptionsPartial} ItemTreeCustomColumnOptions - * @typedef {Partial>} ItemTreeCustomColumnFilters - */ - -class ItemTreeManager { - _observerAdded = false; - - /** @type {Record { - * return item.getField('title').split('').reverse().join(''); - * }, - * }); - * ``` - * @example - * A custom column using all available options. - * Note that the column will only be shown in the main item tree. - * ```js - * const registeredDataKey = await Zotero.ItemTreeManager.registerColumns( - * { - * dataKey: 'rtitle', - * label: 'Reversed Title', - * enabledTreeIDs: ['main'], // only show in the main item tree - * sortReverse: true, // sort by increasing order - * flex: 0, // don't take up all available space - * width: 100, // assign fixed width in pixels - * fixedWidth: true, // don't allow user to resize - * staticWidth: true, // don't allow column to be resized when the tree is resized - * minWidth: 50, // minimum width in pixels - * iconPath: 'chrome://zotero/skin/tick.png', // icon to show in the column header - * htmlLabel: 'reversed title', // use HTML in the label. This will override the label and iconPath property - * showInColumnPicker: true, // show in the column picker - * columnPickerSubMenu: true, // show in the column picker submenu - * pluginID: 'make-it-red@zotero.org', // plugin ID, which will be used to unregister the column when the plugin is unloaded - * dataProvider: (item, dataKey) => { - * // item: the current item in the row - * // dataKey: the dataKey of the column - * // return: the data to display in the column - * return item.getField('title').split('').reverse().join(''); - * }, - * renderCell: (index, data, column, isFirstColumn, doc) => { - * // index: the index of the row - * // data: the data to display in the column, return of `dataProvider` - * // column: the column options - * // isFirstColumn: true if this is the first column - * // doc: the document of the item tree - * // return: the HTML to display in the cell - * const cell = doc.createElement('span'); - * cell.className = `cell ${column.className}`; - * cell.textContent = data; - * cell.style.color = 'red'; - * return cell; - * }, - * zoteroPersist: ['width', 'hidden', 'sortDirection'], // persist the column properties - * }); - * ``` - * @example - * Register multiple custom columns: - * ```js - * const registeredDataKeys = await Zotero.ItemTreeManager.registerColumns( - * [ - * { - * dataKey: 'rtitle', - * iconPath: 'chrome://zotero/skin/tick.png', - * label: 'Reversed Title', - * pluginID: 'make-it-red@zotero.org', // Replace with your plugin ID - * dataProvider: (item, dataKey) => { - * return item.getField('title').split('').reverse().join(''); - * }, - * }, - * { - * dataKey: 'utitle', - * label: 'Uppercase Title', - * pluginID: 'make-it-red@zotero.org', // Replace with your plugin ID - * dataProvider: (item, dataKey) => { - * return item.getField('title').toUpperCase(); - * }, - * }, - * ]); - * ``` - */ - async registerColumns(options) { - const registeredDataKeys = this._addColumns(options); - if (!registeredDataKeys) { - return false; - } - this._addPluginShutdownObserver(); - await this._notifyItemTrees(); - return registeredDataKeys; - } - - /** - * Unregister a custom column. - * Although it's async, resolving does not promise the item trees are updated. - * @param {string | string[]} dataKeys - The dataKey of the column to unregister - * @returns {boolean} true if the column(s) are unregistered - * @example - * The `registeredDataKey` is returned by the `registerColumns` function. - * ```js - * Zotero.ItemTreeManager.unregisterColumns(registeredDataKey); - * ``` - */ - async unregisterColumns(dataKeys) { - const success = this._removeColumns(dataKeys); - if (!success) { - return false; - } - await this._notifyItemTrees(); - return true; - } - - /** - * Get column(s) that matches the properties of option - * @param {string | string[]} [filterTreeIDs] - The tree IDs to match - * @param {ItemTreeCustomColumnFilters} [options] - An option or array of options to match - * @returns {ItemTreeCustomColumnOptions[]} - */ - getCustomColumns(filterTreeIDs, options) { - const allColumns = Object.values(this._customColumns).map(opt => Object.assign({}, opt)); - if (!filterTreeIDs && !options) { - return allColumns; - } - let filteredColumns = allColumns; - if (typeof filterTreeIDs === "string") { - filterTreeIDs = [filterTreeIDs]; - } - if (filterTreeIDs && !filterTreeIDs.includes("*")) { - const filterTreeIDsSet = new Set(filterTreeIDs); - filteredColumns = filteredColumns.filter((column) => { - if (column.enabledTreeIDs[0] == "*") return true; - - for (const treeID of column.enabledTreeIDs) { - if (filterTreeIDsSet.has(treeID)) return true; - } - return false; - }); - } - if (options) { - filteredColumns = filteredColumns.filter((col) => { - return Object.keys(options).every((key) => { - // Ignore undefined properties - if (options[key] === undefined) { - return true; - } - return options[key] === col[key]; - }); - }); - } - return filteredColumns; - } - - /** - * Check if a column is registered as a custom column - * @param {string} dataKey - The dataKey of the column - * @returns {boolean} true if the column is registered as a custom column - */ - isCustomColumn(dataKey) { - return !!this._customColumns[dataKey]; - } - - /** - * A centralized data source for custom columns. This is used by the ItemTreeRow to get data. - * @param {Zotero.Item} item - The item to get data from - * @param {string} dataKey - The dataKey of the column - * @returns {string} - */ - getCustomCellData(item, dataKey) { - const options = this._customColumns[dataKey]; - if (options && options.dataProvider) { - try { - return options.dataProvider(item, dataKey); - } - catch (e) { - Zotero.logError(e); - } - } - return ""; - } - - /** - * Check if column options is valid. - * All its children must be valid. Otherwise, the validation fails. - * @param {ItemTreeCustomColumnOptions[]} options - An array of options to validate - * @returns {boolean} true if the options are valid - */ - _validateColumnOption(options) { - // Check if the input option has duplicate dataKeys - const noInputDuplicates = !options.find((opt, i, arr) => arr.findIndex(o => o.dataKey === opt.dataKey) !== i); - if (!noInputDuplicates) { - Zotero.warn(`ItemTree Column options have duplicate dataKey.`); - } - const noDeniedProperties = options.every((option) => { - const valid = !option.primary; - return valid; - }); - const requiredProperties = options.every((option) => { - const valid = option.dataKey && option.label && option.pluginID; - if (!valid) { - Zotero.warn(`ItemTree Column option ${JSON.stringify(option)} must have dataKey, label, and pluginID.`); - } - return valid; - }); - const noRegisteredDuplicates = options.every((option) => { - const valid = !this._customColumns[option.dataKey] && !ITEMTREE_COLUMNS.find(col => col.dataKey === option.dataKey); - if (!valid) { - Zotero.warn(`ItemTree Column option ${JSON.stringify(option)} with dataKey ${option.dataKey} already exists.`); - } - return valid; - }); - return noInputDuplicates && noDeniedProperties && requiredProperties && noRegisteredDuplicates; - } - - - /** - * Add a new column or new columns. - * If the options is an array, all its children must be valid. - * Otherwise, no columns are added. - * @param {ItemTreeCustomColumnOptions | ItemTreeCustomColumnOptions[]} options - An option or array of options to add - * @returns {string | string[] | false} - The dataKey(s) of the added column(s) or false if no columns were added - */ - _addColumns(options) { - const isSingle = !Array.isArray(options); - if (isSingle) { - options = [options]; - } - options.forEach((o) => { - o.dataKey = this._namespacedDataKey(o); - o.enabledTreeIDs = o.enabledTreeIDs || ["main"]; - if (o.enabledTreeIDs.includes("*")) { - o.enabledTreeIDs = ["*"]; - } - o.showInColumnPicker = o.showInColumnPicker === undefined ? true : o.showInColumnPicker; - }); - // If any check fails, return check results - if (!this._validateColumnOption(options)) { - return false; - } - for (const opt of options) { - this._customColumns[opt.dataKey] = Object.assign({}, opt, { custom: true }); - } - return isSingle ? options[0].dataKey : options.map(opt => opt.dataKey); - } - - /** - * Remove a column option - * @param {string | string[]} dataKeys - The dataKey of the column to remove - * @returns {boolean} - True if column(s) were removed, false if not - */ - _removeColumns(dataKeys) { - if (!Array.isArray(dataKeys)) { - dataKeys = [dataKeys]; - } - // If any check fails, return check results and do not remove any columns - for (const key of dataKeys) { - if (!this._customColumns[key]) { - Zotero.warn(`ItemTree Column option with dataKey ${key} does not exist.`); - return false; - } - } - for (const key of dataKeys) { - delete this._customColumns[key]; - } - return true; - } - - /** - * Make sure the dataKey is namespaced with the plugin ID - * @param {ItemTreeCustomColumnOptions} options - * @returns {string} - */ - _namespacedDataKey(options) { - if (options.pluginID && options.dataKey) { - // Make sure the return value is valid as class name or element id - return `${options.pluginID}-${options.dataKey}`.replace(/[^a-zA-Z0-9-_]/g, "-"); - } - return options.dataKey; - } - - - /** - * Reset the item trees to update the columns - */ - async _notifyItemTrees() { - await Zotero.DB.executeTransaction(async function () { - Zotero.Notifier.queue( - 'refresh', - 'itemtree', - [], - {}, - ); - }); - } - - /** - * Unregister all columns registered by a plugin - * @param {string} pluginID - Plugin ID - */ - async _unregisterColumnByPluginID(pluginID) { - const columns = this.getCustomColumns(undefined, { pluginID }); - if (columns.length === 0) { - return; - } - // Remove the columns one by one - // This is to ensure that the columns are removed and not interrupted by any non-existing columns - columns.forEach(column => this._removeColumns(column.dataKey)); - Zotero.debug(`ItemTree columns registered by plugin ${pluginID} unregistered due to shutdown`); - await this._notifyItemTrees(); - } - - /** - * Ensure that the shutdown observer is added - * @returns {void} - */ - _addPluginShutdownObserver() { - if (this._observerAdded) { - return; - } - - Zotero.Plugins.addObserver({ - shutdown: ({ id: pluginID }) => { - this._unregisterColumnByPluginID(pluginID); - } - }); - this._observerAdded = true; - } -} - -Zotero.ItemTreeManager = new ItemTreeManager(); diff --git a/chrome/content/zotero/xpcom/notifier.js b/chrome/content/zotero/xpcom/notifier.js index e0b7db7de8..74b550fb46 100644 --- a/chrome/content/zotero/xpcom/notifier.js +++ b/chrome/content/zotero/xpcom/notifier.js @@ -51,7 +51,8 @@ Zotero.Notifier = new function () { 'api-key', 'tab', 'itemtree', - 'itempane' + 'itempane', + 'infobox', ]; var _transactionID = false; var _queue = {}; diff --git a/chrome/content/zotero/xpcom/pluginAPI/itemPaneManager.js b/chrome/content/zotero/xpcom/pluginAPI/itemPaneManager.js new file mode 100644 index 0000000000..a60d99c9eb --- /dev/null +++ b/chrome/content/zotero/xpcom/pluginAPI/itemPaneManager.js @@ -0,0 +1,347 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2024 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + + +{ + const PluginAPIBase = ChromeUtils.importESModule("chrome://zotero/content/xpcom/pluginAPI/pluginAPIBase.mjs").PluginAPIBase; + + + /** + * @typedef SectionIcon + * @type {object} + * @property {string} icon - Icon URI + * @property {string} [darkIcon] - Icon URI in dark mode. If not set, use `icon` + * @typedef SectionL10n + * @type {object} + * @property {string} l10nID - data-l10n-id for localization of section header label + * @property {string} [l10nArgs] - data-l10n-args for localization + * @typedef SectionButton + * @type {object} + * @property {string} type - Button type, must be valid DOMString and without "," + * @property {string} icon - Icon URI + * @property {string} [darkIcon] - Icon URI in dark mode. If not set, use `icon` + * @property {string} [l10nID] - data-l10n-id for localization of button tooltiptext + * @property {(props: SectionEventHookArgs) => void} onClick - Button click callback + * @typedef SectionBasicHookArgs + * @type {object} + * @property {string} paneID - Registered pane id + * @property {Document} doc - Document of section + * @property {HTMLDivElement} body - Section body + * @typedef SectionUIHookArgs + * @type {object} + * @property {Zotero.Item} item - Current item + * @property {string} tabType - Current tab type + * @property {boolean} editable - Whether the section is in edit mode + * @property {(l10nArgs: string) => void} setL10nArgs - Set l10n args for section header + * @property {(l10nArgs: string) => void} setEnabled - Set pane enabled state + * @property {(summary: string) => void} setSectionSummary - Set pane section summary, + * the text shown in the section header when the section is collapsed. + * + * See the Abstract section as an example + * @property {(buttonType: string, options: {disabled?: boolean; hidden?: boolean}) => void} setSectionButtonStatus - Set pane section button status + * @typedef SectionHookArgs + * @type {SectionBasicHookArgs & SectionUIHookArgs} + * @typedef {SectionHookArgs & { refresh: () => Promise }} SectionInitHookArgs + * A `refresh` is exposed to plugins to allows plugins to refresh the section when necessary, + * e.g. item modify notifier callback. Note that calling `refresh` during initialization + * have no effect. + * @typedef {SectionHookArgs & { event: Event }} SectionEventHookArgs + * @typedef ItemDetailsSectionOptions + * @type {object} + * @property {string} paneID - Unique pane ID + * @property {string} pluginID - Set plugin ID to auto remove section when plugin is disabled/removed + * @property {SectionL10n & SectionIcon} header - Header options. Icon should be 16*16 and `label` need to be localized + * @property {SectionL10n & SectionIcon} sidenav - Sidenav options. Icon should be 20*20 and `tooltiptext` need to be localized + * @property {string} [bodyXHTML] - Pane body's innerHTML, default to XUL namespace + * @property {(props: SectionInitHookArgs) => void} [onInit] + * Lifecycle hook called when section is initialized. + * You can use destructuring assignment to get the props: + * ```js + * onInit({ paneID, doc, body, item, editable, tabType, setL10nArgs, setEnabled, + * setSectionSummary, setSectionButtonStatus, refresh }) { + * // Your code here + * } + * ``` + * + * Do: + * 1. Initialize data if necessary + * 2. Set up hooks, e.g. notifier callback + * + * Don't: + * 1. Render/refresh UI + * @property {(props: SectionBasicHookArgs) => void} [onDestroy] + * Lifecycle hook called when section is destroyed + * + * Do: + * 1. Remove data and release resource + * 2. Remove hooks, e.g. notifier callback + * + * Don't: + * 1. Render/refresh UI + * @property {(props: SectionHookArgs) => boolean} [onItemChange] + * Lifecycle hook called when section's item change received + * + * Do: + * 1. Update data (no need to render or refresh); + * 2. Update the section enabled state with `props.setEnabled`. For example, if the section + * is only enabled in the readers, you can use: + * ```js + * onItemChange({ setEnabled }) { + * setEnabled(newData.value === "reader"); + * } + * ``` + * + * Don't: + * 1. Render/refresh UI + * @property {(props: SectionHookArgs) => void} onRender + * Lifecycle hook called when section should do initial render. Cannot be async. + * + * Create elements and append them to `props.body`. + * + * If the rendering is slow, you should make the bottleneck async and move it to `onAsyncRender`. + * + * > Note that the rendering of section is fully controlled by Zotero to minimize resource usage. + * > Only render UI things when you are told to. + * @property {(props: SectionHookArgs) => void | Promise} [onAsyncRender] + * [Optional] Lifecycle hook called when section should do async render + * + * The best practice to time-consuming rendering with runtime decided section height is: + * 1. Compute height and create a box in sync `onRender`; + * 2. Render actual contents in async `onAsyncRender`. + * @property {(props: SectionEventHookArgs) => void} [onToggle] - Called when section is toggled + * @property {SectionButton[]} [sectionButtons] - Section button options + */ + + + class ItemPaneSectionManagerInternal extends PluginAPIBase { + constructor() { + super(); + this.config = { + apiName: "ItemPaneSectionAPI", + mainKeyName: "paneID", + notifyType: "itempane", + optionTypeDefinition: { + paneID: "string", + pluginID: "string", + bodyXHTML: { + type: "string", + optional: true, + }, + onInit: { + type: "function", + optional: true, + }, + onDestroy: { + type: "function", + optional: true, + }, + onItemChange: { + type: "function", + optional: true, + }, + onRender: "function", + onAsyncRender: { + type: "function", + optional: true, + }, + onToggle: { + type: "function", + optional: true, + }, + header: { + type: "object", + children: { + l10nID: "string", + l10nArgs: { + type: "string", + optional: true, + }, + icon: "string", + darkIcon: { + type: "string", + optional: true, + }, + } + }, + sidenav: { + type: "object", + children: { + l10nID: "string", + l10nArgs: { + type: "string", + optional: true, + }, + icon: "string", + darkIcon: { + type: "string", + optional: true, + }, + } + }, + sectionButtons: { + type: "array", + optional: true, + children: { + type: "string", + icon: "string", + darkIcon: { + type: "string", + optional: true, + }, + l10nID: { + type: "string", + optional: true, + }, + onClick: "function", + } + }, + }, + }; + } + + _validate(option) { + // Keep in sync with itemDetails.js + let builtInPaneIDs = [ + "info", + "abstract", + "attachments", + "notes", + "attachment-info", + "attachment-annotations", + "libraries-collections", + "tags", + "related" + ]; + + let mainKey = this._namespacedMainKey(option); + if (builtInPaneIDs.includes(mainKey)) { + this._log(`'paneID' must not conflict with built-in paneID, got ${mainKey}`, "warn"); + return false; + } + + return super._validate(option); + } + } + + + class ItemPaneInfoRowManagerInternal extends PluginAPIBase { + constructor() { + super(); + this.config = { + apiName: "ItemPaneInfoRowAPI", + mainKeyName: "rowID", + notifyType: "infobox", + optionTypeDefinition: { + rowID: "string", + pluginID: "string", + label: { + type: "object", + children: { + l10nID: "string", + } + }, + position: { + type: "string", + optional: true, + checkHook: (val) => { + if (typeof val !== "undefined" + && !["start", "afterCreators", "end"].includes(val)) { + return `"position" must be "start", "afterCreators", or "end", got ${val}`; + } + return true; + } + }, + multiline: { + type: "boolean", + optional: true, + }, + nowrap: { + type: "boolean", + optional: true, + }, + editable: { + type: "boolean", + optional: true, + }, + onGetData: { + type: "function", + optional: false, + fallbackReturn: "", + }, + onSetData: { + type: "function", + optional: true, + }, + onItemChange: { + type: "function", + optional: true, + }, + }, + }; + } + } + + + class ItemPaneManager { + _sectionManager = new ItemPaneSectionManagerInternal(); + + _infoRowManager = new ItemPaneInfoRowManagerInternal(); + + registerSection(options) { + return this._sectionManager.register(options); + } + + unregisterSection(paneID) { + return this._sectionManager.unregister(paneID); + } + + get customSectionData() { + return this._sectionManager.data; + } + + registerInfoRow(options) { + return this._infoRowManager.register(options); + } + + unregisterInfoRow(rowID) { + return this._infoRowManager.unregister(rowID); + } + + get customInfoRowData() { + return this._infoRowManager.data; + } + + getInfoRowHook(rowID, type) { + let option = this._infoRowManager._optionsCache[rowID]; + if (!option) { + return undefined; + } + return option[type]; + } + } + + + Zotero.ItemPaneManager = new ItemPaneManager(); +} diff --git a/chrome/content/zotero/xpcom/pluginAPI/itemTreeManager.js b/chrome/content/zotero/xpcom/pluginAPI/itemTreeManager.js new file mode 100644 index 0000000000..8e9fb61bd4 --- /dev/null +++ b/chrome/content/zotero/xpcom/pluginAPI/itemTreeManager.js @@ -0,0 +1,339 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2023 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + + +import { COLUMNS } from 'zotero/itemTreeColumns'; + + +{ + const PluginAPIBase = ChromeUtils.importESModule("chrome://zotero/content/xpcom/pluginAPI/pluginAPIBase.mjs").PluginAPIBase; + + + /** + * @typedef {import("../../itemTreeColumns.jsx").ItemTreeColumnOptions} ItemTreeColumnOptions + * @typedef {"dataKey" | "label" | "pluginID"} RequiredCustomColumnOptionKeys + * @typedef {Required>} RequiredCustomColumnOptionsPartial + * @typedef {Omit} CustomColumnOptionsPartial + * @typedef {RequiredCustomColumnOptionsPartial & CustomColumnOptionsPartial} ItemTreeCustomColumnOptions + * @typedef {Partial>} ItemTreeCustomColumnFilters + */ + + + class ItemTreeColumnManagerInternal extends PluginAPIBase { + constructor() { + super(); + this.config = { + apiName: "ItemTreeColumnManager", + mainKeyName: "dataKey", + notifyType: "itemtree", + optionTypeDefinition: { + dataKey: "string", + label: "string", + pluginID: "string", + enabledTreeIDs: { + type: "array", + optional: true, + }, + defaultIn: { + type: "array", + optional: true, + checkHook: (_value) => { + this._log("The 'defaultIn' property is deprecated. Use 'enabledTreeIDs' instead.", "warn"); + return true; + } + }, + disableIn: { + type: "array", + optional: true, + checkHook: (_value) => { + this._log("The 'disableIn' property is deprecated. Use 'enabledTreeIDs' instead.", "warn"); + return true; + } + }, + sortReverse: { + type: "boolean", + optional: true, + }, + flex: { + type: "number", + optional: true, + }, + width: { + type: "string", + optional: true, + }, + fixedWidth: { + type: "boolean", + optional: true, + }, + staticWidth: { + type: "boolean", + optional: true, + }, + noPadding: { + type: "boolean", + optional: true, + }, + minWidth: { + type: "number", + optional: true, + }, + iconLabel: { + type: "object", + optional: true, + }, + iconPath: { + type: "string", + optional: true, + }, + htmlLabel: { + type: "any", + optional: true, + }, + showInColumnPicker: { + type: "boolean", + optional: true, + }, + columnPickerSubMenu: { + type: "boolean", + optional: true, + }, + dataProvider: { + type: "function", + optional: true, + fallbackReturn: "", + }, + renderCell: { + type: "function", + optional: true, + fallbackReturn: null, + }, + zoteroPersist: { + type: "array", + optional: true, + }, + }, + }; + } + + _add(option) { + option = Object.assign({}, option); + option.enabledTreeIDs = option.enabledTreeIDs || ["main"]; + if (option.enabledTreeIDs.includes("*")) { + option.enabledTreeIDs = ["*"]; + } + option.showInColumnPicker = option.showInColumnPicker === undefined ? true : option.showInColumnPicker; + return super._add(option); + } + + _validate(option) { + let mainKey = this._namespacedMainKey(option); + + if (COLUMNS.find(col => col.dataKey === mainKey)) { + this._log(`'${mainKey}' already exists as a built-in column`, "warn"); + return false; + } + + return super._validate(option); + } + } + + + class ItemTreeManager { + _columnManager = new ItemTreeColumnManagerInternal(); + + /** + * Register a custom column, must be valid with a unique dataKey. + * + * Note that the `dataKey` you use here may be different from the one returned by the function. + * This is because the `dataKey` is prefixed with the `pluginID` to avoid conflicts after the column is registered. + * @param {ItemTreeCustomColumnOptions} option - An option or array of options to register + * @returns {string | false} - The dataKey of the added column or false if no column is added + * @example + * A minimal custom column: + * ```js + * // You can unregister the column later with Zotero.ItemTreeManager.unregisterColumn(registeredDataKey); + * const registeredDataKey = Zotero.ItemTreeManager.registerColumn( + * { + * dataKey: 'rtitle', + * label: 'Reversed Title', + * pluginID: 'make-it-red@zotero.org', // Replace with your plugin ID + * dataProvider: (item, dataKey) => { + * return item.getField('title').split('').reverse().join(''); + * }, + * }); + * ``` + * @example + * A custom column using all available options. + * Note that the column will only be shown in the main item tree. + * ```js + * const registeredDataKey = Zotero.ItemTreeManager.registerColumn( + * { + * dataKey: 'rtitle', + * label: 'Reversed Title', + * enabledTreeIDs: ['main'], // only show in the main item tree + * sortReverse: true, // sort by increasing order + * flex: 0, // don't take up all available space + * width: 100, // assign fixed width in pixels + * fixedWidth: true, // don't allow user to resize + * staticWidth: true, // don't allow column to be resized when the tree is resized + * minWidth: 50, // minimum width in pixels + * iconPath: 'chrome://zotero/skin/tick.png', // icon to show in the column header + * htmlLabel: 'reversed title', // use HTML in the label. This will override the label and iconPath property + * showInColumnPicker: true, // show in the column picker + * columnPickerSubMenu: true, // show in the column picker submenu + * pluginID: 'make-it-red@zotero.org', // plugin ID, which will be used to unregister the column when the plugin is unloaded + * dataProvider: (item, dataKey) => { + * // item: the current item in the row + * // dataKey: the dataKey of the column + * // return: the data to display in the column + * return item.getField('title').split('').reverse().join(''); + * }, + * renderCell: (index, data, column, isFirstColumn, doc) => { + * // index: the index of the row + * // data: the data to display in the column, return of `dataProvider` + * // column: the column options + * // isFirstColumn: true if this is the first column + * // doc: the document of the item tree + * // return: the HTML to display in the cell + * const cell = doc.createElement('span'); + * cell.className = `cell ${column.className}`; + * cell.textContent = data; + * cell.style.color = 'red'; + * return cell; + * }, + * zoteroPersist: ['width', 'hidden', 'sortDirection'], // persist the column properties + * }); + * ``` + */ + registerColumn(option) { + return this._columnManager.register(option); + } + + /** + * @deprecated Use `registerColumn` instead. + */ + registerColumns(options) { + if (!Array.isArray(options)) { + options = [options]; + } + return options.map(option => this.registerColumn(option)); + } + + /** + * Unregister a custom column. + * @param {string} dataKey - The dataKey of the column to unregister + * @returns {boolean} true if the column is unregistered + * @example + * The `registeredDataKey` is returned by the `registerColumn` function. + * ```js + * Zotero.ItemTreeManager.unregisterColumn(registeredDataKey); + * ``` + */ + unregisterColumn(dataKey) { + return this._columnManager.unregister(dataKey); + } + + /** + * @deprecated Use `unregisterColumn` instead. + */ + unregisterColumns(dataKeys) { + if (!Array.isArray(dataKeys)) { + dataKeys = [dataKeys]; + } + return dataKeys.map(dataKey => this.unregisterColumn(dataKey)); + } + + get customColumnUpdateID() { + return this._columnManager.updateID; + } + + /** + * Get column(s) that matches the properties of option + * @param {string | string[]} [filterTreeIDs] - The tree IDs to match + * @param {ItemTreeCustomColumnFilters} [options] - An option or array of options to match + * @returns {ItemTreeCustomColumnOptions[]} + */ + getCustomColumns(filterTreeIDs, options) { + const allColumns = this._columnManager.options; + if (!filterTreeIDs && !options) { + return allColumns; + } + let filteredColumns = allColumns; + if (typeof filterTreeIDs === "string") { + filterTreeIDs = [filterTreeIDs]; + } + if (filterTreeIDs && !filterTreeIDs.includes("*")) { + const filterTreeIDsSet = new Set(filterTreeIDs); + filteredColumns = filteredColumns.filter((column) => { + if (column.enabledTreeIDs[0] == "*") return true; + + for (const treeID of column.enabledTreeIDs) { + if (filterTreeIDsSet.has(treeID)) return true; + } + return false; + }); + } + if (options) { + filteredColumns = filteredColumns.filter((col) => { + return Object.keys(options).every((key) => { + // Ignore undefined properties + if (options[key] === undefined) { + return true; + } + return options[key] === col[key]; + }); + }); + } + return filteredColumns; + } + + /** + * Check if a column is registered as a custom column + * @param {string} dataKey - The dataKey of the column + * @returns {boolean} true if the column is registered as a custom column + */ + isCustomColumn(dataKey) { + return !!this._columnManager._optionsCache[dataKey]; + } + + /** + * A centralized data source for custom columns. This is used by the ItemTreeRow to get data. + * @param {Zotero.Item} item - The item to get data from + * @param {string} dataKey - The dataKey of the column + * @returns {string} + */ + getCustomCellData(item, dataKey) { + const option = this._columnManager._optionsCache[dataKey]; + if (option && option.dataProvider) { + return option.dataProvider(item, dataKey); + } + return ""; + } + } + + + Zotero.ItemTreeManager = new ItemTreeManager(); +} diff --git a/chrome/content/zotero/xpcom/pluginAPI/pluginAPIBase.mjs b/chrome/content/zotero/xpcom/pluginAPI/pluginAPIBase.mjs new file mode 100644 index 0000000000..8e69b3f68f --- /dev/null +++ b/chrome/content/zotero/xpcom/pluginAPI/pluginAPIBase.mjs @@ -0,0 +1,383 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2024 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + + +/** + * @typedef {object} PluginAPIConfig + * @property {string} apiName - The name of the API + * @property {string} mainKeyName - The main key in the option object that uniquely identifies it + * @property {string} pluginIDKeyName - The key in the option object that identifies the plugin + * @property {string} notifyType - The type of the notification to send after an update + * @property {string} notifyAction - The action of the notification to send after an update + * @property {Record boolean} PluginAPIOptDefCheckHook + * - A function to check the value, returning true if it is valid + * + * + * @typedef {object} PluginAPIOptDefConfig + * @property {PluginAPIOptDefType} [type] - The type of the value + * @property {boolean} [optional] - Whether the value is optional, default false + * @property {PluginAPIOptDefCheckHook} [checkHook] + * - A function to check the value, returning true if it is valid + * @property {PluginAPIOptDefConfig} [children] + * - The type definition of the children of the object. The children can be + * an object or an array of objects with the same structure + * @property {any} [fallbackReturn] - The value to return if the check hook fails + * The fallback return is only used if the value is a function and it throws an error + * If no fallback return is defined, the function will return null + * + * + * @typedef {PluginAPIOptDefConfig | PluginAPIOptDefType | PluginAPIOptDefCheckHook} PluginAPIOptDefValue + * - The value of the option definition + * - If the value is a string, it is interpreted as the type + * - If the value is a function, it is interpreted as a check hook + * - If the value is an object, it is interpreted as a configuration object + */ + + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + Zotero: "chrome://zotero/content/zotero.mjs", +}); + + +class PluginAPIBase { + _optionsCache = {}; + + _lastUpdateID = ""; + + /** + * @type {PluginAPIConfig} + */ + _config = { + apiName: "PluginAPIBase", + mainKeyName: "id", + pluginIDKeyName: "pluginID", + notifyType: "unknown", + notifyAction: "refresh", + optionTypeDefinition: {}, + }; + + get updateID() { + return this._lastUpdateID; + } + + get options() { + return Object.values(this._optionsCache).map(opt => Object.assign({}, opt)); + } + + get data() { + return { + updateID: this.updateID, + options: this.options, + }; + } + + /** + * Set the configuration + * @param {PluginAPIConfig} config - The configuration to set + */ + set config(config) { + Object.assign(this._config, config); + } + + /** + * Register an option. Must be valid with a unique mainKey defined by _config#mainKeyName + * @param {object} option - The option to register + * @returns {string | false} - The mainKey of the registered option, or false if the option is invalid + */ + register(option) { + let mainKey = this._add(option); + if (!mainKey) { + return false; + } + this._addPluginShutdownObserver(); + this._update(); + return mainKey; + } + + /** + * Unregister an option by mainKey + * @param {string} mainKey - The mainKey of the option to unregister + * @returns {boolean} - True if the option was successfully unregistered + */ + unregister(mainKey) { + const success = this._remove(mainKey); + if (!success) { + return false; + } + this._update(); + return true; + } + + /** + * Internal implementation of registering an option, can be overridden by subclasses + * @param {object} option - The option to add + * @returns {string | false} - The mainKey of the added option, or false if the option is invalid + */ + _add(option) { + option = this._validate(option); + if (option === false) { + return false; + } + + let mainKey = this._getOptionMainKey(option); + this._optionsCache[mainKey] = option; + return mainKey; + } + + /** + * Internal implementation of unregistering an option, can be overridden by subclasses + * @param {object} mainKey - The mainKey of the option to remove + * @returns {boolean} - True if the option was successfully removed + */ + _remove(mainKey) { + if (!this._optionsCache[mainKey]) { + this._log(`Can't remove unknown option '${mainKey}'`, "warn"); + return false; + } + delete this._optionsCache[mainKey]; + return true; + } + + /** + * Internal implementation of validating an option, can be overridden by subclasses + * @param {object} option + * @returns {object | false} - The option if it is valid, or false if it is invalid + */ + _validate(option) { + let mainKey = this._namespacedMainKey(option); + + if (this._optionsCache[mainKey]) { + this._log(`'${this._config.mainKeyName}' must be unique, got ${mainKey}`, "warn"); + return false; + } + + let validateResult = this._validateObject(option, this._config.optionTypeDefinition); + + if (!validateResult.valid) { + return false; + } + + option = validateResult.obj; + option[this._config.mainKeyName] = mainKey; + return option; + } + + /** + * Validate an object against type definition or check hook + * @param {object} obj - The object to validate + * @param {object} typeDef - The type definition to validate against + * @param {string} path - The path to the object in the type definition + * @returns {{obj?: object, valid: boolean}} - The validated object if valid and the validation flag + */ + _validateObject(obj, typeDef, path = "") { + obj = Object.assign({}, obj); + + try { + for (let key of Object.keys(typeDef)) { + let val = obj[key]; + let requirement = typeDef[key]; + let fullPath = `${path}${key}`; + if (!requirement) { + throw new Error(`Unknown option ${fullPath}`); + } + + if (typeof requirement === "string") { + requirement = { + optional: false, + type: requirement, + }; + } + else if (typeof requirement === "function") { + requirement = { + optional: false, + checkHook: requirement, + }; + } + + if (!requirement.optional && typeof val === "undefined") { + throw new Error(`Option must have ${fullPath}`); + } + + let requiredType = requirement.type; + let gotType = typeof val; + // Allow undefined values since it's checked above + // Should be array or undefined + if (requiredType === "array" && gotType !== "undefined") { + if (!Array.isArray(val)) { + throw new Error(`Option ${fullPath} must be ${requiredType}, got ${typeof val}`); + } + } + // Should be required type or undefined + else if (requiredType && requiredType !== "any" && !["undefined", requiredType].includes(gotType)) { + throw new Error(`Option ${fullPath} must be ${requiredType}, got ${typeof val}`); + } + + if (requirement.checkHook) { + let result = requirement.checkHook(val); + if (result !== true) { + throw new Error(`Option ${fullPath} failed check: ${result}`); + } + } + + if (requirement.children) { + if (Array.isArray(val)) { + // Only validate array elements if they are objects + if (val.length > 0 && typeof val[0] == "object") { + for (let i = 0; i < val.length; i++) { + let result = this._validateObject(val[i], requirement.children, `${path}.${key}[${i}].`); + if (!result.valid) { + // The detailed error message is already logged + throw new Error(`Option ${fullPath}[${i}] is invalid`); + } + val[i] = result.obj; + } + } + } + else if (typeof val == "object") { + let result = this._validateObject(val, requirement.children, `${path}.${key}`); + if (!result.valid) { + // The detailed error message is already logged + throw new Error(`Option ${fullPath} is invalid`); + } + obj[key] = result.obj; + } + } + + // Validations passed, continue with post-processing + + // Wrap functions for safe execution + if (gotType === "function") { + obj[key] = (...args) => { + try { + return val(...args); + } + catch (e) { + this._log(`Error in execution of ${fullPath}`, "warn"); + lazy.Zotero.logError(e); + return requirement?.fallbackReturn || null; + } + }; + } + } + } + catch (e) { + this._log(String(e), "warn"); + return { + valid: false, + }; + } + + return { + obj, + valid: true, + }; + } + + /** + * Make sure the mainKey is namespaced with the pluginID + * @param {object} option + * @returns {string} + */ + _namespacedMainKey(option) { + let mainKey = this._getOptionMainKey(option); + let pluginID = this._getOptionPluginID(option); + if (pluginID && mainKey) { + // Make sure the return value is valid as class name or element id + return CSS.escape(`${pluginID}-${mainKey}`.replace(/[^a-zA-Z0-9-_]/g, "-")); + } + return mainKey; + } + + /** + * Notify the receiver to update + */ + async _update() { + this._lastUpdateID = `${new Date().getTime()}-${lazy.Zotero.Utilities.randomString()}`; + await lazy.Zotero.DB.executeTransaction(async () => { + lazy.Zotero.Notifier.queue( + this._config.notifyAction, + this._config.notifyType, + [], + {}, + ); + }); + } + + /** + * Unregister all stored options by pluginID + * @param {string} pluginID - PluginID + */ + async _unregisterByPluginID(pluginID) { + let paneIDs = Object.keys(this._optionsCache).filter( + id => this._getOptionPluginID(this._optionsCache[id]) == pluginID); + if (paneIDs.length === 0) { + return; + } + // Remove the registrations one by one + paneIDs.forEach(id => this._remove(id)); + this._log(`Registrations for plugin ${pluginID} are unregistered due to shutdown`); + await this._update(); + } + + /** + * Ensure the plugin shutdown observer is added + * @returns {void} + */ + _addPluginShutdownObserver() { + if (this._observerAdded) { + return; + } + + lazy.Zotero.Plugins.addObserver({ + shutdown: ({ id: pluginID }) => { + this._unregisterByPluginID(pluginID); + } + }); + this._observerAdded = true; + } + + _log(message, logType = "debug") { + lazy.Zotero[logType](`${this._config.apiName}: ${message}`); + } + + _getOptionPluginID(option) { + return option[this._config.pluginIDKeyName]; + } + + _getOptionMainKey(option) { + return option[this._config.mainKeyName]; + } +} + +export { PluginAPIBase }; diff --git a/chrome/content/zotero/zotero.mjs b/chrome/content/zotero/zotero.mjs index 909d13a903..4df1055764 100644 --- a/chrome/content/zotero/zotero.mjs +++ b/chrome/content/zotero/zotero.mjs @@ -107,14 +107,14 @@ const xpcomFilesLocal = [ 'fulltext', 'id', 'integration', - 'itemTreeManager', - 'itemPaneManager', 'locale', 'locateManager', 'mime', 'notifier', 'fileHandlers', 'plugins', + 'pluginAPI/itemTreeManager', + 'pluginAPI/itemPaneManager', 'reader', 'progressQueue', 'progressQueueDialog', diff --git a/test/tests/itemPaneTest.js b/test/tests/itemPaneTest.js index ae78772775..a689ae7beb 100644 --- a/test/tests/itemPaneTest.js +++ b/test/tests/itemPaneTest.js @@ -296,7 +296,12 @@ describe("Item pane", function () { var itemBox = doc.getElementById('zotero-editpane-info-box'); var label = itemBox.querySelector('#itembox-field-value-creator-0-lastName'); var firstlast = label.closest('.creator-type-value'); - firstlast.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, button: 2 })); + var menupopup = itemBox.querySelector('#zotero-creator-transform-menu'); + // Fake a right-click + doc.popupNode = firstlast; + menupopup.openPopup( + firstlast, "after_start", 0, 0, true, false, new MouseEvent('click', { button: 2 }) + ); var menuitem = doc.getElementById('creator-transform-swap-names'); assert.isTrue(menuitem.hidden); @@ -443,7 +448,7 @@ describe("Item pane", function () { let promise = waitForItemEvent('modify'); item.saveTx(); await promise; - var itemBox = doc.getElementById('zotero-editpane-item-box'); + var itemBox = doc.getElementById('zotero-editpane-info-box'); let creatorLastName = itemBox.querySelector(".creator-type-value editable-text"); creatorLastName.focus(); // Dispatch shift-Enter event @@ -473,7 +478,7 @@ describe("Item pane", function () { let promise = waitForItemEvent('modify'); item.saveTx(); await promise; - var itemBox = doc.getElementById('zotero-editpane-item-box'); + var itemBox = doc.getElementById('zotero-editpane-info-box'); let creatorLastName = itemBox.querySelector(".creator-type-value editable-text"); creatorLastName.focus(); // Dispatch shift-Enter event @@ -511,7 +516,7 @@ describe("Item pane", function () { item.setCreators(creatorsArr); item.saveTx(); await waitForItemEvent('modify'); - var itemBox = doc.getElementById('zotero-editpane-item-box'); + var itemBox = doc.getElementById('zotero-editpane-info-box'); let moreCreatorsLabel = itemBox.querySelector("#more-creators-label"); let lastVisibleCreator = moreCreatorsLabel.closest(".meta-row").previousElementSibling; let lastVisibleCreatorsPosition = itemBox.getCreatorFields(lastVisibleCreator).position; @@ -548,7 +553,7 @@ describe("Item pane", function () { item.setCreators(creatorsArr); item.saveTx(); await waitForItemEvent('modify'); - var itemBox = doc.getElementById('zotero-editpane-item-box'); + var itemBox = doc.getElementById('zotero-editpane-info-box'); // Add a new empty creator row itemBox.querySelector(".zotero-clicky-plus").click(); await Zotero.Promise.delay(); @@ -581,7 +586,7 @@ describe("Item pane", function () { let modifyPromise = waitForItemEvent('modify'); item.saveTx(); await modifyPromise; - var itemBox = doc.getElementById('zotero-editpane-item-box'); + var itemBox = doc.getElementById('zotero-editpane-info-box'); // Click on the button to switch type to dual let switchTypeBtn = itemBox.querySelector(".zotero-clicky-switch-type"); assert.equal(switchTypeBtn.getAttribute("type"), "single"); diff --git a/test/tests/pluginAPITest.js b/test/tests/pluginAPITest.js new file mode 100644 index 0000000000..dcbc0e14bf --- /dev/null +++ b/test/tests/pluginAPITest.js @@ -0,0 +1,515 @@ +describe("Plugin API", function () { + var win, doc, ZoteroPane, Zotero_Tabs, ZoteroContextPane, _itemsView, infoSection, caches; + + function resetCaches() { + caches = {}; + } + + function initCache(key) { + if (!caches[key]) { + caches[key] = {}; + } + caches[key].deferred = Zotero.Promise.defer(); + caches[key].result = ""; + } + + async function getCache(key) { + let cache = caches[key]; + await cache.deferred.promise; + return cache.result; + } + + function updateCache(key, value) { + let cache = caches[key]; + if (!cache) return; + cache.result = value; + cache.deferred?.resolve(); + } + + before(async function () { + win = await loadZoteroPane(); + doc = win.document; + ZoteroPane = win.ZoteroPane; + Zotero_Tabs = win.Zotero_Tabs; + ZoteroContextPane = win.ZoteroContextPane; + _itemsView = win.ZoteroPane.itemsView; + infoSection = win.ZoteroPane.itemPane._itemDetails.getPane('info'); + }); + + after(function () { + win.close(); + }); + + describe("Item pane info box custom section", function () { + let defaultOption = { + rowID: "default-test", + pluginID: "zotero@zotero.org", + label: { + l10nID: "general-print", + }, + onGetData: ({ item }) => { + let data = `${item.id}`; + updateCache("onGetData", data); + return data; + }, + }; + + let waitForRegister = async (option) => { + initCache("onGetData"); + let getDataPromise = getCache("onGetData"); + let rowID = Zotero.ItemPaneManager.registerInfoRow(option); + await getDataPromise; + return rowID; + }; + + let waitForUnregister = async (rowID) => { + let unregisterPromise = waitForNotifierEvent("refresh", "infobox"); + let success = Zotero.ItemPaneManager.unregisterInfoRow(rowID); + await unregisterPromise; + return success; + }; + + beforeEach(async function () { + resetCaches(); + }); + + afterEach(function () { + Zotero_Tabs.select("zotero-pane"); + Zotero_Tabs.closeAll(); + }); + + it("should render custom row and call onGetData hook", async function () { + initCache("onGetData"); + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + let getDataPromise = getCache("onGetData"); + let rowID = Zotero.ItemPaneManager.registerInfoRow(defaultOption); + let result = await getDataPromise; + + // Should render custom row + let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + assert.exists(rowElem); + + // Should call onGetData and render + let valueElem = rowElem.querySelector(".value"); + assert.equal(result, valueElem.value); + + await waitForUnregister(rowID); + }); + + it("should call onSetData hook", async function () { + let option = Object.assign({}, defaultOption, { + onSetData: ({ value }) => { + let data = `${value}`; + updateCache("onSetData", data); + }, + }); + + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + let rowID = await waitForRegister(option); + + let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + let valueElem = rowElem.querySelector(".value"); + + // Should call onSetData on value change + initCache("onSetData"); + let setDataPromise = getCache("onSetData"); + let newValue = `TEST CUSTOM ROW`; + valueElem.focus(); + valueElem.value = newValue; + let blurEvent = new Event("blur"); + valueElem.dispatchEvent(blurEvent); + let result = await setDataPromise; + assert.equal(newValue, result); + + await waitForUnregister(rowID); + }); + + it("should call onItemChange hook", async function () { + let option = Object.assign({}, defaultOption, { + onItemChange: ({ item, tabType, setEnabled, setEditable }) => { + let editable = item.itemType === "book"; + let enabled = tabType === "library"; + setEnabled(enabled); + setEditable(editable); + let data = { editable, enabled }; + updateCache("onItemChange", data); + } + }); + + initCache("onItemChange"); + + let bookItem = new Zotero.Item('book'); + await bookItem.saveTx(); + await ZoteroPane.selectItem(bookItem.id); + + let itemChangePromise = getCache("onItemChange"); + let rowID = await waitForRegister(option); + let result = await itemChangePromise; + + let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + let valueElem = rowElem.querySelector(".value"); + + // Should be enabled and editable + assert.isTrue(result.editable); + assert.isFalse(valueElem.readOnly); + assert.isTrue(result.enabled); + assert.isFalse(rowElem.hidden); + + initCache("onItemChange"); + itemChangePromise = getCache("onItemChange"); + let docItem = new Zotero.Item('document'); + await docItem.saveTx(); + await ZoteroPane.selectItem(docItem.id); + result = await itemChangePromise; + + // Should be enabled and not editable + assert.isFalse(result.editable); + assert.isTrue(valueElem.readOnly); + assert.isTrue(result.enabled); + assert.isFalse(rowElem.hidden); + + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment = await Zotero.Attachments.importFromFile({ + file, + parentItemID: docItem.id + }); + + initCache("onItemChange"); + itemChangePromise = getCache("onItemChange"); + + await ZoteroPane.viewItems([attachment]); + let tabID = Zotero_Tabs.selectedID; + await Zotero.Reader.getByTabID(tabID)._waitForReader(); + // Ensure context pane is open + ZoteroContextPane.splitter.setAttribute("state", "open"); + result = await itemChangePromise; + + let itemDetails = ZoteroContextPane.context._getItemContext(tabID); + rowElem = itemDetails.getPane("info").querySelector(`[data-custom-row-id="${rowID}"]`); + valueElem = rowElem.querySelector(".value"); + + // Should not be enabled in non-library tab + assert.isFalse(result.enabled); + assert.isTrue(rowElem.hidden); + + await waitForUnregister(rowID); + }); + + it("should render row at position", async function () { + let startOption = Object.assign({}, defaultOption, { + position: "start", + }); + let afterCreatorsOption = Object.assign({}, defaultOption, { + position: "afterCreators", + }); + let endOption = Object.assign({}, defaultOption, { + position: "end", + }); + + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + // Row at start + let rowID = await waitForRegister(startOption); + let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + + assert.notExists(rowElem.previousElementSibling); + await waitForUnregister(rowID); + + // Row after creator rows + rowID = await waitForRegister(afterCreatorsOption); + rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + + assert.exists(rowElem.previousElementSibling.querySelector(".creator-type-value")); + assert.notExists(rowElem.nextElementSibling.querySelector(".creator-type-value")); + await waitForUnregister(rowID); + + // Row at end + rowID = rowID = await waitForRegister(endOption); + rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + + assert.exists(rowElem.nextElementSibling.querySelector("*[fieldname=dateAdded]")); + await waitForUnregister(rowID); + }); + + it("should set input editable", async function () { + let editableOption = Object.assign({}, defaultOption, { + editable: true, + }); + let notEditableOption = Object.assign({}, defaultOption, { + editable: false, + }); + + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + let rowID = await waitForRegister(defaultOption); + + let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + let valueElem = rowElem.querySelector(".value"); + + assert.isFalse(valueElem.readOnly); + + await waitForUnregister(rowID); + + rowID = await waitForRegister(editableOption); + + rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + valueElem = rowElem.querySelector(".value"); + + assert.isFalse(valueElem.readOnly); + + await waitForUnregister(rowID); + + rowID = await waitForRegister(notEditableOption); + + rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + valueElem = rowElem.querySelector(".value"); + + assert.isTrue(valueElem.readOnly); + + await waitForUnregister(rowID); + }); + + it("should set input multiline", async function () { + let multilineOption = Object.assign({}, defaultOption, { + multiline: true, + }); + let notMultilineOption = Object.assign({}, defaultOption, { + multiline: false, + }); + + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + let rowID = await waitForRegister(defaultOption); + + let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + let valueElem = rowElem.querySelector(".value"); + + assert.isFalse(valueElem.multiline); + + await waitForUnregister(rowID); + + rowID = await waitForRegister(multilineOption); + + rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + valueElem = rowElem.querySelector(".value"); + + assert.isTrue(valueElem.multiline); + + await waitForUnregister(rowID); + + rowID = await waitForRegister(notMultilineOption); + + rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + valueElem = rowElem.querySelector(".value"); + + assert.isFalse(valueElem.multiline); + + await waitForUnregister(rowID); + }); + + it("should set input nowrap", async function () { + let noWrapOption = Object.assign({}, defaultOption, { + nowrap: true, + }); + let wrapOption = Object.assign({}, defaultOption, { + nowrap: false, + }); + + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + let rowID = await waitForRegister(defaultOption); + + let rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + let valueElem = rowElem.querySelector(".value"); + + assert.isFalse(valueElem.noWrap); + + await waitForUnregister(rowID); + + rowID = await waitForRegister(noWrapOption); + + rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + valueElem = rowElem.querySelector(".value"); + + assert.isTrue(valueElem.noWrap); + + await waitForUnregister(rowID); + + rowID = await waitForRegister(wrapOption); + + rowElem = infoSection.querySelector(`[data-custom-row-id="${rowID}"]`); + valueElem = rowElem.querySelector(".value"); + + assert.isFalse(valueElem.noWrap); + + await waitForUnregister(rowID); + }); + }); + + describe("Item tree custom column", function () { + // Only test hooks, as other column options are covered in item tree tests + let defaultOption = { + columnID: "default-test", + pluginID: "zotero@zotero.org", + dataKey: "api-test", + label: "APITest", + dataProvider: (item) => { + let data = `${item.id}`; + updateCache("dataProvider", data); + return data; + }, + }; + + let waitForRegister = async (option) => { + initCache("dataProvider"); + let getDataPromise = getCache("dataProvider"); + let columnKey = Zotero.ItemTreeManager.registerColumn(option); + await getDataPromise; + return columnKey; + }; + + let waitForColumnEnable = async (dataKey) => { + _itemsView._columnPrefs[dataKey] = { + dataKey, + hidden: false, + }; + let columns = _itemsView._getColumns(); + let columnID = columns.findIndex(column => column.dataKey === dataKey); + if (columnID === -1) { + return; + } + let column = columns[columnID]; + if (!column.hidden) { + return; + } + _itemsView.tree._columns.toggleHidden(columnID); + + // Wait for column header to render + await waitForCallback( + () => !!doc.querySelector(`#zotero-items-tree .virtualized-table-header .cell.${dataKey}`), + 100, 3); + }; + + let waitForUnregister = async (columnID) => { + let unregisterPromise = waitForNotifierEvent("refresh", "itemtree"); + let success = Zotero.ItemTreeManager.unregisterColumn(columnID); + await unregisterPromise; + return success; + }; + + let getSelectedRowCell = (dataKey) => { + let cell = doc.querySelector(`#zotero-items-tree .row.selected .${dataKey}`); + return cell; + }; + + beforeEach(async function () { + resetCaches(); + }); + + afterEach(function () { + Zotero_Tabs.select("zotero-pane"); + Zotero_Tabs.closeAll(); + }); + + it("should render custom column and call dataProvider hook", async function () { + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + let columnKey = await waitForRegister(defaultOption); + + await waitForColumnEnable(columnKey); + + // Should render custom column cell + let cellElem = getSelectedRowCell(columnKey); + + assert.exists(cellElem); + + // Should call dataProvider and render the value + assert.equal(`${item.id}`, cellElem.textContent); + + await waitForUnregister(columnKey); + }); + + it("should use custom renderCell hook", async function () { + let customCellContent = "Custom renderCell"; + + let option = Object.assign({}, defaultOption, { + renderCell: (index, data, column, isFirstColumn, doc) => { + // index: the index of the row + // data: the data to display in the column, return of `dataProvider` + // column: the column options + // isFirstColumn: true if this is the first column + // doc: the document of the item tree + // return: the HTML to display in the cell + const cell = doc.createElement('span'); + cell.className = `cell ${column.className}`; + cell.textContent = customCellContent; + cell.style.color = 'red'; + updateCache("renderCell", cell.textContent); + return cell; + }, + }); + + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + let columnKey = await waitForRegister(option); + + await waitForColumnEnable(columnKey); + + // Should render custom column cell + let cellElem = getSelectedRowCell(columnKey); + + assert.exists(cellElem); + + // Should call renderCell and render the value + assert.equal('rgb(255, 0, 0)', win.getComputedStyle(cellElem).color); + + await waitForUnregister(columnKey); + }); + + it("should not break ui when hooks throw error", async function () { + let option = Object.assign({}, defaultOption, { + dataProvider: () => { + updateCache("dataProvider", "Test error"); + throw new Error("Test error"); + }, + renderCell: () => { + updateCache("renderCell", "Test error"); + throw new Error("Test error"); + } + }); + + let item = new Zotero.Item('book'); + await item.saveTx(); + await ZoteroPane.selectItem(item.id); + + let columnKey = await waitForRegister(option); + + await waitForColumnEnable(columnKey); + + // Should not break ui + let columnElem = getSelectedRowCell(columnKey); + assert.exists(columnElem); + + await waitForUnregister(columnKey); + }); + }); +});