From da6389ed93896fb1fb48fa5bf129d02bbb71d6db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 26 Nov 2025 14:16:13 +0200 Subject: [PATCH] Citation Explorer: Rewrite with ItemTreeRow subclasses Replace Proxy-wrapped items and hacky rendering overrides with proper ItemTreeRow subclasses and a custom ItemTreeRowProvider. - LibraryItemTreeRow: expandable container for library grouping with sortChildren opt-in - CitationExplorerItemTreeRow: row with citation metadata, explicit id for CSL-only items, isContainer/ isContainerEmpty overrides to suppress twisties - CitationExplorerRowProvider: groups items by library, delegates sorting to base class via _sort()/_compareRows() - CitationExplorerItemTree: custom tree with row provider - Enable all standard columns (hidden by default) with column picker and persistence - isLinked column excluded from picker via showInColumnPicker: false - Remove persistColumns from citationDialog (consolidated into columnPicker in prior commit) - Add .library-container-row styling --- .../zotero/integration/citationDialog.js | 1 - .../zotero/integration/citationExplorer.js | 402 ++++++++++++++---- 2 files changed, 324 insertions(+), 79 deletions(-) diff --git a/chrome/content/zotero/integration/citationDialog.js b/chrome/content/zotero/integration/citationDialog.js index 9972e3b36a..a17cf5b8aa 100644 --- a/chrome/content/zotero/integration/citationDialog.js +++ b/chrome/content/zotero/integration/citationDialog.js @@ -703,7 +703,6 @@ class LibraryLayout extends Layout { this.itemsView = await CollectionViewItemTree.init(itemsTree, { id: "citationDialog", dragAndDrop: DIALOG_STATE.isCitingItems(), - persistColumns: true, columnPicker: true, onSelectionChange: () => { libraryLayout.updateSelectedItems(); diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 0ae8d50ec2..68bccae135 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -27,22 +27,263 @@ const React = require('react'); const ReactDOM = require('react-dom'); const diff = require('diff'); const VirtualizedTable = require('components/virtualized-table'); -// TODO: Create a custom row provider for citationExplorer to use with base ItemTree. -// Currently uses changeCollectionTreeRow which only exists on CollectionViewItemTree, -// so this is broken until we either switch to CollectionViewItemTree or create a -// simple row provider that can display arbitrary items. const { getCSSIcon, CSSIcon } = require('components/icons'); const ItemTree = require('zotero/itemTree'); -const { getColumnDefinitionsByDataKey } = require('zotero/itemTreeColumns'); +const { ItemTreeRowProvider } = ItemTree; +const { ItemTreeRow, ZoteroItemTreeRow } = require('zotero/itemTreeRow'); +const { COLUMNS, getColumnDefinitionsByDataKey } = require('zotero/itemTreeColumns'); const { makeRowRenderer } = VirtualizedTable; +// //////////////////////////////////////////////////////////////////////////// +// +// Citation Explorer Row Classes +// +// //////////////////////////////////////////////////////////////////////////// + +/** + * Container row wrapping a Zotero.Library. + * Follows the CollectionItemTreeRow pattern — uses the default renderRow() + * from ItemTreeRow base, which loops through all columns and calls + * renderCtx.renderCell() for each. Non-title fields return '' from getField(), + * so those cells render empty naturally. + */ +class LibraryItemTreeRow extends ItemTreeRow { + constructor(library, items, isOpen = true) { + super(library, 0, isOpen); // library has treeViewID = "L" + this._items = items; // raw item refs for getChildItems() + } + + get type() { + return 'library'; + } + + get isDraggable() { + return false; + } + + get sortChildren() { + return true; + } + + isContainer() { + return true; + } + + isContainerOpen() { + return this.isOpen; + } + + isContainerEmpty() { + return this._items.length === 0; + } + + getChildItems() { + return this._items; + } + + getField(field) { + if (field === 'title') return this.ref.name; + return ''; + } + + getDisplayTitle() { + return this.ref.name; + } + + getTypeLabel() { + return ''; + } + + getIcon() { + let library = this.ref; + let iconKey = 'library'; + if (library.libraryType === 'group') iconKey = 'library-group'; + else if (library.libraryType === 'publications') iconKey = 'publications'; + let icon = getCSSIcon(iconKey); + icon.classList.add('icon-item-type'); + return icon; + } +} + +/** + * Item row with citation-tracking metadata and "Uncited" badge. + */ +class CitationExplorerItemTreeRow extends ZoteroItemTreeRow { + constructor(ref, level, isOpen, { citedIn = [], cslItemID = null } = {}) { + let id = ref.treeViewID ?? cslItemID ?? 'csl-' + Zotero.Utilities.randomString(8); + super(ref, level, isOpen, id); + this.citedIn = citedIn; + this.cslItemID = cslItemID; + } + + get isLinked() { + return !this.cslItemID; + } + + isContainer() { + return false; + } + + isContainerEmpty() { + return true; + } + + renderPrimaryCell(index, data, column) { + let cell = super.renderPrimaryCell(index, data, column); + if (this.citedIn.length === 0) { + let badge = document.createElement('span'); + badge.classList.add('badge', 'badge-uncited'); + badge.textContent = 'Uncited'; + let textSpan = cell.querySelector('.cell-text'); + if (textSpan) textSpan.after(badge); + else cell.appendChild(badge); + } + return cell; + } +} + +// //////////////////////////////////////////////////////////////////////////// +// +// Citation Explorer Row Provider +// +// //////////////////////////////////////////////////////////////////////////// + +/** + * Row provider that groups items by library with expandable containers. + * Maintains lookup maps so row metadata survives collapse/re-expand. + */ +class CitationExplorerRowProvider extends ItemTreeRowProvider { + constructor(itemTree) { + super(itemTree); + this._sourceItems = []; + this._citedInByID = new Map(); + this._cslItemIDByID = new Map(); + } + + /** + * Override createRow() — the factory hook. + * Called by _toggleOpenState() on collapse/re-expand. + * Reconstructs rows with metadata from lookup maps. + */ + createRow(ref, level, isOpen) { + if (ref instanceof Zotero.Library) { + let items = this._sourceItems.filter( + item => (item.libraryID ?? Zotero.Libraries.userLibraryID) === ref.libraryID + ); + return new LibraryItemTreeRow(ref, items, isOpen); + } + + let key = ref.treeViewID; + return new CitationExplorerItemTreeRow(ref, level, isOpen, { + citedIn: this._citedInByID.get(key) || [], + cslItemID: this._cslItemIDByID.get(key) || null, + }); + } + + /** + * Replace displayed items. Groups by library, sorts, and updates view. + * @param {CitationExplorerItemTreeRow[]} itemRows + */ + async setItems(itemRows) { + this._citedInByID = new Map(); + this._cslItemIDByID = new Map(); + this._sourceItems = []; + for (let row of itemRows) { + let key = row.ref.treeViewID; + this._citedInByID.set(key, row.citedIn); + if (row.cslItemID) this._cslItemIDByID.set(key, row.cslItemID); + this._sourceItems.push(row.ref); + } + + this._rebuildRows(); + await this.itemTree._ensureSortContextReady(); + // _sort() builds the comparator, sorts top-level rows, then + // _restoreOpenState() reopens library containers with children + // sorted by the same comparator via _toggleOpenState() + this._sort(null); + await this.runListeners('update', true, { restoreSelection: false }); + } + + /** + * Build _rows from _sourceItems. Library containers at level 0 + * when multiple libraries, flat list when single library. + */ + _rebuildRows() { + const byLibrary = new Map(); + for (const item of this._sourceItems) { + const libID = item.libraryID ?? Zotero.Libraries.userLibraryID; + if (!byLibrary.has(libID)) byLibrary.set(libID, []); + byLibrary.get(libID).push(item); + } + + const sortedLibIDs = [...byLibrary.keys()].sort((a, b) => a - b); + const showContainers = sortedLibIDs.length > 1; + + this._rows = []; + if (showContainers) { + for (const libID of sortedLibIDs) { + let library = Zotero.Libraries.get(libID); + let items = byLibrary.get(libID); + this._rows.push(new LibraryItemTreeRow(library, items, true)); + // Children will be added by _sort() → _restoreOpenState() → + // _toggleOpenState(), which sorts them using the cached comparator + } + } + else { + for (const item of this._sourceItems) { + this._rows.push(this.createRow(item, 0, false)); + } + } + this.refreshRowMap(); + } +} + +// //////////////////////////////////////////////////////////////////////////// +// +// Citation Explorer Item Tree +// +// //////////////////////////////////////////////////////////////////////////// + +/** + * ItemTree subclass that uses CitationExplorerRowProvider and makes + * library container rows non-selectable. + */ +class CitationExplorerItemTree extends ItemTree { + constructor(props) { + super(props); + this.rowProvider = new CitationExplorerRowProvider(this); + this._setRowProviderUpdateHandler(); + } + + async setItems(items) { + await this.rowProvider.setItems(items); + return this.waitForLoad(); + } + + isSelectable(index, _selectAll = false) { + let row = this.getRow(index); + return !!row && row.type !== 'library'; + } + + _renderItem(index, selection, oldDiv = null, columns = []) { + let div = super._renderItem(index, selection, oldDiv, columns); + let row = this.getRow(index); + div.classList.toggle('library-container-row', row?.type === 'library'); + return div; + } +} + +// //////////////////////////////////////////////////////////////////////////// +// +// Citation Explorer UI +// +// //////////////////////////////////////////////////////////////////////////// + let io, citations, items, uncitedItems, citationList, itemList; let citationRows = []; let itemRows = []; -let uncitedItemRows = []; let _addToTarget; let disableCitationActivate; -let selectedTab = 0; const citationColumns = [ { @@ -69,14 +310,23 @@ const citationColumns = [ }, ]; -let itemColumns = getColumnDefinitionsByDataKey(['title', 'firstCreator', 'year']); +// All standard Zotero columns, with only title/firstCreator/year visible by default. +// The isLinked column is CE-specific and cannot be hidden. +const defaultVisibleColumns = new Set(['title', 'firstCreator', 'year']); +let itemColumns = COLUMNS + .filter(col => col.dataKey !== 'feed') + .map(col => Object.assign({}, col, { + hidden: !defaultVisibleColumns.has(col.dataKey), + })); itemColumns.push({ dataKey: 'isLinked', label: 'Is Linked', iconLabel: , + hidden: false, width: 26, staticWidth: true, fixedWidth: true, + showInColumnPicker: false, renderCell: (index, data, column) => { let icon = getCSSIcon('cross'); if (data) { @@ -86,7 +336,9 @@ itemColumns.push({ return icon; } }); -itemColumns[1].sortDirection = 1; +// Default sort by creator ascending +let creatorCol = itemColumns.find(c => c.dataKey === 'firstCreator'); +if (creatorCol) creatorCol.sortDirection = 1; window.ZoteroDocumentCitations = { init: async function () { @@ -177,39 +429,40 @@ window.ZoteroDocumentCitations = { }, refreshItemList: async function () { - let rows = selectedTab === 0 ? itemRows : uncitedItemRows; - rows.forEach((item) => { - item.isLinked = !item.cslItemID; - }); + let filteredRows = itemRows.filter(row => !this._filteredItems.has(row.id)); - let filteredItems = rows.filter(item => !this._filteredItems.has(item.id)); if (!itemList) { let domElem = document.querySelector('#zotero-items-tree'); - itemList = await ItemTree.init(domElem, { + itemList = await CitationExplorerItemTree.init(domElem, { id: "citation-explorer-items", - regularOnly: true, + regularOnly: false, columns: itemColumns, + columnPicker: true, shouldListenForNotifications: false, - autoSelect: false, onSelectionChange: this.onItemSelectionChange.bind(this), onActivate: this.onItemActivate.bind(this), emptyMessage: Zotero.getString('pane.items.loading'), - firstColumnExtraWidth: 28-16, - firstColumnPrependRenderer: this._itemFirstColumnPrependRenderer.bind(this), + compareItems: (a, b) => { + let libA = a.ref.libraryID ?? Zotero.Libraries.userLibraryID; + let libB = b.ref.libraryID ?? Zotero.Libraries.userLibraryID; + return libA - libB; + }, + getExtraField: (ref, field) => { + if (field === 'isLinked') { + return !ref.cslItemID; + } + }, }); await itemList.waitForLoad(); // Remove focus from citationList if focus is on itemList // to prevent highlighting in both lists - domElem.addEventListener("focusin", (event) => { - citationList?.selection.clearSelection() + domElem.addEventListener("focusin", () => { + citationList?.selection.clearSelection(); }); document.querySelector("item-tree-menu-bar").init(itemList); } - await itemList.changeCollectionTreeRow({ - getItems: async () => filteredItems, - isSearch: () => true, - isSearchMode: () => true, - }); + + await itemList.setItems(filteredRows); }, onCitationFilter: async function () { @@ -226,8 +479,8 @@ window.ZoteroDocumentCitations = { onItemFilter: async function () { let searchString = this._normalizeSearch(document.querySelector('#item-search').value); - let itemStrings = itemRows.map((item) => { - return [item.getField('title'), item.getField('firstCreator'), item.getField('date')].join(' '); + let itemStrings = itemRows.map((row) => { + return [row.ref.getField('title'), row.ref.getField('firstCreator'), row.ref.getField('date')].join(' '); }); this._filteredItems = new Set(); itemStrings.forEach((str, index) => { @@ -241,38 +494,28 @@ window.ZoteroDocumentCitations = { _initMappings: async function () { const itemMap = {}; itemRows = items.map((item) => { - let citedIn = []; - let proxyItem = new Proxy(item, { - get(target, prop) { - if (prop == 'id' && !target.id) { - return target.cslItemID; - } - if (prop == 'citedIn') { - return citedIn; - } - return Reflect.get(...arguments); - } - }); - itemMap[item.id || item.cslItemID] = proxyItem; - return proxyItem; + let cslItemID = item.id ? null : item.cslItemID; + let row = new CitationExplorerItemTreeRow(item, 0, false, { cslItemID }); + itemMap[item.id || item.cslItemID] = row; + return row; }); - itemRows.push(...uncitedItems.map((item) => { - return new Proxy(item, { - get(target, prop) { - if (prop == 'citedIn') { - return []; - } - return Reflect.get(...arguments); - } - }); - })); + // Add uncited items + for (let item of uncitedItems) { + let cslItemID = item.id ? null : item.cslItemID; + let row = new CitationExplorerItemTreeRow(item, 0, false, { citedIn: [], cslItemID }); + itemRows.push(row); + } + // Build citation rows and populate citedIn citationRows = await Promise.all(citations .map(async (citation, citationIndex) => { let isLinked = true; let citedItems = []; - // check if all citation items are linked for (let citationItem of citation.citationItems) { - itemMap[citationItem.id].citedIn.push(citationIndex); + let key = citationItem.id || citationItem.cslItemID; + let row = itemMap[key]; + if (row) { + row.citedIn.push(citationIndex); + } isLinked = isLinked && typeof citationItem.id == 'number'; } let title = await citation.field.getText(); @@ -361,17 +604,23 @@ window.ZoteroDocumentCitations = { * @private */ onItemSelectionChange: async function () { - if (selectedTab === 1) return; + let selectedRows = [...itemList.selection.selected] + .map(index => itemList.getRow(index)) + .filter(row => row && row.type !== 'library'); + this._highlightedCitations = new Set(); - for (let selectedItemIndex of itemList.selection.selected) { - for (let citationIndex of itemRows[selectedItemIndex].citedIn) { + for (let row of selectedRows) { + for (let citationIndex of row.citedIn) { this._highlightedCitations.add(citations[citationIndex].citationID); } } - const item = itemList.getRow(itemList.selection.focused).ref; - const isUnlinked = typeof item.id != 'number'; - const noneSelected = itemList.selection.selected.size === 0; - document.querySelector('#button-show-in-zotero').disabled = noneSelected || isUnlinked; + + let focusedRow = itemList.getRow(itemList.selection.focused); + let isItemRow = focusedRow && focusedRow.type !== 'library'; + let isUnlinked = isItemRow && !focusedRow.isLinked; + let noneSelected = selectedRows.length === 0; + + document.querySelector('#button-show-in-zotero').disabled = noneSelected || !isItemRow || isUnlinked; document.querySelector('#button-relink-item').disabled = noneSelected || !isUnlinked; document.querySelector('#button-addTo-library').disabled = noneSelected || !isUnlinked; @@ -379,13 +628,18 @@ window.ZoteroDocumentCitations = { }, onItemActivate: async function () { - const item = itemList.getRow(itemList.selection.focused).ref; - if (typeof item.id != 'number') { + let focusedRow = itemList.getRow(itemList.selection.focused); + if (!focusedRow || focusedRow.type === 'library') return; + + if (!focusedRow.isLinked) { this.onItemRelink(); } else { - await Zotero.Utilities.Internal.showInLibrary( - Array.from(itemList.selection.selected).map(index => itemList.getRow(index).ref)); + let selectedItems = [...itemList.selection.selected] + .map(index => itemList.getRow(index)) + .filter(row => row && row.type !== 'library') + .map(row => row.ref); + await Zotero.Utilities.Internal.showInLibrary(selectedItems); } }, @@ -404,8 +658,9 @@ window.ZoteroDocumentCitations = { return; } let treeRow = itemList.getRow(itemList.selection.focused); + if (!treeRow || treeRow.type === 'library') return; const oldItemID = treeRow.id; - const itemIdx = itemRows.findIndex(row => row.id === treeRow.id); + const itemIdx = itemRows.findIndex(row => row.id === oldItemID); this._linkItem(items[0], oldItemID, itemIdx); await this._initMappings(); @@ -413,16 +668,6 @@ window.ZoteroDocumentCitations = { await this.refreshItemList(); }, - _itemFirstColumnPrependRenderer: function (index, data, firstColumnPrepend) { - if (itemRows[index].citedIn.length === 0) { - let span = document.createElement('span'); - span.classList.add('badge', 'badge-uncited'); - span.textContent = 'Uncited'; - firstColumnPrepend.push(span); - } - return firstColumnPrepend; - }, - async addToLibraryAndLink() { var collectionID = _addToTarget.objectType == 'collection' ? _addToTarget.id : undefined; @@ -436,8 +681,9 @@ window.ZoteroDocumentCitations = { for (let index of itemList.selection.selected) { let treeRow = itemList.getRow(index); + if (treeRow.type === 'library') continue; const oldItemID = treeRow.id; - const itemIdx = itemRows.findIndex(row => row.id === treeRow.id); + const itemIdx = itemRows.findIndex(row => row.id === oldItemID); // Save item let item = treeRow.ref.clone(_addToTarget.libraryID); @@ -549,4 +795,4 @@ window.ZoteroDocumentCitations = { window.addEventListener('DOMContentLoaded', function () { ZoteroDocumentCitations.init(); -}); \ No newline at end of file +});