From c89590c7b74fc062cbd4843adadb36fa1603cf60 Mon Sep 17 00:00:00 2001 From: windingwind <33902321+windingwind@users.noreply.github.com> Date: Tue, 25 Jul 2023 12:47:12 +0800 Subject: [PATCH] Add ItemTree column API (#3186) --- chrome/content/zotero/advancedSearch.js | 6 +- .../zotero/components/virtualized-table.jsx | 24 +- chrome/content/zotero/elements/relatedBox.js | 2 +- .../zotero/integration/addCitationDialog.js | 4 + .../integration/editBibliographyDialog.js | 4 + chrome/content/zotero/itemTree.jsx | 57 ++- chrome/content/zotero/itemTreeColumns.jsx | 240 +++++++----- chrome/content/zotero/rtfScan.js | 2 +- chrome/content/zotero/selectItemsDialog.js | 2 +- chrome/content/zotero/xpcom/integration.js | 1 + .../content/zotero/xpcom/itemTreeManager.js | 361 ++++++++++++++++++ chrome/content/zotero/xpcom/notifier.js | 3 +- components/zotero-service.js | 1 + 13 files changed, 586 insertions(+), 121 deletions(-) create mode 100644 chrome/content/zotero/xpcom/itemTreeManager.js diff --git a/chrome/content/zotero/advancedSearch.js b/chrome/content/zotero/advancedSearch.js index aa9c6c83f8..a7357f0824 100644 --- a/chrome/content/zotero/advancedSearch.js +++ b/chrome/content/zotero/advancedSearch.js @@ -26,7 +26,7 @@ Components.utils.import("resource://gre/modules/Services.jsm"); import ItemTree from 'zotero/itemTree'; -import { getDefaultColumnsByDataKeys } from 'zotero/itemTreeColumns'; +import { getColumnDefinitionsByDataKey } from 'zotero/itemTreeColumns' var ZoteroAdvancedSearch = new function() { @@ -60,13 +60,14 @@ var ZoteroAdvancedSearch = new function() { id: "advanced-search", dragAndDrop: true, onActivate: this.onItemActivate.bind(this), - columns: getDefaultColumnsByDataKeys(['title', 'firstCreator']), + columns: getColumnDefinitionsByDataKey(["title", "firstCreator"]), }); // A minimal implementation of Zotero.CollectionTreeRow var collectionTreeRow = { view: {}, ref: _searchBox.search, + visibilityGroup: 'default', isSearchMode: () => true, getItems: async () => [], isLibrary: () => false, @@ -96,6 +97,7 @@ var ZoteroAdvancedSearch = new function() { var collectionTreeRow = { view: {}, ref: _searchBox.search, + visibilityGroup: 'default', isSearchMode: () => true, getItems: async function () { await Zotero.Libraries.get(_libraryID).waitForDataLoad('item'); diff --git a/chrome/content/zotero/components/virtualized-table.jsx b/chrome/content/zotero/components/virtualized-table.jsx index 57eb0be005..076369896a 100644 --- a/chrome/content/zotero/components/virtualized-table.jsx +++ b/chrome/content/zotero/components/virtualized-table.jsx @@ -1090,9 +1090,23 @@ class VirtualizedTable extends React.Component { return this._getVisibleColumns().map((column, index) => { let columnName = formatColumnName(column); let label = columnName; + // Allow custom icons to be used in column headers + if (column.iconPath) { + column.iconLabel = + ; + } if (column.iconLabel) { label = column.iconLabel; } + if (column.htmlLabel) { + if (React.isValidElement(column.htmlLabel)) { + label = column.htmlLabel; + } else if (typeof column.htmlLabel === "string") { + label = ; + } + } let resizer = (= this._jsWindow.getFirstVisibleRow() && row <= this._jsWindow.getLastVisibleRow(); } + + async _resetColumns() { + this.invalidate(); + this._columns = new Columns(this); + await new Promise((resolve) => {this.forceUpdate(resolve)}); + } } /** @@ -1452,7 +1472,7 @@ var Columns = class { } _getColumnPrefsToPersist(column) { - let persistKeys = column.zoteroPersist; + let persistKeys = new Set(column.zoteroPersist); if (!persistKeys) persistKeys = new Set(); // Always persist ['ordinal', 'hidden', 'sortDirection'].forEach(k => persistKeys.add(k)); @@ -1585,7 +1605,7 @@ var Columns = class { column.sortDirection *= -1; } else { - column.sortDirection = column.defaultSort || 1; + column.sortDirection = column.sortReverse ? -1 : 1; } } }); diff --git a/chrome/content/zotero/elements/relatedBox.js b/chrome/content/zotero/elements/relatedBox.js index 7477b13925..d27bba0dc4 100644 --- a/chrome/content/zotero/elements/relatedBox.js +++ b/chrome/content/zotero/elements/relatedBox.js @@ -173,7 +173,7 @@ } add = async () => { - let io = { dataIn: null, dataOut: null, deferred: Zotero.Promise.defer() }; + let io = { dataIn: null, dataOut: null, deferred: Zotero.Promise.defer(), itemTreeID: 'related-box-select-item-dialog' }; window.openDialog('chrome://zotero/content/selectItemsDialog.xhtml', '', 'chrome,dialog=no,centerscreen,resizable=yes', io); diff --git a/chrome/content/zotero/integration/addCitationDialog.js b/chrome/content/zotero/integration/addCitationDialog.js index 7681d673a8..e3ba76b693 100644 --- a/chrome/content/zotero/integration/addCitationDialog.js +++ b/chrome/content/zotero/integration/addCitationDialog.js @@ -138,6 +138,10 @@ var Zotero_Citation_Dialog = new function () { i++; } menu.selectedIndex = pageLocatorIndex; + + if (!io.itemTreeID) { + io.itemTreeID = "add-citation-select-item-dialog"; + } // load (from selectItemsDialog.js) yield doLoad(); diff --git a/chrome/content/zotero/integration/editBibliographyDialog.js b/chrome/content/zotero/integration/editBibliographyDialog.js index 08583d6593..8406889709 100644 --- a/chrome/content/zotero/integration/editBibliographyDialog.js +++ b/chrome/content/zotero/integration/editBibliographyDialog.js @@ -57,6 +57,10 @@ var Zotero_Bibliography_Dialog = new function () { window.addEventListener('dialogcancel', () => Zotero_Bibliography_Dialog.close()); _editor = document.querySelector('#editor').contentWindow.editor; + + if (!io.itemTreeID) { + io.itemTreeID = "edit-bib-select-item-dialog"; + } // load (from selectItemsDialog.js) await doLoad(); diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index c1c68a7e53..69dced5680 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -32,10 +32,14 @@ const VirtualizedTable = require('components/virtualized-table'); const { renderCell, formatColumnName } = VirtualizedTable; const Icons = require('components/icons'); const { getDOMElement } = Icons; -const { COLUMNS } = require('./itemTreeColumns'); +const { COLUMNS } = require("zotero/itemTreeColumns"); const { Cc, Ci, Cu } = require('chrome'); Cu.import("resource://gre/modules/osfile.jsm"); +/** + * @typedef {import("./itemTreeColumns.jsx").ItemTreeColumnOptions} ItemTreeColumnOptions + */ + const CHILD_INDENT = 12; const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$"); const COLUMN_PREFS_FILEPATH = OS.Path.join(Zotero.Profile.dir, "treePrefs.json"); @@ -99,7 +103,7 @@ var ItemTree = class ItemTree extends LibraryTree { this._unregisterID = Zotero.Notifier.registerObserver( this, - ['item', 'collection-item', 'item-tag', 'share-items', 'bucket', 'feedItem', 'search'], + ['item', 'collection-item', 'item-tag', 'share-items', 'bucket', 'feedItem', 'search', 'itemtree'], 'itemTreeView', 50 ); @@ -107,8 +111,7 @@ var ItemTree = class ItemTree extends LibraryTree { this._itemsPaneMessage = null; this._columnsId = null; - this.columns = null; - + if (this.collectionTreeRow) { this.collectionTreeRow.view.itemTreeView = this; } @@ -142,12 +145,20 @@ var ItemTree = class ItemTree extends LibraryTree { /** - * Extension developers: use this to monkey-patch additional columns. See - * itemTreeColumns.js for available column fields. - * @returns {Array} + * Get global columns from ItemTreeColumns and local columns from this.columns + * @returns {ItemTreeColumnOptions[]} */ getColumns() { - return Array.from(this.props.columns); + const extraColumns = Zotero.ItemTreeManager.getCustomColumns(this.props.id); + + /** @type {ItemTreeColumnOptions[]} */ + const currentColumns = this.props.columns.map(col => Object.assign({}, col)); + extraColumns.forEach((column) => { + if (!currentColumns.find(c => c.dataKey === column.dataKey)) { + currentColumns.push(column); + } + }); + return currentColumns; } /** @@ -349,6 +360,12 @@ var ItemTree = class ItemTree extends LibraryTree { return; } + // Reset columns on custom column change + if(type === "itemtree" && action === "refresh") { + await this._resetColumns(); + return; + } + if (type == 'search' && action == 'modify') { // TODO: Only refresh on condition change (not currently available in extraData) await this.refresh(); @@ -1300,7 +1317,8 @@ var ItemTree = class ItemTree extends LibraryTree { return (row.ref.isFeedItem && Zotero.Feeds.get(row.ref.libraryID).name) || ""; default: - return row.ref.getField(field, false, true); + // Get from row.getField() to allow for custom fields + return row.getField(field, false, true); } } @@ -3165,6 +3183,7 @@ var ItemTree = class ItemTree extends LibraryTree { let columnsSettings = this._getColumnPrefs(); + // Refresh columns from itemTreeColumns const columns = this.getColumns(); let hasDefaultIn = columns.some(column => 'defaultIn' in column); for (let column of columns) { @@ -3193,7 +3212,7 @@ var ItemTree = class ItemTree extends LibraryTree { // Initial hidden value if (!("hidden" in column)) { if (hasDefaultIn) { - column.hidden = !(column.defaultIn && column.defaultIn.has(visibilityGroup)); + column.hidden = !(column.defaultIn && column.defaultIn.includes(visibilityGroup)); } else { column.hidden = false; @@ -3573,7 +3592,7 @@ var ItemTree = class ItemTree extends LibraryTree { let columnMenuitemElements = {}; for (let i = 0; i < columns.length; i++) { const column = columns[i]; - if (column.ignoreInColumnPicker === true) continue; + if (column.showInColumnPicker === false) continue; let label = formatColumnName(column); let menuitem = doc.createXULElement('menuitem'); menuitem.setAttribute('type', 'checkbox'); @@ -3604,7 +3623,7 @@ var ItemTree = class ItemTree extends LibraryTree { let moreItems = []; for (let i = 0; i < columns.length; i++) { const column = columns[i]; - if (column.submenu) { + if (column.columnPickerSubMenu) { moreItems.push(columnMenuitemElements[column.dataKey]); } } @@ -3815,6 +3834,15 @@ var ItemTree = class ItemTree extends LibraryTree { } return span; } + + async _resetColumns(){ + this._columnsId = null; + return new Promise((resolve) => this.forceUpdate(async () => { + await this.tree._resetColumns(); + await this.refreshAndMaintainSelection(); + resolve(); + })); + } }; var ItemTreeRow = function(ref, level, isOpen) @@ -3827,7 +3855,10 @@ var ItemTreeRow = function(ref, level, isOpen) ItemTreeRow.prototype.getField = function(field, unformatted) { - return this.ref.getField(field, unformatted, true); + if (!Zotero.ItemTreeManager.isCustomColumn(field)) { + return this.ref.getField(field, unformatted, true); + } + return Zotero.ItemTreeManager.getCustomCellData(this.ref, field); } ItemTreeRow.prototype.numNotes = function() { diff --git a/chrome/content/zotero/itemTreeColumns.jsx b/chrome/content/zotero/itemTreeColumns.jsx index 2b0928d53e..08fcd2be55 100644 --- a/chrome/content/zotero/itemTreeColumns.jsx +++ b/chrome/content/zotero/itemTreeColumns.jsx @@ -2,7 +2,7 @@ ***** BEGIN LICENSE BLOCK ***** Copyright © 2020 Corporation for Digital Scholarship - Vienna, Virginia, USA + Vienna, Virginia, USA http://zotero.org This file is part of Zotero. @@ -23,318 +23,358 @@ ***** END LICENSE BLOCK ***** */ -(function() { const React = require('react'); const Icons = require('components/icons'); /** - * @type Column { - * dataKey: string, // Required, see use in ItemTree#_getRowData() - * - * defaultIn: Set, // Types of trees the column is default in. Can be [default, feed]; - * disabledIn: Set, // Types of trees where the column is not available - * defaultSort: number // Default: 1. -1 for descending sort - * - * flex: number, // Default: 1. When the column is added to the tree how much space it should occupy as a flex ratio - * width: string, // A column width instead of flex ratio. See above. - * fixedWidth: boolean // Default: false. Set to true to disable column resizing - * staticWidth: boolean // Default: false. Set to true to prevent columns from changing width when - * // the width of the tree increases or decreases - * minWidth: number, // Override the default [20px] column min-width for resizing - * - * label: string, // The column label. Either a string or the id to an i18n string. - * iconLabel: React.Component, // Set an Icon label instead of a text-based one - * - * ignoreInColumnPicker: boolean // Default: false. Set to true to not display in column picker. - * submenu: boolean, // Default: false. Set to true to display the column in "More Columns" submenu of column picker. - * - * primary: boolean, // Should only be one column at the time. Title is the primary column - * zoteroPersist: Set, // Which column properties should be persisted between zotero close - * } + * @typedef ItemTreeColumnOptions + * @type {object} + * @property {string} dataKey - Required, see use in ItemTree#_getRowData() + * @property {string} label - The column label. Either a string or the id to an i18n string. + * @property {string} [pluginID] - Set plugin ID to auto remove column when plugin is removed. + * @property {string[]} [enabledTreeIDs=[]] - Which tree ids the column should be enabled in. If undefined, enabled in main tree. If ["*"], enabled in all trees. + * @property {string[]} [defaultIn] - Will be deprecated. Types of trees the column is default in. Can be [default, feed]; + * @property {string[]} [disabledIn] - Will be deprecated. Types of trees where the column is not available + * @property {boolean} [sortReverse=false] - Default: false. Set to true to reverse the sort order + * @property {number} [flex=1] - Default: 1. When the column is added to the tree how much space it should occupy as a flex ratio + * @property {string} [width] - A column width instead of flex ratio. See above. + * @property {boolean} [fixedWidth] - Default: false. Set to true to disable column resizing + * @property {boolean} [staticWidth] - Default: false. Set to true to prevent columns from changing width when the width of the tree increases or decreases + * @property {number} [minWidth] - Override the default [20px] column min-width for resizing + * @property {React.Component} [iconLabel] - Set an Icon label instead of a text-based one + * @property {string} [iconPath] - Set an Icon path, overrides {iconLabel} + * @property {string | React.Component} [htmlLabel] - Set an HTML label, overrides {iconLabel} and {label}. Can be a HTML string or a React component. + * @property {boolean} [showInColumnPicker=true] - Default: true. Set to true to show in column picker. + * @property {boolean} [columnPickerSubMenu=false] - Default: false. Set to true to display the column in "More Columns" submenu of column picker. + * @property {boolean} [primary] - Should only be one column at the time. Title is the primary column + * @property {boolean} [custom] - Set automatically to true when the column is added by the user + * @property {(item: Zotero.Item, dataKey: string) => string} [dataProvider] - Custom data provider that is called when rendering cells + * @property {string[]} [zoteroPersist] - Which column properties should be persisted between zotero close + */ + +/** + * @type {ItemTreeColumnOptions[]} + * @constant */ const COLUMNS = [ { dataKey: "title", primary: true, - defaultIn: new Set(["default", "feeds", "feed"]), + defaultIn: ["default", "feeds", "feed"], label: "itemFields.title", - ignoreInColumnPicker: true, + showInColumnPicker: false, flex: 4, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "firstCreator", - defaultIn: new Set(["default", "feeds", "feed"]), + defaultIn: ["default", "feeds", "feed"], label: "zotero.items.creator_column", + showInColumnPicker: true, flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "itemType", label: "zotero.items.itemType", + showInColumnPicker: true, width: "40", - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "date", - defaultIn: new Set(["feeds", "feed"]), - defaultSort: -1, + defaultIn: ["feeds", "feed"], + sortReverse: true, label: "itemFields.date", + showInColumnPicker: true, flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "year", disabledIn: ["feeds", "feed"], - defaultSort: -1, + sortReverse: true, label: "zotero.items.year_column", + showInColumnPicker: true, flex: 1, staticWidth: true, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "publisher", label: "itemFields.publisher", + showInColumnPicker: true, flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "publicationTitle", label: "itemFields.publicationTitle", + showInColumnPicker: true, flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "journalAbbreviation", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.journalAbbreviation", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "language", - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.language", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "accessDate", disabledIn: ["feeds", "feed"], - defaultSort: -1, - submenu: true, + sortReverse: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.accessDate", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "libraryCatalog", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.libraryCatalog", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "callNumber", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.callNumber", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "rights", - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.rights", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "dateAdded", - defaultSort: -1, + sortReverse: true, disabledIn: ["feeds", "feed"], + showInColumnPicker: true, label: "itemFields.dateAdded", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "dateModified", - defaultSort: -1, + sortReverse: true, disabledIn: ["feeds", "feed"], + showInColumnPicker: true, label: "zotero.items.dateModified_column", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "archive", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.archive", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "archiveLocation", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.archiveLocation", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "place", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.place", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "volume", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.volume", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "edition", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.edition", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "number", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.number", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "pages", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.pages", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "issue", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.issue", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "series", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.series", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "seriesTitle", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.seriesTitle", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "court", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.court", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "medium", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.medium", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "genre", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.genre", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "system", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.system", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "shortTitle", disabledIn: ["feeds", "feed"], - submenu: true, + showInColumnPicker: true, + columnPickerSubMenu: true, label: "itemFields.shortTitle", flex: 2, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "extra", disabledIn: ["feeds", "feed"], + showInColumnPicker: true, label: "itemFields.extra", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "hasAttachment", - defaultIn: new Set(["default"]), + defaultIn: ["default"], disabledIn: ["feeds", "feed"], + showInColumnPicker: true, label: "zotero.tabs.attachments.label", iconLabel: , fixedWidth: true, width: "16", - zoteroPersist: new Set(["hidden", "sortDirection"]) + zoteroPersist: ["hidden", "sortDirection"] }, { dataKey: "numNotes", disabledIn: ["feeds", "feed"], + showInColumnPicker: true, label: "zotero.tabs.notes.label", iconLabel: , width: "14", minWidth: 14, staticWidth: true, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] }, { dataKey: "feed", disabledIn: ["default", "feed"], + showInColumnPicker: true, label: "itemFields.feed", flex: 1, - zoteroPersist: new Set(["width", "hidden", "sortDirection"]) + zoteroPersist: ["width", "hidden", "sortDirection"] } ]; -function getDefaultColumnByDataKey(dataKey) { - return Object.assign({}, COLUMNS.find(col => col.dataKey == dataKey), {hidden: false}); -} - -function getDefaultColumnsByDataKeys(dataKeys) { - return COLUMNS.filter(column => dataKeys.includes(column.dataKey)).map(column => Object.assign({}, column, {hidden: false})); +/** + * Returns the columns that match the given data keys from the COLUMNS constant. + * @param {string | string[]} dataKeys - The data key(s) to match. + * @returns {ItemTreeColumnOptions | ItemTreeColumnOptions[]} - The matching columns. + */ +function getColumnDefinitionsByDataKey(dataKeys) { + const isSingle = !Array.isArray(dataKeys); + if (isSingle) { + dataKeys = [dataKeys]; + } + const matches = COLUMNS.filter(column => dataKeys.includes(column.dataKey)).map(column => Object.assign({}, column, { hidden: false })); + return isSingle ? matches[0] : matches; } module.exports = { COLUMNS, - getDefaultColumnByDataKey, - getDefaultColumnsByDataKeys, + getColumnDefinitionsByDataKey, }; - -})(); diff --git a/chrome/content/zotero/rtfScan.js b/chrome/content/zotero/rtfScan.js index 793b8bcb0c..f265bb5a8a 100644 --- a/chrome/content/zotero/rtfScan.js +++ b/chrome/content/zotero/rtfScan.js @@ -341,7 +341,7 @@ const Zotero_RTFScan = { // eslint-disable-line no-unused-vars, camelcase } else { // mapped or unmapped citation, or ambiguous citation parent var citation = row.rtf; - var io = { singleSelection: true }; + var io = { singleSelection: true, itemTreeID: 'rtf-scan-select-item-dialog' }; if (this.citationItemIDs[citation] && this.citationItemIDs[citation].length == 1) { // mapped citation // specify that item should be selected in window io.select = this.citationItemIDs[citation][0]; diff --git a/chrome/content/zotero/selectItemsDialog.js b/chrome/content/zotero/selectItemsDialog.js index 1ecc85a9c8..f244da2574 100644 --- a/chrome/content/zotero/selectItemsDialog.js +++ b/chrome/content/zotero/selectItemsDialog.js @@ -60,7 +60,7 @@ var doLoad = async function () { onItemSelected(); } }, - id: "select-items-dialog", + id: io.itemTreeID || "select-items-dialog", dragAndDrop: false, persistColumns: true, columnPicker: true, diff --git a/chrome/content/zotero/xpcom/integration.js b/chrome/content/zotero/xpcom/integration.js index 6ba2affcd6..dddb797b3c 100644 --- a/chrome/content/zotero/xpcom/integration.js +++ b/chrome/content/zotero/xpcom/integration.js @@ -3181,6 +3181,7 @@ Zotero.Integration.Citation = class { io.addBorder = Zotero.isWin; io.singleSelection = true; + io.itemTreeID = "handle-missing-item-select-item-dialog"; await Zotero.Integration.displayDialog('chrome://zotero/content/selectItemsDialog.xhtml', 'resizable', io); diff --git a/chrome/content/zotero/xpcom/itemTreeManager.js b/chrome/content/zotero/xpcom/itemTreeManager.js new file mode 100644 index 0000000000..ea34a773a3 --- /dev/null +++ b/chrome/content/zotero/xpcom/itemTreeManager.js @@ -0,0 +1,361 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2019 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +const { COLUMNS: ITEMTREE_COLUMNS } = require("zotero/itemTreeColumns"); + +/** + * @typedef {import("../itemTreeColumns.jsx").ItemTreeColumnOptions} ItemTreeColumnOptions + * @typedef {"dataKey" | "label" | "pluginID"} RequiredCustomColumnOptionKeys + * @typedef {Required>} RequiredCustomColumnOptionsPartial + * @typedef {Omit} CustomColumnOptionsPartial + * @typedef {RequiredCustomColumnOptionsPartial & CustomColumnOptionsPartial} ItemTreeCustomColumnOptions + * @typedef {Partial>} ItemTreeCustomColumnFilters + */ + +class ItemTreeManager { + _observerAdded = false; + + /** @type {Record { + * return item.getField('title').split('').reverse().join(''); + * }, + * }); + * ``` + * @example + * A custom column using all available options. + * Note that the column will only be shown in the main item tree. + * ```js + * const registeredDataKey = await Zotero.ItemTreeManager.registerColumns( + * { + * dataKey: 'rtitle', + * label: 'Reversed Title', + * enabledTreeIDs: ['main'], // only show in the main item tree + * sortReverse: true, // sort by increasing order + * flex: 0, // don't take up all available space + * width: 100, // assign fixed width in pixels + * fixedWidth: true, // don't allow user to resize + * staticWidth: true, // don't allow column to be resized when the tree is resized + * minWidth: 50, // minimum width in pixels + * iconPath: 'chrome://zotero/skin/tick.png', // icon to show in the column header + * htmlLabel: 'reversed title', // use HTML in the label. This will override the label and iconPath property + * showInColumnPicker: true, // show in the column picker + * columnPickerSubMenu: true, // show in the column picker submenu + * pluginID: 'make-it-red@zotero.org', // plugin ID, which will be used to unregister the column when the plugin is unloaded + * dataProvider: (item, dataKey) => { + * // item: the current item in the row + * // dataKey: the dataKey of the column + * // return: the data to display in the column + * return item.getField('title').split('').reverse().join(''); + * }, + * 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 + * ```js + * Zotero.ItemTreeManager.unregisterColumns('rtitle'); + * ``` + */ + async unregisterColumns(dataKeys) { + const success = this._removeColumns(dataKeys); + if (!success) { + return false; + } + await this._notifyItemTrees(); + return true; + } + + // TODO: add cell renderer registration + + /** + * 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) { + return options.dataProvider(item, dataKey); + } + 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 | ItemTreeCItemTreeCustomColumnOptionsolumnOptions[]} options - An option or array of options to add + * @returns {string | string[] | false} - The dataKey(s) of the added column(s) or false if no columns were added + */ + _addColumns(options) { + const isSingle = !Array.isArray(options); + if (isSingle) { + options = [options]; + } + options.forEach((o) => { + o.dataKey = this._namespacedDataKey(o); + o.enabledTreeIDs = o.enabledTreeIDs || ["main"]; + if (o.enabledTreeIDs.includes("*")) { + o.enabledTreeIDs = ["*"]; + } + o.showInColumnPicker = o.showInColumnPicker === undefined ? true : o.showInColumnPicker; + }); + // If any check fails, return check results + if (!this._validateColumnOption(options)) { + return false; + } + for (const opt of options) { + this._customColumns[opt.dataKey] = Object.assign({}, opt, { custom: true }); + } + return isSingle ? options[0].dataKey : options.map(opt => opt.dataKey); + } + + /** + * Remove a column option + * @param {string | string[]} dataKeys - The dataKey of the column to remove + * @returns {boolean} - True if column(s) were removed, false if not + */ + _removeColumns(dataKeys) { + if (!Array.isArray(dataKeys)) { + dataKeys = [dataKeys]; + } + // If any check fails, return check results and do not remove any columns + for (const key of dataKeys) { + if (!this._customColumns[key]) { + Zotero.warn(`ItemTree Column option with dataKey ${key} does not exist.`); + return false; + } + } + for (const key of dataKeys) { + delete this._customColumns[key]; + } + return true; + } + + /** + * Make sure the dataKey is namespaced with the plugin ID + * @param {ItemTreeCustomColumnOptions} options + * @returns {string} + */ + _namespacedDataKey(options) { + if (options.pluginID && options.dataKey) { + // Make sure the return value is valid as class name or element id + return `${options.pluginID}-${options.dataKey}`.replace(/[^a-zA-Z0-9-_]/g, "-"); + } + return options.dataKey; + } + + + /** + * Reset the item trees to update the columns + */ + async _notifyItemTrees() { + await Zotero.DB.executeTransaction(async function () { + Zotero.Notifier.queue( + 'refresh', + 'itemtree', + [], + {}, + ); + }); + } + + /** + * Unregister all columns registered by a plugin + * @param {string} pluginID - Plugin ID + */ + async _unregisterColumnByPluginID(pluginID) { + const columns = this.getCustomColumns(undefined, { pluginID }); + if (columns.length === 0) { + return; + } + // Remove the columns one by one + // This is to ensure that the columns are removed and not interrupted by any non-existing columns + columns.forEach(column => this._removeColumns(column.dataKey)); + Zotero.debug(`ItemTree columns registered by plugin ${pluginID} unregistered due to shutdown`); + await this._notifyItemTrees(); + } + + /** + * Ensure that the shutdown observer is added + * @returns {void} + */ + _addPluginShutdownObserver() { + if (this._observerAdded) { + return; + } + + Zotero.Plugins.addObserver({ + shutdown: ({ id: pluginID }) => { + this._unregisterColumnByPluginID(pluginID); + } + }); + this._observerAdded = true; + } +} + +Zotero.ItemTreeManager = new ItemTreeManager(); diff --git a/chrome/content/zotero/xpcom/notifier.js b/chrome/content/zotero/xpcom/notifier.js index 98621c4077..b118056f8c 100644 --- a/chrome/content/zotero/xpcom/notifier.js +++ b/chrome/content/zotero/xpcom/notifier.js @@ -33,7 +33,8 @@ Zotero.Notifier = new function(){ var _types = [ 'collection', 'search', 'share', 'share-items', 'item', 'file', 'collection-item', 'item-tag', 'tag', 'setting', 'group', 'trash', - 'bucket', 'relation', 'feed', 'feedItem', 'sync', 'api-key', 'tab' + 'bucket', 'relation', 'feed', 'feedItem', 'sync', 'api-key', 'tab', + 'itemtree' ]; var _transactionID = false; var _queue = {}; diff --git a/components/zotero-service.js b/components/zotero-service.js index ef825641b1..b906efcfe5 100644 --- a/components/zotero-service.js +++ b/components/zotero-service.js @@ -154,6 +154,7 @@ const xpcomFilesLocal = [ 'connector/httpIntegrationClient', 'connector/server_connector', 'connector/server_connectorIntegration', + 'itemTreeManager', ]; Components.utils.import("resource://gre/modules/ComponentUtils.jsm");