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
This commit is contained in:
windingwind 2024-06-09 22:25:37 +08:00 committed by Dan Stillman
parent 347caaff4c
commit aec6e61cb3
13 changed files with 1986 additions and 817 deletions

View file

@ -63,6 +63,14 @@
this._selectFieldSelection = null; this._selectFieldSelection = null;
this._addCreatorRow = false; this._addCreatorRow = false;
this._switchedModeOfCreator = null; this._switchedModeOfCreator = null;
this._lastUpdateCustomRows = "";
// Keep in sync with itemPaneManager.js
this._customRowElemCache = {
start: [],
afterCreators: [],
end: [],
};
} }
get content() { 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', () => { Zotero.Prefs.registerObserver('fontSize', () => {
this._forceRenderAll(); this._forceRenderAll();
}); });
@ -352,6 +360,11 @@
this._item = val; this._item = val;
this.scrollToTop(); 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 // .ref is an alias for .item
@ -473,15 +486,13 @@
// //
// Methods // Methods
// //
notify(event, _type, ids) { notify(event, type, ids) {
if (event != 'modify' || !this.item || !this.item.id) return; if (event == 'refresh' && type == 'infobox' && this.item?.id) {
for (let i = 0; i < ids.length; i++) { this.renderCustomRows();
let id = ids[i]; return;
if (id != this.item.id) { }
continue; if (event == 'modify' && this.item?.id && ids.includes(this.item.id)) {
}
this._forceRenderAll(); this._forceRenderAll();
break;
} }
} }
@ -502,6 +513,11 @@
this._saveFieldFocus(); this._saveFieldFocus();
delete this._linkMenu.dataset.link; delete this._linkMenu.dataset.link;
this.renderCustomRows();
// No need to recreate custom rows every time
this.cacheCustomRowElements();
// //
// Clear and rebuild metadata fields // Clear and rebuild metadata fields
@ -594,15 +610,15 @@
rowLabel.className = "meta-label"; rowLabel.className = "meta-label";
rowLabel.setAttribute('fieldname', fieldName); rowLabel.setAttribute('fieldname', fieldName);
let valueElement = this.createValueElement( let valueElement = this.createFieldValueElement(
val, fieldName val, fieldName
); );
if (fieldName) { if (fieldName) {
let label = document.createElement('label'); let label = this.createLabelElement({
label.className = 'key'; text: Zotero.ItemFields.getLocalizedString(fieldName),
label.textContent = Zotero.ItemFields.getLocalizedString(fieldName); id: `itembox-field-${fieldName}-label`,
label.setAttribute("id", `itembox-field-${fieldName}-label`); });
rowLabel.appendChild(label); rowLabel.appendChild(label);
valueElement.setAttribute('aria-labelledby', label.id); valueElement.setAttribute('aria-labelledby', label.id);
} }
@ -825,35 +841,6 @@
}); });
this._showCreatorTypeGuidance = false; 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._ensureButtonsFocusable();
this._updateCreatorButtonsStatus(); this._updateCreatorButtonsStatus();
@ -873,6 +860,196 @@
this.querySelectorAll("menupopup").forEach((popup) => { this.querySelectorAll("menupopup").forEach((popup) => {
popup.hidePopup(); 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() { addItemTypeMenu() {
@ -881,10 +1058,10 @@
var labelWrapper = document.createElement('div'); var labelWrapper = document.createElement('div');
labelWrapper.className = "meta-label"; labelWrapper.className = "meta-label";
labelWrapper.setAttribute("fieldname", "itemType"); labelWrapper.setAttribute("fieldname", "itemType");
var label = document.createElement("label"); var label = this.createLabelElement({
label.className = "key"; id: "itembox-field-itemType-label",
label.id = "itembox-field-itemType-label"; text: Zotero.getString("zotero.items.itemType")
label.innerText = Zotero.getString("zotero.items.itemType"); });
labelWrapper.appendChild(label); labelWrapper.appendChild(label);
var rowData = document.createElement('div'); var rowData = document.createElement('div');
rowData.className = "meta-data"; rowData.className = "meta-data";
@ -1014,10 +1191,10 @@
} }
rowLabel.appendChild(labelWrapper); rowLabel.appendChild(labelWrapper);
var label = document.createElement("label"); let label = this.createLabelElement({
label.setAttribute('id', 'creator-type-label-inner'); id: 'creator-type-label-inner',
label.className = 'key'; text: Zotero.getString('creatorTypes.' + Zotero.CreatorTypes.getName(typeID))
label.textContent = Zotero.getString('creatorTypes.' + Zotero.CreatorTypes.getName(typeID)); });
labelWrapper.appendChild(label); labelWrapper.appendChild(label);
var rowData = document.createElement("div"); var rowData = document.createElement("div");
@ -1029,7 +1206,7 @@
var fieldName = 'creator-' + rowIndex + '-lastName'; var fieldName = 'creator-' + rowIndex + '-lastName';
var lastNameElem = firstlast.appendChild( var lastNameElem = firstlast.appendChild(
this.createValueElement( this.createFieldValueElement(
lastName, lastName,
fieldName, fieldName,
) )
@ -1038,7 +1215,7 @@
lastNameElem.placeholder = this._defaultLastName; lastNameElem.placeholder = this._defaultLastName;
fieldName = 'creator-' + rowIndex + '-firstName'; fieldName = 'creator-' + rowIndex + '-firstName';
var firstNameElem = firstlast.appendChild( var firstNameElem = firstlast.appendChild(
this.createValueElement( this.createFieldValueElement(
firstName, firstName,
fieldName, fieldName,
) )
@ -1238,16 +1415,16 @@
var rowLabel = document.createElement("div"); var rowLabel = document.createElement("div");
rowLabel.className = "meta-label"; rowLabel.className = "meta-label";
rowLabel.setAttribute("fieldname", field); rowLabel.setAttribute("fieldname", field);
var label = document.createElement('label'); let label = this.createLabelElement({
label.className = 'key'; text: Zotero.ItemFields.getLocalizedString(field),
label.textContent = Zotero.ItemFields.getLocalizedString(field); id: `itembox-field-${field}-label`
label.setAttribute("id", `itembox-field-${field}-label`); });
rowLabel.appendChild(label); rowLabel.appendChild(label);
var rowData = document.createElement('div'); var rowData = document.createElement('div');
rowData.className = "meta-data date-box"; rowData.className = "meta-data date-box";
var elem = this.createValueElement( var elem = this.createFieldValueElement(
Zotero.Date.multipartToStr(value), Zotero.Date.multipartToStr(value),
field field
); );
@ -1459,27 +1636,53 @@
return openLink; return openLink;
} }
createValueElement(valueText, fieldName) { createLabelElement({ text, id, attributes, classList }) {
valueText += ''; let label = document.createElement('label');
label.classList.add('key', ...classList || []);
if (fieldName) { if (text) label.textContent = text;
var fieldID = Zotero.ItemFields.getID(fieldName); if (id) label.id = id;
if (attributes) {
for (let [key, value] of Object.entries(attributes)) {
label.setAttribute(key, value);
}
} }
// On click of the label, toggle the focus of the value field
let isMultiline = Zotero.ItemFields.isMultiline(fieldName); if (this.editable) {
let isNoWrap = fieldName.startsWith('creator-'); label.addEventListener('mousedown', (event) => {
// Prevent default focus/blur behavior - we implement our own below
var valueElement = document.createXULElement("editable-text"); event.preventDefault();
valueElement.className = 'value'; });
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) { if (isMultiline) {
valueElement.setAttribute('multiline', true); valueElement.setAttribute('multiline', true);
} }
else if (isNoWrap) { else if (isNoWrap) {
valueElement.setAttribute("nowrap", true); valueElement.setAttribute("nowrap", true);
} }
if (editable) {
if (this._fieldIsClickable(fieldName)) {
valueElement.addEventListener("focus", e => this.showEditor(e.target)); valueElement.addEventListener("focus", e => this.showEditor(e.target));
valueElement.addEventListener("blur", e => this.hideEditor(e.target)); valueElement.addEventListener("blur", e => this.hideEditor(e.target));
} }
@ -1487,13 +1690,51 @@
valueElement.setAttribute('readonly', true); valueElement.setAttribute('readonly', true);
} }
valueElement.setAttribute('id', `itembox-field-value-${fieldName}`); if (id) valueElement.id = id;
valueElement.setAttribute('fieldname', fieldName); if (tooltipText) valueElement.tooltipText = tooltipText;
if (attributes) {
for (let [key, value] of Object.entries(attributes)) {
valueElement.setAttribute(key, value);
}
}
valueElement.setAttribute('tight', true); 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) { switch (fieldName) {
case 'itemType': case 'itemType':
valueElement.setAttribute('itemTypeID', valueText); attributes.itemTypeID = valueText;
valueText = Zotero.ItemTypes.getLocalizedString(valueText); valueText = Zotero.ItemTypes.getLocalizedString(valueText);
break; break;
@ -1512,15 +1753,24 @@
break; break;
} }
let tooltipText;
if (fieldID) { if (fieldID) {
// Display the SQL date as a tooltip for date fields // Display the SQL date as a tooltip for date fields
// TEMP - filingDate // TEMP - filingDate
if (Zotero.ItemFields.isFieldOfBase(fieldID, 'date') || fieldName == '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) { if (lazy.BIDI_BROWSER_UI) {
// Attempt to guess text direction automatically // Attempt to guess text direction automatically
@ -1605,13 +1855,25 @@
} }
async showEditor(elem) { async showEditor(elem) {
Zotero.debug(`Showing editor for ${elem.getAttribute('fieldname')}`); let isCustomRow = elem.classList.contains("custom-row-value");
var fieldName = elem.getAttribute('fieldname'); 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 // Multiline field will be at least 6 lines
if (Zotero.ItemFields.isMultiline(fieldName)) { if (isMultiline) {
elem.setAttribute("min-lines", 6); elem.setAttribute("min-lines", 6);
} }
if (isCustomRow) {
return;
}
Zotero.debug(`Showing editor for ${fieldName}`);
var [field, creatorIndex, creatorField] = fieldName.split('-'); var [field, creatorIndex, creatorField] = fieldName.split('-');
let value; let value;
if (field == 'creator') { if (field == 'creator') {
@ -1861,20 +2123,54 @@
if (this.ignoreBlur || !textbox) { if (this.ignoreBlur || !textbox) {
return; 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 // Handle cases where creator autocomplete doesn't trigger
// the textentered and change events handled in showEditor // the textentered and change events handled in showEditor
if (textbox.getAttribute('fieldname').startsWith('creator-')) { if (fieldName.startsWith('creator-')) {
this.handleCreatorAutoCompleteSelect(textbox); this.handleCreatorAutoCompleteSelect(textbox);
} }
Zotero.debug(`Hiding editor for ${textbox.getAttribute('fieldname')}`); Zotero.debug(`Hiding editor for ${fieldName}`);
// Prevent autocomplete breakage in Firefox 3 // Prevent autocomplete breakage in Firefox 3
if (textbox.mController) { if (textbox.mController) {
textbox.mController.input = null; textbox.mController.input = null;
} }
var fieldName = textbox.getAttribute('fieldname');
// Multiline fields go back to occupying as much space as needed // Multiline fields go back to occupying as much space as needed
if (Zotero.ItemFields.isMultiline(fieldName)) { if (Zotero.ItemFields.isMultiline(fieldName)) {
@ -2241,7 +2537,7 @@
return; return;
} }
let field = activeElement.closest("[fieldname], [tabindex], [focusable]"); let field = activeElement.closest("[fieldname], [tabindex], [focusable], .custom-row-value");
let fieldID; let fieldID;
// Special treatment for creator rows. When an unsaved row is just added, creator rows ids // 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. // 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"])`); 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 // the same component from the last available creator row
if (!refocusField && this._selectField.startsWith("creator-")) { if (!refocusField && this._selectField.startsWith("creator-")) {
let maybeLastCreatorID = this._selectField.replace(/\d+/g, Math.max(this._creatorCount - 1, 0)); let maybeLastCreatorID = this._selectField.replace(/\d+/g, Math.max(this._creatorCount - 1, 0));

View file

@ -226,7 +226,7 @@
this._disableScrollHandler = false; this._disableScrollHandler = false;
this._pinnedPaneMinScrollHeight = 0; this._pinnedPaneMinScrollHeight = 0;
this._lastUpdateCustomSection = 0; this._lastUpdateCustomSection = "";
// If true, will render on tab select // If true, will render on tab select
this._pendingRender = false; this._pendingRender = false;
@ -320,11 +320,10 @@
} }
renderCustomSections() { renderCustomSections() {
let lastUpdate = Zotero.ItemPaneManager.getUpdateTime(); let { options: targetPanes, updateID } = Zotero.ItemPaneManager.customSectionData;
if (this._lastUpdateCustomSection == lastUpdate) return; if (this._lastUpdateCustomSection == updateID) return;
this._lastUpdateCustomSection = lastUpdate; this._lastUpdateCustomSection = updateID;
let targetPanes = Zotero.ItemPaneManager.getCustomSections();
let currentPaneElements = this.getCustomPanes(); let currentPaneElements = this.getCustomPanes();
// Remove // Remove
for (let elem of currentPaneElements) { for (let elem of currentPaneElements) {

View file

@ -2955,7 +2955,8 @@ var ItemTree = class ItemTree extends LibraryTree {
// Pass document to renderCell so that it can create elements // Pass document to renderCell so that it can create elements
cell = column.renderCell.apply(this, [...arguments, document]); cell = column.renderCell.apply(this, [...arguments, document]);
// Ensure that renderCell returns an Element // 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'); throw new Error('renderCell must return an Element');
} }
} }

View file

@ -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} [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} [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} [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 {(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 {(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 * @property {string[]} [zoteroPersist] - Which column properties should be persisted between zotero close

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
const { COLUMNS: ITEMTREE_COLUMNS } = require("zotero/itemTreeColumns");
/**
* @typedef {import("../itemTreeColumns.jsx").ItemTreeColumnOptions} ItemTreeColumnOptions
* @typedef {"dataKey" | "label" | "pluginID"} RequiredCustomColumnOptionKeys
* @typedef {Required<Pick<ItemTreeColumnOptions, RequiredCustomColumnOptionKeys>>} RequiredCustomColumnOptionsPartial
* @typedef {Omit<ItemTreeColumnOptions, RequiredCustomColumnOptionKeys>} CustomColumnOptionsPartial
* @typedef {RequiredCustomColumnOptionsPartial & CustomColumnOptionsPartial} ItemTreeCustomColumnOptions
* @typedef {Partial<Omit<ItemTreeCustomColumnOptions, "enabledTreeIDs">>} ItemTreeCustomColumnFilters
*/
class ItemTreeManager {
_observerAdded = false;
/** @type {Record<string, ItemTreeCustomColumnOptions}} */
_customColumns = {};
/**
* Register a custom column. All registered columns must be valid, and must have a unique dataKey.
* Although it's async, resolving does not promise the item trees are updated.
*
* 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 | ItemTreeCustomColumnOptions[]} options - An option or array of options to register
* @returns {string | string[] | false} - The dataKey(s) of the added column(s) or false if no columns were added
* @example
* A minimal custom column:
* ```js
* // You can unregister the column later with Zotero.ItemTreeManager.unregisterColumns(registeredDataKey);
* const registeredDataKey = await Zotero.ItemTreeManager.registerColumns(
* {
* 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 = 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: '<span style="color: red;">reversed title</span>', // 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();

View file

@ -51,7 +51,8 @@ Zotero.Notifier = new function () {
'api-key', 'api-key',
'tab', 'tab',
'itemtree', 'itemtree',
'itempane' 'itempane',
'infobox',
]; ];
var _transactionID = false; var _transactionID = false;
var _queue = {}; var _queue = {};

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
***** 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<Pick<ItemTreeColumnOptions, RequiredCustomColumnOptionKeys>>} RequiredCustomColumnOptionsPartial
* @typedef {Omit<ItemTreeColumnOptions, RequiredCustomColumnOptionKeys>} CustomColumnOptionsPartial
* @typedef {RequiredCustomColumnOptionsPartial & CustomColumnOptionsPartial} ItemTreeCustomColumnOptions
* @typedef {Partial<Omit<ItemTreeCustomColumnOptions, "enabledTreeIDs">>} 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: '<span style="color: red;">reversed title</span>', // 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();
}

View file

@ -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 <http://www.gnu.org/licenses/>.
***** 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<string, PluginAPIOptDefValue} optionTypeDefinition
* - The option validation definition.
*
*
* @typedef {string} PluginAPIOptDefType
* - The type of the value
*
*
* @typedef {(value: any) => 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 };

View file

@ -107,14 +107,14 @@ const xpcomFilesLocal = [
'fulltext', 'fulltext',
'id', 'id',
'integration', 'integration',
'itemTreeManager',
'itemPaneManager',
'locale', 'locale',
'locateManager', 'locateManager',
'mime', 'mime',
'notifier', 'notifier',
'fileHandlers', 'fileHandlers',
'plugins', 'plugins',
'pluginAPI/itemTreeManager',
'pluginAPI/itemPaneManager',
'reader', 'reader',
'progressQueue', 'progressQueue',
'progressQueueDialog', 'progressQueueDialog',

View file

@ -296,7 +296,12 @@ describe("Item pane", function () {
var itemBox = doc.getElementById('zotero-editpane-info-box'); var itemBox = doc.getElementById('zotero-editpane-info-box');
var label = itemBox.querySelector('#itembox-field-value-creator-0-lastName'); var label = itemBox.querySelector('#itembox-field-value-creator-0-lastName');
var firstlast = label.closest('.creator-type-value'); 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'); var menuitem = doc.getElementById('creator-transform-swap-names');
assert.isTrue(menuitem.hidden); assert.isTrue(menuitem.hidden);
@ -443,7 +448,7 @@ describe("Item pane", function () {
let promise = waitForItemEvent('modify'); let promise = waitForItemEvent('modify');
item.saveTx(); item.saveTx();
await promise; 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"); let creatorLastName = itemBox.querySelector(".creator-type-value editable-text");
creatorLastName.focus(); creatorLastName.focus();
// Dispatch shift-Enter event // Dispatch shift-Enter event
@ -473,7 +478,7 @@ describe("Item pane", function () {
let promise = waitForItemEvent('modify'); let promise = waitForItemEvent('modify');
item.saveTx(); item.saveTx();
await promise; 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"); let creatorLastName = itemBox.querySelector(".creator-type-value editable-text");
creatorLastName.focus(); creatorLastName.focus();
// Dispatch shift-Enter event // Dispatch shift-Enter event
@ -511,7 +516,7 @@ describe("Item pane", function () {
item.setCreators(creatorsArr); item.setCreators(creatorsArr);
item.saveTx(); item.saveTx();
await waitForItemEvent('modify'); 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 moreCreatorsLabel = itemBox.querySelector("#more-creators-label");
let lastVisibleCreator = moreCreatorsLabel.closest(".meta-row").previousElementSibling; let lastVisibleCreator = moreCreatorsLabel.closest(".meta-row").previousElementSibling;
let lastVisibleCreatorsPosition = itemBox.getCreatorFields(lastVisibleCreator).position; let lastVisibleCreatorsPosition = itemBox.getCreatorFields(lastVisibleCreator).position;
@ -548,7 +553,7 @@ describe("Item pane", function () {
item.setCreators(creatorsArr); item.setCreators(creatorsArr);
item.saveTx(); item.saveTx();
await waitForItemEvent('modify'); 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 // Add a new empty creator row
itemBox.querySelector(".zotero-clicky-plus").click(); itemBox.querySelector(".zotero-clicky-plus").click();
await Zotero.Promise.delay(); await Zotero.Promise.delay();
@ -581,7 +586,7 @@ describe("Item pane", function () {
let modifyPromise = waitForItemEvent('modify'); let modifyPromise = waitForItemEvent('modify');
item.saveTx(); item.saveTx();
await modifyPromise; 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 // Click on the button to switch type to dual
let switchTypeBtn = itemBox.querySelector(".zotero-clicky-switch-type"); let switchTypeBtn = itemBox.querySelector(".zotero-clicky-switch-type");
assert.equal(switchTypeBtn.getAttribute("type"), "single"); assert.equal(switchTypeBtn.getAttribute("type"), "single");

515
test/tests/pluginAPITest.js Normal file
View file

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