diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index 982c679b77..0414798e68 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -51,6 +51,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { } static defaultProps = { + customRows: [], dragAndDrop: false, filterLibraryIDs: false, hideSources: [], @@ -61,6 +62,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { static propTypes = { onSelectionChange: PropTypes.func.isRequired, + customRows: PropTypes.array, dragAndDrop: PropTypes.bool, filterLibraryIDs: PropTypes.array, hideSources: PropTypes.array, @@ -491,7 +493,6 @@ var CollectionTree = class CollectionTree extends LibraryTree { ref: ref => this.tree = ref, treeboxRef: ref => this._treebox = ref, renderItem: this.renderItem, - alternatingRowColors: null, onSelectionChange: this._handleSelectionChange, isSelectable: this.isSelectable, @@ -603,6 +604,26 @@ var CollectionTree = class CollectionTree extends LibraryTree { ); added += await this._expandRow(newRows, added - 1); } + + // Custom rows, e.g. Suggested Items in select dialog + let customRows = this.props.customRows.map((customRow) => { + let row = new Zotero.CollectionTreeRow( + this, + customRow.type, + customRow.ref, + customRow.level, + customRow.isOpen + ); + row._id = customRow.id; + return Object.assign(row, customRow.properties); + }); + let addedCustomRows = customRows.length; + if (addedCustomRows) { + customRows.push(new Zotero.CollectionTreeRow(this, 'separator', false, 0)); + addedCustomRows++; + newRows.unshift(...customRows); + added += addedCustomRows; + } this.selection.selectEventsSuppressed = true; // If the focused row does not match the filter, create a hidden dummy row at the bottom @@ -1504,6 +1525,10 @@ var CollectionTree = class CollectionTree extends LibraryTree { getIconName(index) { const treeRow = this.getRow(index); + // Custom row icons + if (treeRow.iconName) { + return treeRow.iconName; + } let collectionType = treeRow.type; let icon = collectionType; @@ -2025,159 +2050,11 @@ var CollectionTree = class CollectionTree extends LibraryTree { return true; } - /** - * Copy a given item into another library. Used when we need to create a copy of a collection - * in another library if collection is drag-dropped into a group it is not a part of. - */ - async _copyItem({ item, targetLibraryID, targetTreeRow, options }) { - // Check if there's already a copy of this item in the library - var linkedItem = await item.getLinkedItem(targetLibraryID, true); - if (linkedItem) { - return linkedItem.id; - - /* - // TODO: support tags, related, attachments, etc. - - // Overlay source item fields on unsaved clone of linked item - var newItem = item.clone(false, linkedItem.clone(true)); - newItem.setField('dateAdded', item.dateAdded); - newItem.setField('dateModified', item.dateModified); - - var diff = newItem.diff(linkedItem, false, ["dateAdded", "dateModified"]); - if (!diff) { - // Check if creators changed - var creatorsChanged = false; - - var creators = item.getCreators(); - var linkedCreators = linkedItem.getCreators(); - if (creators.length != linkedCreators.length) { - Zotero.debug('Creators have changed'); - creatorsChanged = true; - } - else { - for (var i=0; i { var collections = [{ id: collection.id, @@ -2271,7 +2144,6 @@ var CollectionTree = class CollectionTree extends LibraryTree { parentID: targetCollectionID, addItems, targetLibraryID, - targetTreeRow, copyOptions }); for (let [collectionID, items] of addItems.entries()) { @@ -2371,7 +2243,6 @@ var CollectionTree = class CollectionTree extends LibraryTree { collection: droppedCollection, targetCollectionID, targetLibraryID, - targetTreeRow, copyOptions }); } @@ -2490,12 +2361,10 @@ var CollectionTree = class CollectionTree extends LibraryTree { return Zotero.DB.executeTransaction(async () => { let copiedItemIDs = []; for (let item of chunk) { - var id = await this._copyItem({ - item, - targetLibraryID, - targetTreeRow, - options: copyOptions - }); + let copiedItem = await Zotero.Items.copyToLibrary( + item, targetLibraryID, copyOptions + ); + let id = copiedItem && copiedItem.id; // Standalone attachments might not get copied if (!id) { continue; diff --git a/chrome/content/zotero/components/windowed-list.js b/chrome/content/zotero/components/windowed-list.js index cd264e6851..7b36dbf251 100644 --- a/chrome/content/zotero/components/windowed-list.js +++ b/chrome/content/zotero/components/windowed-list.js @@ -63,6 +63,7 @@ module.exports = class { this.scrollDirection = 0; this.scrollOffset = 0; this.overscanCount = 2; + this._invalidating = false; this._lastItemCount = null; this._rowOffsets = [[0, 0]]; @@ -113,6 +114,8 @@ module.exports = class { * Rerender items within the scrollbox. Call sparingly */ invalidate() { + if (this._invalidating) return; + this._invalidating = true; // Removes any items out of view and adds the ones not in view let oldRenderedRows = new Set(this._renderedRows.keys()); this.render(); @@ -122,6 +125,7 @@ module.exports = class { if (!oldRenderedRows.has(index)) continue; this.rerenderItem(index); } + this._invalidating = false; } /** diff --git a/chrome/content/zotero/elements/splitMenuButton.js b/chrome/content/zotero/elements/splitMenuButton.js index 600bf9b613..a71a6f6064 100644 --- a/chrome/content/zotero/elements/splitMenuButton.js +++ b/chrome/content/zotero/elements/splitMenuButton.js @@ -58,6 +58,20 @@ connectedCallback() { this.append(this.contentFragment); + + // Prevent DOM-attached mouse handlers from running in the dropmarker area + for (const eventType of ['mousedown', 'mouseup', 'click']) { + const handler = this.getAttribute('on' + eventType); + if (!handler) { + continue; + } + this['on' + eventType] = null; + this.addEventListener(eventType, (event) => { + if (!this._isEventInDropmarkerBox(event)) { + eval(handler).bind(this); + } + }); + } } disconnectedCallback() { 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 f92d0879bb..6ce6f441b2 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -27,67 +27,65 @@ const React = require('react'); const ReactDOM = require('react-dom'); const diff = require('diff'); const VirtualizedTable = require('components/virtualized-table'); -const { getCSSIcon, IconAttachSmall } = require('components/icons'); -// 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 ItemTree = require('zotero/itemTree'); -const { getColumnDefinitionsByDataKey } = require('zotero/itemTreeColumns'); +const { getCSSIcon, CSSIcon } = require('components/icons'); +const { COLUMNS } = require('zotero/itemTreeColumns'); const { makeRowRenderer } = VirtualizedTable; +const { + CitationExplorerItemTree, + CitationExplorerItemTreeRow, + LibraryItemTreeRow, + UnlinkedItemsTreeRow, + UNLINKED_ITEMS_ID, +} = require('zotero/integration/citationExplorerItemTree'); + +// //////////////////////////////////////////////////////////////////////////// +// +// 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 = [ + { + dataKey: 'title', + label: 'integration-citationExplorer-citation-column', + type: 'html' + }, { dataKey: 'isLinked', - label: 'Is Linked', - iconLabel: , + label: 'integration-citationExplorer-linked-column', + iconLabel: , width: 26, staticWidth: true, fixedWidth: true, renderCell: (index, data, column) => { - let icon = getCSSIcon('IconCross'); + let icon = getCSSIcon('cross'); if (data) { - icon = getCSSIcon('IconTick'); + icon = getCSSIcon('tick'); } - icon.className += ` cell ${column.className}`; + + icon.className += ` cell icon-16 ${column.className}`; return icon; } }, - { - dataKey: 'title', - label: "Citation", - type: 'html' - }, ]; -let itemColumns = getColumnDefinitionsByDataKey(['title', 'firstCreator', 'date']); -itemColumns.push({ - dataKey: 'isLinked', - label: 'Is Linked', - iconLabel: , - width: 26, - staticWidth: true, - fixedWidth: true, - renderCell: (index, data, column) => { - let icon = getCSSIcon('IconCross'); - if (data) { - icon = getCSSIcon('IconTick'); - } - icon.className += ` cell ${column.className}`; - return icon; - } -}); -itemColumns[1].sortDirection = 1; +// All standard Zotero columns, with only title/firstCreator/year visible by default. +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), + })); +// Default sort by creator ascending +let creatorCol = itemColumns.find(c => c.dataKey === 'firstCreator'); +if (creatorCol) creatorCol.sortDirection = 1; -window.ZoteroDocumentCitations = { +window.ZoteroCitationExplorer = { init: async function () { this._highlightedCitations = new Set(); this._filteredCitations = new Set(); @@ -97,27 +95,28 @@ window.ZoteroDocumentCitations = { document.querySelector('#button-edit-citation').addEventListener('click', this.onCitationEdit.bind(this)); document.querySelector('#button-show-in-zotero').addEventListener('click', this.onItemActivate.bind(this)); - document.querySelector('#button-relink-item').addEventListener('click', this.onItemRelink.bind(this)); - - let lastTranslationTarget = Zotero.Prefs.get('documentCitations.lastAddToTarget'); - if (lastTranslationTarget) { - let id = parseInt(lastTranslationTarget.substr(1)); - if (lastTranslationTarget[0] == "L") { - _addToTarget = Zotero.Libraries.get(id); - } - else if (lastTranslationTarget[0] == "C") { - _addToTarget = Zotero.Collections.get(id); - } - } - if (!_addToTarget) { - _addToTarget = Zotero.Libraries.userLibrary; - } - this.setAddToButton(); + document.querySelector('#button-manage-items').addEventListener('click', this.onManageItems.bind(this)); io = window.arguments[0].wrappedJSObject; citations = Object.values(io.citations); items = io.items; uncitedItems = io.uncitedItems; + + // Load library data for all items + let librariesNeeded = new Set(); + for (let item of [...items, ...uncitedItems]) { + if (item.libraryID) { + librariesNeeded.add(item.libraryID); + } + } + for (let libraryID of librariesNeeded) { + let library = Zotero.Libraries.get(libraryID); + if (!library.getDataLoaded('item')) { + Zotero.debug("Waiting for items to load for library " + library.libraryID); + await library.waitForDataLoad('item'); + } + } + await this._initMappings(); await this.refreshCitationList(); await this.refreshItemList(); @@ -132,8 +131,9 @@ window.ZoteroDocumentCitations = { // init VirtualizedTable if (!citationList) { await new Promise((resolve) => { - ReactDOM.createRoot(document.querySelector('#citation-list')).render( { citationList = ref; resolve(); @@ -148,36 +148,41 @@ window.ZoteroDocumentCitations = { onSelectionChange={this.onCitationSelectionChange.bind(this)} getRowString={index => this._renderedCitationRows[index].title} />); + // Remove focus from itemList if focus is on citationList + // to prevent highlighting in both lists + domElem.addEventListener("focusin", (event) => { + itemList?.selection.clearSelection() + }); }); } citationList.invalidate(); }, 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('#item-list'); - itemList = await ItemTree.init(domElem, { - id: "document-collections", - regularOnly: true, + let domElem = document.querySelector('#zotero-items-tree'); + itemList = await CitationExplorerItemTree.init(domElem, { + id: "citation-explorer-items", + regularOnly: false, columns: itemColumns, + columnPicker: true, shouldListenForNotifications: false, onSelectionChange: this.onItemSelectionChange.bind(this), onActivate: this.onItemActivate.bind(this), - emptyMessage: Zotero.getString('pane.items.loading') + emptyMessage: Zotero.getString('pane.items.loading'), }); await itemList.waitForLoad(); + // Remove focus from citationList if focus is on itemList + // to prevent highlighting in both lists + 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 () { @@ -194,8 +199,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) => { @@ -206,68 +211,40 @@ window.ZoteroDocumentCitations = { await this.refreshItemList(); }, - onSelectTab: async function (selectedIndex) { - if (selectedIndex === selectedTab) return; - selectedTab = selectedIndex; - if (selectedTab) { - this._highlightedCitations = new Set(); - document.querySelector('#button-show-in-zotero').hidden = true; - document.querySelector('#button-relink-item').hidden = false; - document.querySelector('#button-addTo-library').style.display = 'none'; - } - await this.refreshCitationList(); - await this.refreshItemList(); - }, - _initMappings: async function () { + const itemMap = {}; itemRows = items.map((item) => { - let citedIn = []; - return new Proxy(item, { - get(target, prop) { - if (prop == 'id' && !target.id) { - return target.cslItemID; - } - if (prop == 'citedIn') { - return citedIn; - } - return Reflect.get(...arguments); - } - }); - }); - uncitedItemRows = uncitedItems.map((item) => { - return new Proxy(item, { - get(target, prop) { - if (prop == 'citedIn') { - return []; - } - return Reflect.get(...arguments); - } - }); + let cslItemID = item.id ? null : item.cslItemID; + let row = new CitationExplorerItemTreeRow(item, 0, false, { cslItemID }); + itemMap[item.id || item.cslItemID] = row; + return row; }); + // 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) { - itemRows.forEach((itemRow, itemIndex) => { - if ([itemRow.id, itemRow.cslItemID].includes(citationItem.id)) { - citedItems.push(itemIndex); - itemRow.citedIn.push(citationIndex); - } - }); - if (typeof citationItem.id != 'number') { - isLinked = false; - break; + 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(); if (citation.properties.plainCitation != title) { let d = diff(citation.properties.plainCitation, title); title = d.map(([type, text]) => { if (type == 0) return text; - if (type == -1) return `${text}`; - if (type == 1) return `${text}`; + if (type == -1) return `${text}`; + if (type == 1) return `${text}`; }).join(''); } return { @@ -296,7 +273,9 @@ window.ZoteroDocumentCitations = { return; } } - catch (e) { } + catch (e) { + Zotero.logError(e); + } // An error got thrown or wrong citation got activated, which means that some citations got deleted // and now the citation explorer dialog is not showing correct citations and citation // activation is not going to work right. @@ -328,6 +307,9 @@ window.ZoteroDocumentCitations = { } } itemList.setHighlightedRows(highlightedItems); + const noneSelected = citationList.selection.selected.size === 0; + document.querySelector('#button-show-in-document').disabled = noneSelected; + document.querySelector('#button-edit-citation').disabled = noneSelected; }, onCitationEdit: async function () { @@ -342,158 +324,114 @@ window.ZoteroDocumentCitations = { * @private */ onItemSelectionChange: async function () { - if (selectedTab === 1) return; + const getGroupKey = (row) => { + if (!row) return null; + if (row instanceof UnlinkedItemsTreeRow) return UNLINKED_ITEMS_ID; + if (row instanceof CitationExplorerItemTreeRow) { + return row.isLinked + ? (row.ref.libraryID ?? Zotero.Libraries.userLibraryID) + : UNLINKED_ITEMS_ID; + } + if (row instanceof LibraryItemTreeRow) { + return row.ref.libraryID ?? Zotero.Libraries.userLibraryID; + } + return null; + }; + + let selectedIndexes = [...itemList.selection.selected]; + if (selectedIndexes.length > 1) { + let focusedIndex = itemList.selection.focused; + if (focusedIndex == null || focusedIndex < 0 || !itemList.getRow(focusedIndex)) { + focusedIndex = selectedIndexes[0]; + } + let focusedRow = itemList.getRow(focusedIndex); + let allowedIndexes = []; + + // Deny multi-selection that includes container rows + if (focusedRow instanceof LibraryItemTreeRow) { + allowedIndexes = [focusedIndex]; + } + else { + let focusedGroup = getGroupKey(focusedRow); + allowedIndexes = selectedIndexes.filter((index) => { + let row = itemList.getRow(index); + return row instanceof CitationExplorerItemTreeRow + && getGroupKey(row) === focusedGroup; + }); + } + + if (!allowedIndexes.length) { + allowedIndexes = [focusedIndex]; + } + + if (allowedIndexes.length !== selectedIndexes.length) { + let oldSelected = new Set(itemList.selection.selected); + itemList.selection.selected = new Set(allowedIndexes); + if (!allowedIndexes.includes(focusedIndex)) { + itemList.selection.focused = allowedIndexes[0] ?? focusedIndex; + } + itemList.selection.pivot = itemList.selection.focused; + let toInvalidate = new Set([...oldSelected, ...allowedIndexes]); + for (let index of toInvalidate) { + itemList.tree?.invalidateRow(index); + } + selectedIndexes = allowedIndexes; + } + } + + let selectedRows = selectedIndexes + .map(index => itemList.getRow(index)) + .filter(row => row instanceof CitationExplorerItemTreeRow); + 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 isMultiple = itemList.selection.selected.size > 1; - document.querySelector('#button-show-in-zotero').hidden = isMultiple || isUnlinked; - document.querySelector('#button-relink-item').hidden = isMultiple || !isUnlinked; - document.querySelector('#button-addTo-library').style.display = (isMultiple || !isUnlinked) ? 'none' : 'inherit'; + + let focusedRow = itemList.getRow(itemList.selection.focused); + let isItemRow = focusedRow instanceof CitationExplorerItemTreeRow; + let isUnlinked = isItemRow && !focusedRow.isLinked; + let noneItemsSelected = selectedRows.length === 0; + + document.querySelector('#button-show-in-zotero').disabled = noneItemsSelected || !isItemRow || isUnlinked; await this.refreshCitationList(); }, onItemActivate: async function () { - if (itemList.selection.selected.size > 1) return; - const item = itemList.getRow(itemList.selection.focused).ref; - if (typeof item.id != 'number') { - this.onItemRelink(); + let focusedRow = itemList.getRow(itemList.selection.focused); + if (focusedRow instanceof LibraryItemTreeRow) return; + + if (!focusedRow.isLinked && itemList.selection.count === 1) { + this.onManageItems(); } else { - await Zotero.Utilities.Internal.showInLibrary(item); + let selectedItems = [...itemList.selection.selected] + .map(index => itemList.getRow(index)) + .filter(row => row instanceof CitationExplorerItemTreeRow) + .map(row => row.ref); + await Zotero.Utilities.Internal.showInLibrary(selectedItems); } }, - onItemRelink: async function () { - let io = { dataIn: null, dataOut: null, multiSelect: false, deferred: Zotero.Promise.defer() }; - window.openDialog('chrome://zotero/content/selectItemsDialog.xhtml', '', - 'chrome,dialog=no,centerscreen,resizable=yes', io); - - await io.deferred.promise; - if (!io.dataOut || !io.dataOut.length) { - return; + onManageItems: async function () { + let wizardIO = Object.assign({}, io, { + completed: false, + deferred: Zotero.Promise.defer(), + }); + wizardIO.wrappedJSObject = wizardIO; + window.openDialog( + 'chrome://zotero/content/integration/citationExplorerWizard.xhtml', + '', + 'chrome,dialog=no,centerscreen,resizable=yes', + wizardIO + ); + await wizardIO.deferred.promise; + if (wizardIO.completed) { + window.close(); } - - let items = await Zotero.Items.getAsync(io.dataOut); - if (!items.length) { - return; - } - let treeRow = itemList.getRow(itemList.selection.focused); - const oldItemID = treeRow.id; - const itemIdx = itemRows.findIndex(row => row.id === treeRow.id); - this._linkItem(items[0], oldItemID, itemIdx); - - await this._initMappings(); - await this.refreshCitationList(); - await this.refreshItemList(); - }, - - async addToLibraryAndLink() { - var collectionID = _addToTarget.objectType == 'collection' ? _addToTarget.id : undefined; - for (let index of itemList.selection.selected) { - let treeRow = itemList.getRow(index); - const oldItemID = treeRow.id; - const itemIdx = itemRows.findIndex(row => row.id === treeRow.id); - - // Save item - let item = treeRow.ref.clone(_addToTarget.libraryID); - if (collectionID) { - item.addToCollection(collectionID); - } - await item.saveTx(); - this._linkItem(item, oldItemID, itemIdx); - } - await this._initMappings(); - await this.refreshCitationList(); - await this.refreshItemList(); - }, - - _linkItem(item, oldItemID, itemIdx) { - // For all citations where the item is cited - for (let citationIndex of itemRows[itemIdx].citedIn) { - let citation = citations[citationIndex]; - let citationItemIdx = citation.citationItems.findIndex(i => i.id == oldItemID); - let citationItem = citation.citationItems[citationItemIdx]; - // Update the citation with the new item - citationItem.id = item.id; - citationItem.uris = Zotero.Integration.currentSession.uriMap.getURIsForItemID(citationItem.id); - // Mark citation for an update with citeproc and write changes to doc - io.updateIndex(citationIndex); - } - items[itemIdx] = item; - }, - - buildAddToLibraryContextMenu(event) { - var menu = document.querySelector('#item-addTo-menu'); - // Don't trigger rebuilding on nested popupmenu open/close - if (event.target != menu) { - return; - } - // Clear previous items - while (menu.firstChild) { - menu.removeChild(menu.firstChild); - } - - let target = Zotero.Prefs.get('documentCitations.lastAddToTarget'); - if (!target) { - target = "L" + Zotero.Libraries.userLibraryID; - } - - var libraries = Zotero.Libraries.getAll(); - for (let library of libraries) { - if (!library.editable || library.libraryType == 'publications') { - continue; - } - Zotero.Utilities.Internal.createMenuForTarget( - library, - menu, - target, - function(event, libraryOrCollection) { - if (event.target.tagName == 'menu') { - Zotero.Promise.coroutine(function* () { - // Simulate menuitem flash on OS X - if (Zotero.isMac) { - event.target.setAttribute('_moz-menuactive', false); - yield Zotero.Promise.delay(50); - event.target.setAttribute('_moz-menuactive', true); - yield Zotero.Promise.delay(50); - event.target.setAttribute('_moz-menuactive', false); - yield Zotero.Promise.delay(50); - event.target.setAttribute('_moz-menuactive', true); - } - menu.hidePopup(); - - ZoteroDocumentCitations.setAddToTarget(libraryOrCollection); - event.stopPropagation(); - })(); - } - else { - ZoteroDocumentCitations.setAddToTarget(libraryOrCollection); - event.stopPropagation(); - } - } - ); - } - }, - - setAddToTarget(translationTarget) { - _addToTarget = translationTarget; - Zotero.Prefs.set('documentCitations.lastAddToTarget', translationTarget.treeViewID); - this.setAddToButton(); - }, - - setAddToButton() { - var label = Zotero.getString('pane.item.addTo', _addToTarget.name); - var elem = document.querySelector('#button-addTo-library'); - elem.label = label; - elem.title = label; - elem.image = _addToTarget.treeViewImage; }, @@ -510,5 +448,5 @@ window.ZoteroDocumentCitations = { }; window.addEventListener('DOMContentLoaded', function () { - ZoteroDocumentCitations.init(); -}); \ No newline at end of file + ZoteroCitationExplorer.init(); +}); diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index 62ddb9c227..8cde1bc579 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -25,76 +25,78 @@ ***** END LICENSE BLOCK ***** --> + - + + + + + + + + + + + + -
-
-
-
Citations
-
- -
-
-
-
- - -
-
-
-
-
- - - - - - - - -
- -
-
-
-
- - -
-
-
+ +
+ + +
+ +
+ +
+ +
+
+
+ +
+ + +
+ + +
+ +
+ +
+ +
+
+
+ +
+ +
diff --git a/chrome/content/zotero/integration/citationExplorerItemTree.js b/chrome/content/zotero/integration/citationExplorerItemTree.js new file mode 100644 index 0000000000..a003e64528 --- /dev/null +++ b/chrome/content/zotero/integration/citationExplorerItemTree.js @@ -0,0 +1,349 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2023 Corporation for Digital Scholarship + Vienna, Virginia, USA + http://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +const { getCSSIcon } = require('components/icons'); +const ItemTree = require('zotero/itemTree'); +const { ItemTreeRowProvider } = ItemTree; +const { ItemTreeRow, ZoteroItemTreeRow } = require('zotero/itemTreeRow'); + +// //////////////////////////////////////////////////////////////////////////// +// +// Citation Explorer Row Classes +// +// //////////////////////////////////////////////////////////////////////////// + +const UNLINKED_ITEMS_ID = 'UNLINKED_ITEMS'; + +/** + * Sort unlinked items first, then libraries by libraryID. + * Used as CitationExplorerItemTree's default compareItems(). + */ +function getLibraryGroupOrder(row) { + if (row.ref?.treeViewID === UNLINKED_ITEMS_ID + || (row.ref?.cslItemID && !row.ref?.id)) { + return -1; + } + if (row.ref instanceof Zotero.Library) { + return row.ref.libraryID ?? Zotero.Libraries.userLibraryID; + } + return row.ref?.libraryID ?? Zotero.Libraries.userLibraryID; +} + +/** + * Container row wrapping a Zotero.Library. + */ +class LibraryItemTreeRow extends ItemTreeRow { + constructor(library, items, isOpen = true) { + super(library, 0, isOpen); // library has treeViewID = "L" + this._items = items; + } + + get type() { + return 'library'; + } + + get sortChildren() { + return true; + } + + isContainer() { + return true; + } + + isContainerEmpty() { + return this._items.length === 0; + } + + getChildItems() { + return this._items; + } + + getField(field) { + if (field === 'title') return this.getDisplayTitle(); + return ''; + } + + getDisplayTitle() { + return this.ref.name; + } + + 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; + } + + renderRow(div, index, columns, rowData, renderCtx) { + let titleColumn = columns.find(c => c.dataKey === 'title') || columns[0]; + let cell = renderCtx.renderCell(index, this.getDisplayTitle(), titleColumn, true); + div.appendChild(cell); + } +} + +/** + * Pseudo-library container for unlinked CSL-only items. + */ +class UnlinkedItemsTreeRow extends LibraryItemTreeRow { + constructor(items, isOpen = true) { + super({ id: UNLINKED_ITEMS_ID, treeViewID: UNLINKED_ITEMS_ID }, items, isOpen); + } + + get type() { + return 'unlinked-items'; + } + + getDisplayTitle() { + return Zotero.getString('integration.citationExplorer.unlinkedItems'); + } + + getIcon() { + let icon = getCSSIcon('cross'); + 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; + } + + getField(field) { + if (field === 'action') return ''; + return super.getField(field); + } + + 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) { + let badge = document.createElement('span'); + badge.classList.add('badge', 'badge-uncited'); + badge.textContent = Zotero.getString('integration.citationExplorer.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._unlinkedItems = []; + this._citedInByID = new Map(); + this._cslItemIDByID = new Map(); + this._containerOpenState = 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.treeViewID === UNLINKED_ITEMS_ID) { + return new UnlinkedItemsTreeRow(this._unlinkedItems, isOpen); + } + if (ref instanceof Zotero.Library) { + let items = this._sourceItems.filter((item) => { + let key = item.treeViewID ?? item.cslItemID; + return (item.libraryID ?? Zotero.Libraries.userLibraryID) === ref.libraryID + && !this._cslItemIDByID.has(key); + }); + return new LibraryItemTreeRow(ref, items, isOpen); + } + + let key = ref.treeViewID ?? ref.cslItemID; + 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.id; + 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. Use grouped container mode when there are + * multiple libraries OR any unlinked items. + */ + _rebuildRows() { + for (let row of this._rows) { + if (row instanceof LibraryItemTreeRow) { + this._containerOpenState.set(row.id, row.isContainerOpen()); + } + } + + const byLibrary = new Map(); + this._unlinkedItems = []; + + for (const item of this._sourceItems) { + let key = item.treeViewID ?? item.cslItemID; + if (this._cslItemIDByID.has(key)) { + this._unlinkedItems.push(item); + continue; + } + 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 = this._unlinkedItems.length > 0 || sortedLibIDs.length > 1; + + this._rows = []; + if (showContainers) { + if (this._unlinkedItems.length) { + let isOpen = this._containerOpenState.get(UNLINKED_ITEMS_ID) ?? true; + this._rows.push(this.createRow({ treeViewID: UNLINKED_ITEMS_ID }, 0, isOpen)); + } + for (const libID of sortedLibIDs) { + let library = Zotero.Libraries.get(libID); + let isOpen = this._containerOpenState.get(library.treeViewID) ?? true; + this._rows.push(this.createRow(library, 0, isOpen)); + // 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. + */ +class CitationExplorerItemTree extends ItemTree { + static defaultProps = { + ...ItemTree.defaultProps, + compareItems: (a, b) => getLibraryGroupOrder(a) - getLibraryGroupOrder(b), + }; + + 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) { + return !!this.getRow(index); + } + + handleKeyDown(event) { + if (!super.handleKeyDown(event)) return false; + if (event.key === ' ' + && this.props.onActionMenuOpen?.()) { + return false; + } + return true; + } + + _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 instanceof LibraryItemTreeRow); + return div; + } +} + +module.exports = { + CitationExplorerItemTree, + CitationExplorerItemTreeRow, + LibraryItemTreeRow, + UnlinkedItemsTreeRow, + UNLINKED_ITEMS_ID, + getLibraryGroupOrder, +}; + diff --git a/chrome/content/zotero/integration/citationExplorerWizard.js b/chrome/content/zotero/integration/citationExplorerWizard.js new file mode 100644 index 0000000000..f6797650c1 --- /dev/null +++ b/chrome/content/zotero/integration/citationExplorerWizard.js @@ -0,0 +1,694 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2026 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +const { COLUMNS } = require('zotero/itemTreeColumns'); +const { + CitationExplorerItemTree, + CitationExplorerItemTreeRow, + LibraryItemTreeRow, +} = require('zotero/integration/citationExplorerItemTree'); + +const ACTION_ADD_TO_COLLECTION = 'addToCollection'; +const ACTION_RELINK_TO_EXISTING = 'relinkToExisting'; +const ACTION_COPY_FROM_OTHER_LIBRARY = 'copyFromOtherLibrary'; +const ACTION_ADD_FROM_DOCUMENT = 'addFromDocument'; +const ACTION_CHOOSE = 'choose'; +const ACTION_SKIP = 'skip'; + +/** + * An action offered for a document item. + * + * @typedef {Object} ItemAction + * @property {String} type - One of the ACTION_* constants above. + * @property {Zotero.Item} [item] - Existing item to relink to or copy from. Actions that + * create or skip an item do not need one. + */ + +/** + * State used to determine and apply the action for a document item. + * + * @typedef {Object} CitationExplorerWizardItemData + * @property {Zotero.Item} item + * @property {Boolean} isCited - Used for Uncited badge + * @property {ItemAction[]} availableActions + * @property {Number} selectedActionIndex + * @property {Number[]} candidateItemIDs - IDs of matching library items suggested by duplicate + * detection. Used to seed actions and item picker. + */ + +const ITEM_TREE_COLUMNS = ['title', 'firstCreator', 'year'].map((dataKey) => { + let column = COLUMNS.find(col => col.dataKey === dataKey); + return Object.assign({}, column, { + hidden: false, + sortDirection: dataKey === 'title' ? 1 : 0, + }); +}); +ITEM_TREE_COLUMNS.find(column => column.dataKey === 'title').flex = 1; +ITEM_TREE_COLUMNS.find(column => column.dataKey === 'firstCreator').width = 90; +ITEM_TREE_COLUMNS.find(column => column.dataKey === 'year').width = 70; + +let io; +let wizard; +let itemTree; +let targetCollection; + +/** @type {Map} */ +let itemData = new Map(); +let actionDisplay = new WeakMap(); +let isApplying = false; +let isChoosingItem = false; + +window.ZoteroCitationExplorerWizard = { + async init() { + io = window.arguments[0].wrappedJSObject ?? window.arguments[0]; + wizard = document.getElementById('citation-explorer-wizard'); + this._initMappings(); + + window.addEventListener('close', (event) => { + if (isApplying) event.preventDefault(); + }); + wizard.getPageById('choose-operation') + .addEventListener('pageshow', this.onChoosePageShow.bind(this)); + wizard.getPageById('review-actions') + .addEventListener('pageshow', this.onReviewPageShow.bind(this)); + wizard.addEventListener('wizardfinish', this.onFinish.bind(this)); + + // wizard.shadowRoot content isn't exposed to our CSS + wizard.shadowRoot + .querySelector('.wizard-header-label').style.fontSize = '16px'; + + // The first page is already shown by the time the window's load handler runs. + this.onChoosePageShow(); + }, + + unload() { + itemTree?.unregister(); + io?.deferred?.resolve(); + }, + + onChoosePageShow() { + let operation = document.getElementById('item-operation'); + let hasUnlinkedItems = [...itemData.values()].some(data => !data.item.id); + document.getElementById('item-operation-relink').hidden = !hasUnlinkedItems; + document.getElementById('item-operation-relink-description').hidden = !hasUnlinkedItems; + if (!operation.value || (!hasUnlinkedItems && operation.value === 'relink')) { + operation.value = hasUnlinkedItems ? 'relink' : 'addToTarget'; + } + targetCollection ??= this._getDefaultLibrary(); + this.buildTargetMenu(); + wizard.canRewind = false; + }, + + async onReviewPageShow() { + wizard.canRewind = false; + wizard.canAdvance = false; + wizard.getButton('finish').disabled = true; + await this.initItemTree(); + await itemTree.setItemsPaneMessage( + await document.l10n.formatValue('integration-citationExplorerWizard-status-finding') + ); + + try { + await this._initActions(); + await this._setItemTreeItems(); + wizard.canRewind = true; + this._updateCanFinish(); + } + catch (e) { + Zotero.logError(e); + await itemTree.setItemsPaneMessage( + await document.l10n.formatValue('integration-citationExplorerWizard-status-find-error') + ); + wizard.canRewind = true; + } + }, + + _initMappings() { + itemData = new Map(); + for (let item of [...io.items, ...io.uncitedItems]) { + let itemID = this._getItemID(item); + // See CitationExplorerWizardItemData + itemData.set(itemID, { + item, + isCited: false, + availableActions: [], + selectedActionIndex: -1, + candidateItemIDs: [], + }); + } + + for (let citation of Object.values(io.citations)) { + for (let citationItem of citation.citationItems) { + let data = itemData.get(this._getItemID(citationItem)); + data.isCited = true; + } + } + }, + + _getItemID(item) { + return item.id || item.cslItemID; + }, + + _getLibraries() { + return Zotero.Libraries.getAll().filter(library => library.editable + && library.libraryType !== 'publications'); + }, + + /** + * Default to the eligible library with the highest number of linked citation + * occurrences. Uncited linked items count once. + */ + _getDefaultLibrary() { + let libraries = this._getLibraries(); + if (!libraries.length) return null; + + let counts = new Map(); + for (let citation of Object.values(io.citations)) { + for (let citationItem of citation.citationItems) { + let item = itemData.get(this._getItemID(citationItem))?.item; + if (item?.id) { + counts.set(item.libraryID, (counts.get(item.libraryID) || 0) + 1); + } + } + } + for (let item of io.uncitedItems) { + if (item.id) { + counts.set(item.libraryID, (counts.get(item.libraryID) || 0) + 1); + } + } + + return libraries.reduce((defaultLibrary, library) => { + return (counts.get(library.libraryID) || 0) + > (counts.get(defaultLibrary.libraryID) || 0) + ? library + : defaultLibrary; + }, libraries[0]); + }, + + buildTargetMenu() { + let targetField = document.getElementById('item-target'); + let menu = document.getElementById('item-target-menu'); + menu.replaceChildren(); + + let selectTarget = (event, libraryOrCollection) => { + targetCollection = libraryOrCollection; + targetField.open = false; + event.stopPropagation(); + this.buildTargetMenu(); + }; + for (let library of this._getLibraries()) { + Zotero.Utilities.Internal.createMenuForTarget( + library, + menu, + targetCollection?.treeViewID, + selectTarget, + () => false + ); + } + + // A collection can be nested in a submenu, so the menulist doesn't derive its + // displayed value from a direct child menuitem. + targetField.selectedIndex = -1; + if (targetCollection) { + targetField.setAttribute('label', targetCollection.name); + targetField.setAttribute('image', targetCollection.treeViewImage); + } + else { + targetField.removeAttribute('label'); + targetField.removeAttribute('image'); + } + wizard.canAdvance = !!targetCollection; + }, + + async initItemTree() { + if (itemTree) return; + + let actionColumn = { + dataKey: 'action', + label: await document.l10n.formatValue( + 'integration-citationExplorerWizard-action-column' + ), + width: 360, + minWidth: 280, + renderCell: (index, _data, column, _isFirstColumn, doc) => { + return this._renderActionCell(itemTree.getRow(index), column, doc); + }, + }; + + itemTree = await CitationExplorerItemTree.init( + document.getElementById('zotero-items-tree'), + { + id: 'citation-explorer-item-actions', + regularOnly: false, + columns: [...ITEM_TREE_COLUMNS, actionColumn], + columnPicker: false, + multiSelect: false, + shouldListenForNotifications: false, + onActivate: () => this._openActionMenuForFocusedRow(), + onActionMenuOpen: () => this._openActionMenuForFocusedRow(), + } + ); + await itemTree.waitForLoad(); + }, + + async _initActions() { + actionDisplay = new WeakMap(); + let targetLibrary = Zotero.Libraries.get(targetCollection.libraryID); + if (!targetLibrary.getDataLoaded('item')) { + await targetLibrary.waitForDataLoad('item'); + } + + let operation = document.getElementById('item-operation').value; + for (let data of itemData.values()) { + data.availableActions = []; + data.selectedActionIndex = -1; + data.candidateItemIDs = []; + + if (operation === 'relink' && data.item.id) continue; + + let result = await this._getItemActions(data.item); + data.availableActions = result.availableActions; + data.candidateItemIDs = result.candidateItemIDs; + for (let action of data.availableActions) { + actionDisplay.set(action, await this._getActionDisplay(action)); + } + if (data.availableActions.length) data.selectedActionIndex = 0; + } + }, + + async _getItemActions(item) { + let availableActions = [ + { type: ACTION_CHOOSE }, + { type: ACTION_SKIP }, + ]; + let candidateItemIDs = []; + let targetLibraryID = targetCollection.libraryID; + let collectionID = targetCollection.objectType === 'collection' + ? targetCollection.id + : null; + + // Item already in target library, add to library. + if (item.id && item.libraryID === targetLibraryID) { + if (collectionID && !item.inCollection(collectionID)) { + availableActions.unshift({ type: ACTION_ADD_TO_COLLECTION }); + return { availableActions, candidateItemIDs }; + } + return { availableActions: [], candidateItemIDs }; + } + + let linkedItem = item.id + ? await item.getLinkedItem(targetLibraryID, true) + : null; + // Exists in the target library, as a linked item, so relink. + if (linkedItem) { + candidateItemIDs.push(linkedItem.id); + availableActions.unshift({ type: ACTION_RELINK_TO_EXISTING, item: linkedItem }); + return { availableActions, candidateItemIDs }; + } + + let libraryIDs = Zotero.Libraries.getAll() + .filter(library => library.libraryType !== 'feed') + .map(library => library.libraryID); + let duplicates = new Zotero.Duplicates(libraryIDs); + candidateItemIDs = await duplicates.findDuplicatesOf(item); + let candidateItems = await Zotero.Items.getAsync(candidateItemIDs); + let targetMatch = candidateItems.find(candidate => candidate.libraryID === targetLibraryID); + + // Exists in the target library as a duplicate item, so offer to relink + if (targetMatch) { + availableActions.unshift({ + type: ACTION_RELINK_TO_EXISTING, + item: targetMatch, + }); + return { availableActions, candidateItemIDs }; + } + + // If it doesn't exist in the target library, we offer to copy from another library + // add from the document, or choose a different item in the item selector. + + // Offer at most one copy source per library. The current linked item is the + // preferred source for its library even if another candidate also exists there. + let copySources = item.id ? [item] : []; + let copySourceLibraryIDs = new Set(copySources.map(source => source.libraryID)); + for (let candidate of candidateItems) { + if (candidate.libraryID !== targetLibraryID + && !copySourceLibraryIDs.has(candidate.libraryID)) { + copySources.push(candidate); + copySourceLibraryIDs.add(candidate.libraryID); + } + } + availableActions.unshift(...copySources.map(source => ({ + type: ACTION_COPY_FROM_OTHER_LIBRARY, + item: source, + }))); + + if (!item.id) { + availableActions.splice(-2, 0, { type: ACTION_ADD_FROM_DOCUMENT }); + } + else { + candidateItemIDs.push(item.id); + } + return { availableActions, candidateItemIDs }; + }, + + async _setItemTreeItems() { + let rows = [...itemData.entries()] + .filter(([, data]) => data.availableActions.length) + .map(([itemID, data]) => new CitationExplorerItemTreeRow(data.item, 0, false, { + // The wizard only needs citation presence for the Uncited badge, while Citation + // Explorer rows store actual citation indexes for selection highlighting. + citedIn: data.isCited ? [0] : [], + cslItemID: data.item.id ? null : itemID, + })); + + await itemTree.clearItemsPaneMessage(); + await itemTree.setItems(rows); + if (!rows.length) { + await itemTree.setItemsPaneMessage( + await document.l10n.formatValue('integration-citationExplorerWizard-status-no-changes') + ); + } + }, + + _openActionMenuForFocusedRow() { + let index = itemTree.selection.focused; + let row = itemTree.getRow(index); + if (!row || row instanceof LibraryItemTreeRow) return false; + + let rowElement = document.getElementById(`${itemTree.id}-row-${index}`); + let menulist = rowElement?.querySelector('.item-action-select'); + if (!menulist) return false; + menulist.open = true; + // Focusing before opening can rerender the virtualized row and discard the control. + menulist.focus(); + return true; + }, + + async _getActionDisplay(action) { + let l10nID; + let l10nArgs; + let icon; + switch (action.type) { + case ACTION_ADD_TO_COLLECTION: + case ACTION_ADD_FROM_DOCUMENT: + l10nID = 'integration-citationExplorerWizard-action-add-to-target'; + l10nArgs = { target: targetCollection.name }; + icon = targetCollection.treeViewImage; + break; + + case ACTION_RELINK_TO_EXISTING: + l10nID = 'integration-citationExplorerWizard-action-relink'; + l10nArgs = { title: action.item.getDisplayTitle() }; + icon = targetCollection.treeViewImage; + break; + + case ACTION_COPY_FROM_OTHER_LIBRARY: + l10nID = 'integration-citationExplorerWizard-action-copy'; + l10nArgs = { library: action.item.library.name }; + icon = action.item.library.treeViewImage; + break; + + case ACTION_CHOOSE: + l10nID = 'integration-citationExplorerWizard-action-choose'; + break; + + case ACTION_SKIP: + l10nID = 'integration-citationExplorerWizard-action-skip'; + icon = 'chrome://zotero/skin/16/universal/cross.svg'; + break; + + default: + throw new Error(`Unknown item action '${action.type}'`); + } + return { + label: await document.l10n.formatValue(l10nID, l10nArgs), + icon, + }; + }, + + _renderActionCell(row, column, doc) { + let cell = doc.createElement('span'); + cell.className = `cell ${column.className}`; + if (row instanceof LibraryItemTreeRow) return cell; + + let data = itemData.get(row.id); + let selectedAction = this._getSelectedAction(row.id); + let select = doc.createXULElement('menulist'); + select.className = 'item-action-select'; + select.setAttribute('native', 'true'); + select.setAttribute('tabindex', '-1'); + select.classList.toggle('skip-selected', selectedAction.type === ACTION_SKIP); + let popup = doc.createXULElement('menupopup'); + for (let [actionIndex, action] of data.availableActions.entries()) { + let { label, icon } = actionDisplay.get(action); + let option = doc.createXULElement('menuitem'); + option.value = String(actionIndex); + option.setAttribute('label', label); + if (icon) { + option.setAttribute('image', icon); + option.classList.add('menuitem-iconic'); + } + option.classList.toggle('skip-action', action.type === ACTION_SKIP); + option.addEventListener('command', async (event) => { + event.stopPropagation(); + await this._onActionChanged(row.id, actionIndex); + }); + popup.appendChild(option); + } + popup.addEventListener('popuphidden', () => { + if (!isChoosingItem) itemTree.tree.focus(); + }); + select.appendChild(popup); + select.value = String(data.selectedActionIndex); + select.addEventListener('mousedown', event => event.stopPropagation()); + select.addEventListener('mouseup', event => event.stopPropagation()); + select.addEventListener('keydown', event => event.stopPropagation()); + cell.appendChild(select); + + if (selectedAction.type === ACTION_CHOOSE) { + let chooseButton = doc.createElement('button'); + chooseButton.className = 'choose-item-button'; + chooseButton.tabIndex = -1; + doc.l10n.setAttributes( + chooseButton, + 'integration-citationExplorerWizard-choose-button' + ); + chooseButton.addEventListener('mousedown', event => event.stopPropagation()); + chooseButton.addEventListener('mouseup', event => event.stopPropagation()); + chooseButton.addEventListener('click', async (event) => { + event.stopPropagation(); + event.preventDefault(); + await this._chooseItem(row.id); + this._invalidateItemRow(row.id); + this._updateCanFinish(); + itemTree.tree.focus(); + }); + cell.appendChild(chooseButton); + } + return cell; + }, + + async _onActionChanged(itemID, selectedActionIndex) { + let data = itemData.get(itemID); + data.selectedActionIndex = selectedActionIndex; + this._updateCanFinish(); + if (this._getSelectedAction(itemID).type === ACTION_CHOOSE) { + await this._chooseItem(itemID); + } + this._invalidateItemRow(itemID); + this._updateCanFinish(); + itemTree.tree.focus(); + }, + + async _chooseItem(itemID) { + if (isChoosingItem) return; + isChoosingItem = true; + try { + let data = itemData.get(itemID); + let chooserIO = { + dataIn: null, + dataOut: null, + itemIDs: data.candidateItemIDs.length ? data.candidateItemIDs : undefined, + multiSelect: false, + onlyRegularItems: true, + deferred: Zotero.Promise.defer(), + }; + chooserIO.wrappedJSObject = chooserIO; + let chooserWindow = window.openDialog( + 'chrome://zotero/content/selectItemsDialog.xhtml', + '', + 'chrome,dialog=yes,centerscreen,resizable=yes', + chooserIO + ); + Zotero.Utilities.Internal.activate(chooserWindow); + await chooserIO.deferred.promise; + if (!chooserIO.dataOut?.length) return; + + let chosenItem = await Zotero.Items.getAsync(chooserIO.dataOut[0]); + let actionType = chosenItem.libraryID === targetCollection.libraryID + ? ACTION_RELINK_TO_EXISTING + : ACTION_COPY_FROM_OTHER_LIBRARY; + + // Keep only one manually chosen action. Candidate actions can be identified + // by candidateItemIDs and remain available when another item is chosen. + let extraActionIndex = data.availableActions.findIndex((action) => { + return [ACTION_RELINK_TO_EXISTING, ACTION_COPY_FROM_OTHER_LIBRARY].includes(action.type) + && !data.candidateItemIDs.includes(action.item.id); + }); + if (extraActionIndex !== -1) { + data.availableActions.splice(extraActionIndex, 1); + } + + let selectedActionIndex = data.availableActions.findIndex((action) => { + return action.type === actionType && action.item?.id === chosenItem.id; + }); + if (selectedActionIndex === -1) { + let chooseActionIndex = data.availableActions.findIndex( + action => action.type === ACTION_CHOOSE + ); + selectedActionIndex = chooseActionIndex; + let action = { + type: actionType, + item: chosenItem, + }; + data.availableActions.splice(selectedActionIndex, 0, action); + actionDisplay.set(action, await this._getActionDisplay(action)); + } + data.selectedActionIndex = selectedActionIndex; + } + finally { + isChoosingItem = false; + } + }, + + _invalidateItemRow(itemID) { + let index = itemTree.getRowIndexByID(itemID); + if (index !== false) itemTree.tree.invalidateRow(index); + }, + + _getSelectedAction(itemID) { + let data = itemData.get(itemID); + return data.availableActions[data.selectedActionIndex] || null; + }, + + _updateCanFinish() { + let canFinish = [...itemData.entries()] + .filter(([, data]) => data.availableActions.length) + .every(([itemID]) => { + let action = this._getSelectedAction(itemID); + return action && action.type !== ACTION_CHOOSE; + }); + wizard.canAdvance = canFinish; + wizard.getButton('finish').disabled = !canFinish; + }, + + async onFinish(event) { + event.preventDefault(); + if (!wizard.canAdvance || isApplying) return; + + isApplying = true; + wizard.canAdvance = false; + wizard.canRewind = false; + wizard.getButton('finish').disabled = true; + wizard.getButton('cancel').disabled = true; + await itemTree.setItemsPaneMessage( + await document.l10n.formatValue('integration-citationExplorerWizard-status-applying'), + true + ); + + try { + let replacements = []; + await Zotero.DB.executeTransaction(async () => { + for (let [itemID, data] of itemData) { + if (!data.availableActions.length) continue; + let action = this._getSelectedAction(itemID); + if (action.type === ACTION_SKIP) continue; + + let destinationItem = await this._applyAction(data.item, action); + if (action.type !== ACTION_ADD_TO_COLLECTION && destinationItem.id != itemID) { + replacements.push({ oldItemID: itemID, item: destinationItem }); + } + } + }); + + if (replacements.length) io.relinkItems(replacements); + io.completed = true; + isApplying = false; + window.close(); + } + catch (e) { + Zotero.logError(e); + isApplying = false; + itemTree._locked = false; + await itemTree.setItemsPaneMessage( + await document.l10n.formatValue('integration-citationExplorerWizard-status-apply-error') + ); + wizard.getButton('cancel').disabled = false; + wizard.canRewind = true; + this._updateCanFinish(); + } + }, + + async _applyAction(documentItem, action) { + Zotero.DB.requireTransaction(); + let destinationItem; + switch (action.type) { + case ACTION_ADD_TO_COLLECTION: + destinationItem = documentItem; + break; + + case ACTION_RELINK_TO_EXISTING: + destinationItem = action.item; + break; + + case ACTION_COPY_FROM_OTHER_LIBRARY: + destinationItem = await Zotero.Items.copyToLibrary( + action.item, + targetCollection.libraryID + ); + break; + + case ACTION_ADD_FROM_DOCUMENT: + destinationItem = documentItem.clone(targetCollection.libraryID); + await destinationItem.save({ skipSelect: true }); + break; + + default: + throw new Error(`Cannot apply item action '${action.type}'`); + } + + if (!destinationItem) { + throw new Error(`Could not create destination item in library ${targetCollection.libraryID}`); + } + + // Copy to collection if target is a collection + if (targetCollection.objectType === 'collection' + && !destinationItem.inCollection(targetCollection.id)) { + destinationItem.addToCollection(targetCollection.id); + await destinationItem.save({ skipSelect: true }); + } + return destinationItem; + }, +}; diff --git a/chrome/content/zotero/integration/citationExplorerWizard.xhtml b/chrome/content/zotero/integration/citationExplorerWizard.xhtml new file mode 100644 index 0000000000..56e1579326 --- /dev/null +++ b/chrome/content/zotero/integration/citationExplorerWizard.xhtml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index 4bbe9be9e6..7c78ff2be6 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -27,6 +27,7 @@ const { noop, getDragTargetOrient } = require("components/utils"); const PropTypes = require("prop-types"); const React = require('react'); const ReactDOM = require('react-dom'); +const cx = require('classnames'); const LibraryTree = require('./libraryTree'); const VirtualizedTable = require('components/virtualized-table'); const { VirtualizedTree, formatColumnName } = VirtualizedTable; @@ -922,6 +923,7 @@ var ItemTree = class ItemTree extends LibraryTree { regularOnly: false, multiSelect: true, shouldListenForNotifications: true, + autoSelect: true, columns: COLUMNS, onContextMenu: noop, onActivate: noop, @@ -939,6 +941,8 @@ var ItemTree = class ItemTree extends LibraryTree { regularOnly: PropTypes.bool, multiSelect: PropTypes.bool, shouldListenForNotifications: PropTypes.bool, + // Whether to automatically select the focused row when the tree is focused via tab key + autoSelect: PropTypes.bool, columns: PropTypes.array, onSelectionChange: PropTypes.func, onContextMenu: PropTypes.func, @@ -1408,7 +1412,7 @@ var ItemTree = class ItemTree extends LibraryTree { * Select the first row when the tree is tabbed into with no selection. */ handleKeyUp = (event) => { - if (!Zotero.locked && event.code === 'Tab' && this.selection.count == 0) { + if (!Zotero.locked && this.props.autoSelect && (event.code === 'Tab' || event.key.includes("Arrow")) && this.selection.count == 0) { this.selection.select(this.selection.focused); } }; @@ -1446,8 +1450,10 @@ var ItemTree = class ItemTree extends LibraryTree { renderItem: this._renderItem.bind(this), hide: showMessage, key: "virtualized-table", + className: cx([{ "regular-only": this.props.regularOnly }]), showHeader: true, + alternateRowColors: true, columns: this._getColumns(), onColumnPickerMenu: this._displayColumnPickerMenu.bind(this), onColumnSort: this.isSortable ? this._handleColumnSort : null, @@ -2036,20 +2042,22 @@ var ItemTree = class ItemTree extends LibraryTree { moreItems.push(columnMenuitemElements[column.dataKey]); } } + + if (moreItems.length) { + // Sort fields and move to submenu + var collation = Zotero.getLocaleCollation(); + moreItems.sort(function (a, b) { + return collation.compareString(1, a.getAttribute('label'), b.getAttribute('label')); + }); + moreItems.forEach(function (elem) { + moreMenuPopup.appendChild(menupopup.removeChild(elem)); + }); - // Sort fields and move to submenu - var collation = Zotero.getLocaleCollation(); - moreItems.sort(function (a, b) { - return collation.compareString(1, a.getAttribute('label'), b.getAttribute('label')); - }); - moreItems.forEach(function (elem) { - moreMenuPopup.appendChild(menupopup.removeChild(elem)); - }); - - let sep = document.createXULElement('menuseparator'); - menupopup.appendChild(sep); - moreMenu.appendChild(moreMenuPopup); - menupopup.appendChild(moreMenu); + let sep = document.createXULElement('menuseparator'); + menupopup.appendChild(sep); + moreMenu.appendChild(moreMenuPopup); + menupopup.appendChild(moreMenu); + } } catch (e) { Zotero.logError(e); diff --git a/chrome/content/zotero/selectItemsDialog.js b/chrome/content/zotero/selectItemsDialog.js index ef93302d0d..9517c1a370 100644 --- a/chrome/content/zotero/selectItemsDialog.js +++ b/chrome/content/zotero/selectItemsDialog.js @@ -25,11 +25,14 @@ import CollectionTree from 'zotero/collectionTree'; import CollectionViewItemTree from 'zotero/collectionViewItemTree'; +const { getCSSIcon } = require('components/icons'); +const { COLUMNS } = require('zotero/itemTreeColumns'); var itemsView; var collectionsView; var loaded; var io; +var suggestedItemsTempTable; const isSelectItemsDialog = !!document.querySelector('#zotero-select-items-dialog'); const isEditBibliographyDialog = !!document.querySelector('#zotero-edit-bibliography-dialog'); const isAddEditItemsDialog = !!document.querySelector('#zotero-add-citation-dialog'); @@ -62,9 +65,82 @@ var doLoad = async function () { if(io.wrappedJSObject) io = io.wrappedJSObject; if(io.addBorder) document.getElementsByTagName("dialog")[0].style.border = "1px solid black"; if(io.singleSelection) document.getElementById("zotero-items-tree").setAttribute("seltype", "single"); + + let columns = COLUMNS; + let customRows = []; + if (io.itemIDs) { + let items = await Zotero.Items.getAsync(io.itemIDs); + let itemIDs = items.map(item => item.id); + let libraryIDs = new Set(items.map(item => item.libraryID)); + suggestedItemsTempTable = await Zotero.Search.idsToTempTable(itemIDs, { idColumn: 'id' }); + let search = new Zotero.Search(); + search.name = Zotero.getString('select-items-suggested-items'); + // Called by CollectionViewItemTree when the custom row is loaded or filtered + let getSuggestedItems = async function () { + let ids = []; + for (let libraryID of libraryIDs) { + let filteredSearch = new Zotero.Search(); + filteredSearch.libraryID = libraryID; + filteredSearch.addCondition('tempTable', 'is', suggestedItemsTempTable); + if (this.searchText) { + let condition = 'quicksearch-' + + (this.searchMode || Zotero.Prefs.get('search.quicksearch-mode')); + filteredSearch.addCondition(condition, 'contains', this.searchText); + } + for (let tag of this.tags) { + filteredSearch.addCondition('tag', 'is', tag); + } + ids.push(...await filteredSearch.search()); + } + return Zotero.Items.getAsync(ids); + }; + customRows.push({ + id: 'suggested-items', + type: 'suggestedItems', + ref: search, + properties: { + iconName: 'duplicates', + // This cross-library row has no libraryID + isWithinGroup: () => false, + getItems: getSuggestedItems + } + }); + if ([...libraryIDs].some(id => id != Zotero.Libraries.userLibraryID)) { + let libraryColumn = { + dataKey: 'library', + label: 'select-items-library-column', + showInColumnPicker: true, + defaultIn: ['suggestedItems'], + enabledIn: ['suggestedItems'], + width: '180', + minWidth: 120, + zoteroPersist: ['width', 'hidden', 'sortDirection'], + renderCell(index, libraryName, column, _isFirstColumn, doc) { + let item = this.getRow(index).ref; + let library = Zotero.Libraries.get(item.libraryID); + let cell = doc.createElement('span'); + cell.className = `cell ${column.className}`; + let icon = getCSSIcon(library.libraryType === 'group' ? 'library-group' : 'library'); + icon.classList.add('cell-icon', 'item-icon', 'icon-item-type'); + let text = doc.createElement('span'); + text.className = 'cell-text'; + text.textContent = libraryName; + cell.append(icon, text); + return cell; + } + }; + let attachmentColumnIndex = COLUMNS.findIndex(column => column.dataKey === 'hasAttachment'); + columns = [ + ...COLUMNS.slice(0, attachmentColumnIndex), + libraryColumn, + ...COLUMNS.slice(attachmentColumnIndex) + ]; + } + } itemsView = await CollectionViewItemTree.init(document.getElementById('zotero-items-tree'), { onSelectionChange: () => { + updateShowInZoteroButton(); if (isEditBibliographyDialog) { Zotero_Bibliography_Dialog.treeItemSelected(); } @@ -78,8 +154,15 @@ var doLoad = async function () { id: io.itemTreeID || "select-items-dialog", dragAndDrop: false, regularOnly: io.onlyRegularItems, + columns, columnPicker: true, multiSelect: io.multiSelect, + getExtraField: (item, field) => { + if (field === 'library' && item.libraryID) { + return Zotero.Libraries.get(item.libraryID).name; + } + return undefined; + }, emptyMessage: Zotero.getString('pane.items.loading') }); itemsView.setItemsPaneMessage(Zotero.getString('pane.items.loading')); @@ -88,19 +171,34 @@ var doLoad = async function () { const hideSources = io.hideCollections || ['duplicates', 'trash', 'feeds']; collectionsView = await CollectionTree.init(document.getElementById('zotero-collections-tree'), { onSelectionChange: () => onCollectionSelected(), + customRows, filterLibraryIDs, hideSources }); await collectionsView.makeVisible(); - if (io.select) { + if (io.itemIDs) { + await collectionsView.selectWait(collectionsView.getRowIndexByID('suggested-items')); + } + else if (io.select) { await collectionsView.selectItem(io.select); } Zotero.updateQuickSearchBox(document); document.addEventListener('dialogaccept', doAccept); + if (io.itemIDs) { + let showInZoteroButton = document.querySelector("dialog button[dlgtype='extra1']"); + document.l10n.setAttributes(showInZoteroButton, 'select-items-show-in-zotero'); + showInZoteroButton.addEventListener('click', async () => { + await Zotero.Utilities.Internal.showInLibrary(itemsView.getSelectedItems()); + }); + updateShowInZoteroButton(); + } + else if (!io.extraButtons?.some(button => button.type === 'extra1')) { + document.querySelector("dialog button[dlgtype='extra1']")?.setAttribute('hidden', true); + } if (isSelectItemsDialog) { // Set proper tab order. It is only needed in selectItemsDialog -- other dialogs' focus order is correct @@ -126,13 +224,25 @@ var doLoad = async function () { loaded = true; }; -function doUnload() -{ - collectionsView.unregister(); - if(itemsView) - itemsView.unregister(); - - io.deferred && io.deferred.resolve(); +function doUnload() { + collectionsView?.unregister(); + itemsView?.unregister(); + if (suggestedItemsTempTable) { + Zotero.DB.queryAsync( + `DROP TABLE IF EXISTS ${suggestedItemsTempTable}`, + false, + { noCache: true } + ).catch(e => Zotero.logError(e)); + } + io?.deferred?.resolve(); +} + +function updateShowInZoteroButton() { + let button = document.querySelector("dialog button[dlgtype='extra1']"); + if (!button || !io?.itemIDs || !collectionsView) return; + let row = collectionsView.getRow(collectionsView.selection.focused); + button.hidden = row?.type !== 'suggestedItems'; + button.disabled = button.hidden || !itemsView?.getSelectedItems().length; } var onCollectionSelected = async function () { @@ -142,21 +252,31 @@ var onCollectionSelected = async function () { if (itemsView && itemsView.collectionTreeRows[0]?.id == collectionTreeRow.id) { return; } + document.getElementById('zotero-tb-search').onCollectionSelected(); collectionTreeRow.setSearch(''); - Zotero.Prefs.set('lastViewedFolder', collectionTreeRow.id); + if (collectionTreeRow.type !== 'suggestedItems') { + Zotero.Prefs.set('lastViewedFolder', collectionTreeRow.id); + } itemsView.setItemsPaneMessage(Zotero.getString('pane.items.loading')); // Load library data if necessary - var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID); - if (!library.getDataLoaded('item')) { - Zotero.debug("Waiting for items to load for library " + library.libraryID); - await library.waitForDataLoad('item'); + if (collectionTreeRow.type !== 'suggestedItems') { + var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID); + if (!library.getDataLoaded('item')) { + Zotero.debug("Waiting for items to load for library " + library.libraryID); + await library.waitForDataLoad('item'); + } + } + // Prevent a race-condition if rapidly clicking on different libraries without loaded + // item data + if (collectionsView.getRow(collectionsView.selection.focused)?.id !== collectionTreeRow.id) { + return; } - await itemsView.changeCollectionTreeRows([collectionTreeRow]); itemsView.clearItemsPaneMessage(); + updateShowInZoteroButton(); }; function onSearch() diff --git a/chrome/content/zotero/selectItemsDialog.xhtml b/chrome/content/zotero/selectItemsDialog.xhtml index 05c61c937c..ffd46dca44 100644 --- a/chrome/content/zotero/selectItemsDialog.xhtml +++ b/chrome/content/zotero/selectItemsDialog.xhtml @@ -46,7 +46,7 @@ diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js index dd35b89a53..6cde975a68 100644 --- a/chrome/content/zotero/xpcom/data/items.js +++ b/chrome/content/zotero/xpcom/data/items.js @@ -967,6 +967,128 @@ Zotero.Items = function () { }; + /** + * Copy an item to another library, including enabled child items + * + * Requires a transaction + * + * @param {Zotero.Item} item + * @param {Integer} targetLibraryID + * @param {Object} [options] + * @param {Boolean} [options.tags] + * @param {Boolean} [options.childNotes] + * @param {Boolean} [options.childLinks] + * @param {Boolean} [options.childFileAttachments] + * @param {Boolean} [options.annotations] + * @return {Promise} The copied or existing linked item, or false if an + * attachment was skipped + */ + this.copyToLibrary = async function (item, targetLibraryID, options = {}) { + Zotero.DB.requireTransaction(); + + if (!item.isRegularItem() && !item.isNote() && !item.isAttachment()) { + throw new Error("Only regular items, notes, and attachments can be copied between libraries"); + } + + // Check if there's already a copy of this item in the library + let linkedItem = await item.getLinkedItem(targetLibraryID, true); + if (linkedItem) { + // TODO: Support reconciling tags, related items, attachments, etc. + return linkedItem; + } + + let targetLibrary = Zotero.Libraries.get(targetLibraryID); + let { + tags: copyTags = Zotero.Prefs.get('groups.copyTags'), + childNotes: copyChildNotes = Zotero.Prefs.get('groups.copyChildNotes'), + childLinks: copyChildLinks = Zotero.Prefs.get('groups.copyChildLinks'), + childFileAttachments: copyChildFileAttachments + = Zotero.Prefs.get('groups.copyChildFileAttachments'), + annotations: copyAnnotations = Zotero.Prefs.get('groups.copyAnnotations'), + } = options; + + // Standalone attachment + if (item.isAttachment()) { + // Skip linked files + if (item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { + Zotero.debug("Skipping standalone linked file attachment on copy"); + return false; + } + if (!targetLibrary.filesEditable) { + Zotero.debug("Skipping standalone file attachment on copy"); + return false; + } + let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(item, targetLibraryID); + if (copyAnnotations) { + await this.copyChildItems(item, newAttachment); + } + return newAttachment; + } + + // Create new clone item in target library + let newItem = item.clone(targetLibraryID, { skipTags: !copyTags }); + await newItem.save({ skipSelect: true }); + + // Record link + await newItem.addLinkedItem(item); + + if (item.isNote()) { + if (targetLibrary.filesEditable) { + await Zotero.Notes.copyEmbeddedImages(item, newItem); + } + return newItem; + } + + // For regular items, add child items if prefs and permissions allow + + // Child notes + if (copyChildNotes) { + for (let note of Zotero.Items.get(item.getNotes())) { + let newNote = note.clone(targetLibraryID, { skipTags: !copyTags }); + newNote.parentID = newItem.id; + await newNote.save({ skipSelect: true }); + if (targetLibrary.filesEditable) { + await Zotero.Notes.copyEmbeddedImages(note, newNote); + } + await newNote.addLinkedItem(note); + } + } + + // Child attachments + if (copyChildLinks || copyChildFileAttachments) { + for (let attachment of Zotero.Items.get(item.getAttachments())) { + let linkMode = attachment.attachmentLinkMode; + + // Skip linked files + if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { + Zotero.debug("Skipping child linked file attachment on copy"); + continue; + } + + // Skip imported files if we don't have pref and permissions + if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) { + if (!copyChildLinks) { + Zotero.debug("Skipping child link attachment on copy"); + continue; + } + } + else if (!copyChildFileAttachments || !targetLibrary.filesEditable) { + Zotero.debug("Skipping child file attachment on copy"); + continue; + } + let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary( + attachment, targetLibraryID, newItem.id + ); + if (copyAnnotations) { + await this.copyChildItems(attachment, newAttachment); + } + } + } + + return newItem; + }; + + /** * Copy child items from one item to another (e.g., in another library) * diff --git a/chrome/content/zotero/xpcom/duplicates.js b/chrome/content/zotero/xpcom/duplicates.js index de8eaca05c..bb3bc69e85 100644 --- a/chrome/content/zotero/xpcom/duplicates.js +++ b/chrome/content/zotero/xpcom/duplicates.js @@ -23,21 +23,77 @@ ***** END LICENSE BLOCK ***** */ -Zotero.Duplicates = function (libraryID) { - if (typeof libraryID == 'undefined') { - throw ("libraryID not provided in Zotero.Duplicates constructor"); +Zotero.Duplicates = function (libraryIDs) { + if (typeof libraryIDs == 'undefined') { + throw ("libraryID(s) not provided in Zotero.Duplicates constructor"); } - if (!libraryID) { - libraryID = Zotero.Libraries.userLibraryID; + if (!libraryIDs) { + libraryIDs = Zotero.Libraries.userLibraryID; } - this._libraryID = libraryID; + if (!Array.isArray(libraryIDs)) { + libraryIDs = [libraryIDs]; + } + + if (!libraryIDs.length) { + throw ("libraryIDs must contain at least one libraryID"); + } + + this._libraryIDs = libraryIDs; } Zotero.Duplicates.prototype.__defineGetter__('name', function () { return Zotero.getString('pane.collections.duplicate'); }); -Zotero.Duplicates.prototype.__defineGetter__('libraryID', function () { return this._libraryID; }); +Zotero.Duplicates.prototype.__defineGetter__('libraryIDs', function () { return this._libraryIDs; }); +Zotero.Duplicates.prototype.__defineGetter__('libraryID', function () { + if (this._libraryIDs.length > 1) { + throw ("libraryID is not available when Zotero.Duplicates includes multiple libraries"); + } + return this._libraryIDs[0]; +}); + +Zotero.Duplicates.prototype._getLibraryCondition = function (field = 'libraryID') { + if (this._libraryIDs.length == 1) { + return { + sql: `${field}=?`, + params: [this._libraryIDs[0]] + }; + } + + return { + sql: `${field} IN (${this._libraryIDs.map(() => '?').join(', ')})`, + params: this._libraryIDs.slice() + }; +}; + +Zotero.Duplicates.normalizeString = function (str) { + // Make sure we have a string and not an integer + str = str + ""; + + if (str === "") { + return ""; + } + + str = Zotero.Utilities.removeDiacritics(str) + .replace(/[ !-/:-@[-`{-~]+/g, ' ') // Convert (ASCII) punctuation to spaces + .trim() + .toLowerCase(); + + return str; +}; + +Zotero.Duplicates._sortByValue = function (a, b) { + if ((a.value === null && b.value !== null) + || (a.value === undefined && b.value !== undefined) + || a.value < b.value) { + return -1; + } + + if (a.value === b.value) return 0; + + return 1; +}; /** * Get duplicates, populate a temporary table, and return a search based @@ -74,7 +130,9 @@ Zotero.Duplicates.prototype.getSearchObject = async function () { } var s = new Zotero.Search; - s.libraryID = this._libraryID; + if (this._libraryIDs.length == 1) { + s.libraryID = this.libraryID; + } s.addCondition('tempTable', 'is', table); return s; }; @@ -98,327 +156,417 @@ Zotero.Duplicates.prototype._getObjectFromID = function (id) { } -Zotero.Duplicates.prototype._findDuplicates = async function () { - Zotero.debug("Finding duplicates"); +/** + * The comparison function for title-based duplicate matching. + * + * Reads metadata directly from the row objects (which are enriched with + * doi/isbn/year/creators in _loadCaches and findDuplicatesOf), so it has + * no dependency on instance caches. + * + * Assumes rows are sorted by normalized title. Returns: + * -1: not a match, stop comparing (title mismatch in sorted order) + * 0: not a match, but keep looking (title matches but metadata conflicts) + * 1: match + * + * @param {Object} a - Enriched row {itemID, value, doi?, isbn?, year?, creators?} + * @param {Object} b - Enriched row {itemID, value, doi?, isbn?, year?, creators?} + * @return {Integer} + */ +Zotero.Duplicates._compareRows = function (a, b) { + var aTitle = a.value; + var bTitle = b.value; - var start = Date.now(); - - var self = this; - - this._sets = new Zotero.DisjointSetForest; - var sets = this._sets; - - function normalizeString(str) { - // Make sure we have a string and not an integer - str = str + ""; - - if (str === "") { - return ""; - } - - str = Zotero.Utilities.removeDiacritics(str) - .replace(/[ !-/:-@[-`{-~]+/g, ' ') // Convert (ASCII) punctuation to spaces - .trim() - .toLowerCase(); - - return str; + // If we stripped one of the strings completely, we can't compare them + if (!aTitle || !bTitle) { + return -1; } - function sortByValue(a, b) { - if((a.value === null && b.value !== null) - || (a.value === undefined && b.value !== undefined) - || a.value < b.value) { - return -1; - } - - if(a.value === b.value) return 0; - + if (aTitle !== bTitle) { + return -1; // everything is sorted by title, so if this mismatches, everything following will too + } + + // If both items have a DOI and they don't match, it's not a dupe + if (a.doi && b.doi && a.doi != b.doi) { + return 0; + } + + // If both items have an ISBN and they don't match, it's not a dupe + if (a.isbn && b.isbn && a.isbn != b.isbn) { + return 0; + } + + // If both items have a year and they're off by more than one, it's not a dupe + if (a.year && b.year && Math.abs(a.year - b.year) > 1) { + return 0; + } + + // Match if neither has creators + if (!a.creators && !b.creators) { return 1; } - /** - * @param {Function} compareRows Comparison function, if not exact match - * @param {Boolean} reprocessMatches Compare every row against every other, - * without skipping ahead to the last match. - * This is necessary for multi-dimensional - * matches such as title + at least one creator. - * Without it, only one set of matches would be - * found per matching title, since items with - * different creators wouldn't match the first - * set and the next start row would be a - * different title. - */ - function processRows(rows, compareRows, reprocessMatches) { - if (!rows.length) { - return; - } + // One has creators and the other doesn't — not a dupe + if (!a.creators || !b.creators) { + return 0; + } + + // Check for at least one match on last name + first initial of first name + for (let i = 0; i < a.creators.length; i++) { + let aCreator = a.creators[i]; + let aLastName = aCreator.lastName; + let aFirstInitial = aCreator.firstInitial || ""; - for (var i = 0, len = rows.length; i < len; i++) { - var j = i + 1, lastMatch = false; - while (j < len) { - if (compareRows) { - var match = compareRows(rows[i], rows[j]); - // Not a match, and don't try any more with this i value - if (match == -1) { - break; - } - // Not a match, but keep looking - if (match == 0) { - j++; - continue; - } - } - // If no comparison function, check for exact match - else { - if (!rows[i].value || !rows[j].value - || (rows[i].value !== rows[j].value) - ) { - break; - } - } - - sets.union( - self._getObjectFromID(rows[i].itemID), - self._getObjectFromID(rows[j].itemID) - ); - - lastMatch = j; - j++; - } - if (!reprocessMatches && lastMatch) { - i = lastMatch; + for (let j = 0; j < b.creators.length; j++) { + let bCreator = b.creators[j]; + let bLastName = bCreator.lastName; + let bFirstInitial = bCreator.firstInitial || ""; + + if (aLastName === bLastName && aFirstInitial === bFirstInitial) { + return 1; } } } + return 0; +}; + + +/** + * Check if a target row has duplicates among the given rows. + * + * This is the inner loop of processRows, extracted so it can be reused + * by findDuplicatesOf. Rows must be sorted by value. + * + * @param {Object} targetRow - Row with .itemID and .value + * @param {Object[]} rows - Sorted rows to compare against + * @param {Function} [compareRows] - Comparison function returning -1/0/1. + * If omitted, checks for exact value match. + * @return {Object[]} - Array of matching rows + */ +Zotero.Duplicates._checkIfDuplicate = function (targetRow, rows, compareRows) { + let matches = []; + for (let j = 0; j < rows.length; j++) { + if (compareRows) { + let match = compareRows(targetRow, rows[j]); + // Not a match, and don't try any more + if (match == -1) { + break; + } + // Not a match, but keep looking + if (match == 0) { + continue; + } + } + // If no comparison function, check for exact match + else { + if (!targetRow.value || !rows[j].value + || (targetRow.value !== rows[j].value) + ) { + break; + } + } + matches.push(rows[j]); + } + return matches; +}; + + +/** + * Load all data needed for duplicate detection from the database. + * + * Populates: + * this._isbnRows - sorted [{itemID, value}] for ISBN exact-match pass + * this._doiRows - sorted [{itemID, value}] for DOI exact-match pass + * this._titleRows - sorted enriched rows for title+creators pass: + * [{itemID, value, doi?, isbn?, year?, creators?}] + * this._itemCache - {itemID: {doi?, isbn?, year?, creators?}} — consolidated + * metadata used to enrich title rows + */ +Zotero.Duplicates.prototype._loadCaches = async function () { + var normalizeString = Zotero.Duplicates.normalizeString; + var sortByValue = Zotero.Duplicates._sortByValue; + + let libraryCondition = this._getLibraryCondition(); + + this._itemCache = {}; + var getCacheEntry = (itemID) => { + if (!this._itemCache[itemID]) this._itemCache[itemID] = {}; + return this._itemCache[itemID]; + }; + // Match books by ISBN var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " + "JOIN itemDataValues USING (valueID) " - + "WHERE libraryID=? AND itemTypeID=? AND fieldID=? " + + `WHERE ${libraryCondition.sql} AND itemTypeID=? AND fieldID=? ` + "AND itemID NOT IN (SELECT itemID FROM deletedItems)"; var rows = await Zotero.DB.queryAsync( sql, [ - this._libraryID, + ...libraryCondition.params, Zotero.ItemTypes.getID('book'), Zotero.ItemFields.getID('ISBN') ] ); - var isbnCache = {}; - if (rows.length) { - let newRows = []; - for (let i = 0; i < rows.length; i++) { - let row = rows[i]; - let newVal = Zotero.Utilities.cleanISBN('' + row.value); - if (!newVal) continue; - // Canonicalize to ISBN-13 so an ISBN-10 and its ISBN-13 equivalent match - newVal = Zotero.Utilities.toISBN13(newVal); - isbnCache[row.itemID] = newVal; - newRows.push({ - itemID: row.itemID, - value: newVal - }); - } - newRows.sort(sortByValue); - processRows(newRows); + this._isbnRows = []; + for (let row of rows) { + let cleaned = Zotero.Utilities.cleanISBN('' + row.value); + if (!cleaned) continue; + // Canonicalize to ISBN-13 so an ISBN-10 and its ISBN-13 equivalent match + cleaned = Zotero.Utilities.toISBN13(cleaned); + getCacheEntry(row.itemID).isbn = cleaned; + this._isbnRows.push({ itemID: row.itemID, value: cleaned }); } + this._isbnRows.sort(sortByValue); // DOI - var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " - + "JOIN itemDataValues USING (valueID) " - + "WHERE libraryID=? AND fieldID=? AND value LIKE ? " - + "AND itemID NOT IN (SELECT itemID FROM deletedItems)"; - var rows = await Zotero.DB.queryAsync( + sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " + + "JOIN itemDataValues USING (valueID) " + + `WHERE ${libraryCondition.sql} AND fieldID=? AND value LIKE ? ` + + "AND itemID NOT IN (SELECT itemID FROM deletedItems)"; + rows = await Zotero.DB.queryAsync( sql, [ - this._libraryID, + ...libraryCondition.params, Zotero.ItemFields.getID('DOI'), '10.%' ] ); - var doiCache = {}; - if (rows.length) { - let newRows = []; - for (let i = 0; i < rows.length; i++) { - let row = rows[i]; - // DOIs are case insensitive - let newVal = (row.value + '').trim().toUpperCase(); - doiCache[row.itemID] = newVal; - newRows.push({ - itemID: row.itemID, - value: newVal - }); - } - newRows.sort(sortByValue); - processRows(newRows); + this._doiRows = []; + for (let row of rows) { + // DOIs are case insensitive + let doi = (row.value + '').trim().toUpperCase(); + getCacheEntry(row.itemID).doi = doi; + this._doiRows.push({ itemID: row.itemID, value: doi }); } + this._doiRows.sort(sortByValue); // Get years - var dateFields = [Zotero.ItemFields.getID('date')].concat( - Zotero.ItemFields.getTypeFieldsFromBase('date') - ); - var sql = "SELECT itemID, SUBSTR(value, 1, 4) AS year FROM items " - + "JOIN itemData USING (itemID) " - + "JOIN itemDataValues USING (valueID) " - + "WHERE libraryID=? AND fieldID IN (" - + dateFields.map(() => '?').join() + ") " - + "AND SUBSTR(value, 1, 4) != '0000' " - + "AND itemID NOT IN (SELECT itemID FROM deletedItems) " - + "ORDER BY value"; - var rows = await Zotero.DB.queryAsync(sql, [this._libraryID].concat(dateFields)); - var yearCache = {}; - for (let i = 0; i < rows.length; i++) { - let row = rows[i]; - yearCache[row.itemID] = row.year; + var dateFields = [ + Zotero.ItemFields.getID('date'), + ...Zotero.ItemFields.getTypeFieldsFromBase('date') + ]; + sql = "SELECT itemID, SUBSTR(value, 1, 4) AS year FROM items " + + "JOIN itemData USING (itemID) " + + "JOIN itemDataValues USING (valueID) " + + `WHERE ${libraryCondition.sql} AND fieldID IN (` + + dateFields.map(() => '?').join() + ") " + + "AND SUBSTR(value, 1, 4) != '0000' " + + "AND itemID NOT IN (SELECT itemID FROM deletedItems) " + + "ORDER BY value"; + rows = await Zotero.DB.queryAsync(sql, [...libraryCondition.params, ...dateFields]); + for (let row of rows) { + getCacheEntry(row.itemID).year = row.year; } var itemTypeAttachment = Zotero.ItemTypes.getID('attachment'); var itemTypeNote = Zotero.ItemTypes.getID('note'); + // Get all creators and group by itemID + sql = "SELECT itemID, lastName, firstName, fieldMode FROM items " + + "JOIN itemCreators USING (itemID) " + + "JOIN creators USING (creatorID) " + + `WHERE ${libraryCondition.sql} AND itemTypeID NOT IN (${itemTypeAttachment}, ${itemTypeNote}) AND ` + + "itemID NOT IN (SELECT itemID FROM deletedItems)" + + "ORDER BY itemID, orderIndex"; + let creatorRows = await Zotero.DB.queryAsync(sql, libraryCondition.params); + for (let row of creatorRows) { + let entry = getCacheEntry(row.itemID); + if (!entry.creators) entry.creators = []; + entry.creators.push({ + lastName: normalizeString(row.lastName), + firstInitial: row.fieldMode == 0 ? normalizeString(row.firstName).charAt(0) : false + }); + } + // Match on normalized title var titleIDs = Zotero.ItemFields.getTypeFieldsFromBase('title'); titleIDs.push(Zotero.ItemFields.getID('title')); - var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " - + "JOIN itemDataValues USING (valueID) " - + "WHERE libraryID=? AND fieldID IN " - + "(" + titleIDs.join(', ') + ") " - + `AND itemTypeID NOT IN (${itemTypeAttachment}, ${itemTypeNote}) ` - + "AND itemID NOT IN (SELECT itemID FROM deletedItems)"; - var rows = await Zotero.DB.queryAsync(sql, [this._libraryID]); - if (rows.length) { - //normalize all values ahead of time - rows = rows.map(function (row) { - return { - itemID: row.itemID, - value: normalizeString(row.value) - }; - }); - //sort rows by normalized values - rows.sort(sortByValue); - - // Get all creators and separate by itemID - // - // We won't need all of these, but otherwise we would have to make processRows() - // asynchronous, which would be too slow - let creatorRowsCache = {}; - let sql = "SELECT itemID, lastName, firstName, fieldMode FROM items " - + "JOIN itemCreators USING (itemID) " - + "JOIN creators USING (creatorID) " - + `WHERE libraryID=? AND itemTypeID NOT IN (${itemTypeAttachment}, ${itemTypeNote}) AND ` - + "itemID NOT IN (SELECT itemID FROM deletedItems)" - + "ORDER BY itemID, orderIndex"; - let creatorRows = await Zotero.DB.queryAsync(sql, this._libraryID); - let lastItemID; - let itemCreators = []; - for (let i = 0; i < creatorRows.length; i++) { - let row = creatorRows[i]; - if (lastItemID && row.itemID != lastItemID) { - if (itemCreators.length) { - creatorRowsCache[lastItemID] = itemCreators; - itemCreators = []; - } - } - - lastItemID = row.itemID; - - itemCreators.push({ - lastName: normalizeString(row.lastName), - firstInitial: row.fieldMode == 0 ? normalizeString(row.firstName).charAt(0) : false - }); + sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " + + "JOIN itemDataValues USING (valueID) " + + `WHERE ${libraryCondition.sql} AND fieldID IN ` + + "(" + titleIDs.join(', ') + ") " + + `AND itemTypeID NOT IN (${itemTypeAttachment}, ${itemTypeNote}) ` + + "AND itemID NOT IN (SELECT itemID FROM deletedItems)"; + rows = await Zotero.DB.queryAsync(sql, libraryCondition.params); + // Normalize titles and enrich with metadata from the cache + this._titleRows = rows.map((row) => { + let entry = this._itemCache[row.itemID] || {}; + return { + itemID: row.itemID, + value: normalizeString(row.value), + ...entry + }; + }); + // Sort rows by normalized values + this._titleRows.sort(sortByValue); +}; + + +/** + * Process sorted rows, finding duplicates and unioning them into sets. + * + * @param {Object[]} rows - Sorted rows with .itemID and .value + * @param {Function} [compareRows] - Comparison function returning -1/0/1. + * If omitted, checks for exact value match. + * @param {Boolean} [reprocessMatches] - If true, don't skip ahead past matches. + * Needed for multi-dimensional comparisons + * (e.g. title + creators) where items with + * the same title but different creators + * must still be compared individually. + */ +Zotero.Duplicates.prototype._processRows = function (rows, compareRows, reprocessMatches) { + for (let i = 0, len = rows.length; i < len; i++) { + let matches = Zotero.Duplicates._checkIfDuplicate( + rows[i], rows.slice(i + 1), compareRows + ); + for (let m of matches) { + this._sets.union( + this._getObjectFromID(rows[i].itemID), + this._getObjectFromID(m.itemID) + ); } - // Add final item creators - if (itemCreators.length) { - creatorRowsCache[lastItemID] = itemCreators; + if (!reprocessMatches && matches.length) { + i += matches.length; } - - processRows(rows, function (a, b) { - var aTitle = a.value; - var bTitle = b.value; - - // If we stripped one of the strings completely, we can't compare them - if(!aTitle || !bTitle) { - return -1; - } - - if (aTitle !== bTitle) { - return -1; //everything is sorted by title, so if this mismatches, everything following will too - } - - // If both items have a DOI and they don't match, it's not a dupe - if (typeof doiCache[a.itemID] != 'undefined' - && typeof doiCache[b.itemID] != 'undefined' - && doiCache[a.itemID] != doiCache[b.itemID]) { - return 0; - } - - // If both items have an ISBN and they don't match, it's not a dupe - if (typeof isbnCache[a.itemID] != 'undefined' - && typeof isbnCache[b.itemID] != 'undefined' - && isbnCache[a.itemID] != isbnCache[b.itemID]) { - return 0; - } - - // If both items have a year and they're off by more than one, it's not a dupe - if (typeof yearCache[a.itemID] != 'undefined' - && typeof yearCache[b.itemID] != 'undefined' - && Math.abs(yearCache[a.itemID] - yearCache[b.itemID]) > 1) { - return 0; - } - - // Check for at least one match on last name + first initial of first name - var aCreatorRows, bCreatorRows; - if (typeof creatorRowsCache[a.itemID] != 'undefined') { - aCreatorRows = creatorRowsCache[a.itemID]; - } - if (typeof creatorRowsCache[b.itemID] != 'undefined') { - bCreatorRows = creatorRowsCache[b.itemID]; - } - - // Match if no creators - if (!aCreatorRows && !bCreatorRows) { - return 1; - } - - if (!aCreatorRows || !bCreatorRows) { - return 0; - } - - for (let i = 0; i < aCreatorRows.length; i++) { - let aCreatorRow = aCreatorRows[i]; - let aLastName = aCreatorRow.lastName; - let aFirstInitial = aCreatorRow.firstInitial || ""; - - for (let j = 0; j < bCreatorRows.length; j++) { - let bCreatorRow = bCreatorRows[j]; - let bLastName = bCreatorRow.lastName; - let bFirstInitial = bCreatorRow.firstInitial || ""; - - if (aLastName === bLastName && aFirstInitial === bFirstInitial) { - return 1; - } - } - } - - return 0; - }, true); } +}; + + +Zotero.Duplicates.prototype._findDuplicates = async function () { + Zotero.debug("Finding duplicates"); - // Match on exact fields - /*var fields = ['']; - for (let field of fields) { - var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " - + "JOIN itemDataValues USING (valueID) " - + "WHERE libraryID=? AND fieldID=? " - + "AND itemID NOT IN (SELECT itemID FROM deletedItems) " - + "ORDER BY value"; - var rows = yield Zotero.DB.queryAsync(sql, [this._libraryID, Zotero.ItemFields.getID(field)]); - processRows(rows); - }*/ + var start = Date.now(); + + await this._loadCaches(); + + this._sets = new Zotero.DisjointSetForest; + + this._processRows(this._isbnRows); + this._processRows(this._doiRows); + this._processRows(this._titleRows, Zotero.Duplicates._compareRows, true); Zotero.debug("Found duplicates in " + (Date.now() - start) + " ms"); }; +/** + * Build an enriched row (suitable for _compareRows) from a Zotero.Item. + * + * @param {Zotero.Item} item - A saved or unsaved Zotero.Item + * @return {Object} - {itemID, value, doi?, isbn?, year?, creators?} + */ +Zotero.Duplicates._rowFromItem = function (item) { + var normalizeString = Zotero.Duplicates.normalizeString; + + var rawDOI = item.getField('DOI'); + var doi = rawDOI ? (rawDOI + '').trim().toUpperCase() : undefined; + if (doi && !doi.startsWith('10.')) doi = undefined; + + var rawISBN = item.getField('ISBN'); + var isbn = rawISBN ? Zotero.Utilities.cleanISBN('' + rawISBN) : undefined; + isbn = isbn ? Zotero.Utilities.toISBN13(isbn) : undefined; + + var year = item.getField('year') || undefined; + + var creators = item.getCreators(); + var normalizedCreators = creators.length + ? creators.map(c => ({ + lastName: normalizeString(c.lastName || ''), + firstInitial: c.fieldMode === 0 ? normalizeString(c.firstName || '').charAt(0) : false + })) + : undefined; + + return { + itemID: item.id || null, + value: normalizeString(item.getField('title', false, true)), + doi: doi, + isbn: isbn, + year: year, + creators: normalizedCreators + }; +}; + + +/** + * Find items in the library that are duplicates of the given item. + * + * @param {Zotero.Item|Object} itemOrCSLJSON - A Zotero.Item, or a CSL-JSON object + * @return {Promise} - Array of matching itemIDs + */ +Zotero.Duplicates.prototype.findDuplicatesOf = async function (itemOrCSLJSON) { + var item; + if (itemOrCSLJSON instanceof Zotero.Item) { + item = itemOrCSLJSON; + } + else { + item = new Zotero.Item(); + Zotero.Utilities.Item.itemFromCSLJSON(item, itemOrCSLJSON); + } + + await this._loadCaches(); + + var targetRow = Zotero.Duplicates._rowFromItem(item); + var matches = new Set(); + + // ISBN exact-match pass + if (targetRow.isbn) { + let startIdx = _binarySearch(this._isbnRows, targetRow.isbn); + let m = Zotero.Duplicates._checkIfDuplicate( + { value: targetRow.isbn }, + this._isbnRows.slice(startIdx) + ); + for (let r of m) matches.add(r.itemID); + } + + // DOI exact-match pass + if (targetRow.doi) { + let startIdx = _binarySearch(this._doiRows, targetRow.doi); + let m = Zotero.Duplicates._checkIfDuplicate( + { value: targetRow.doi }, + this._doiRows.slice(startIdx) + ); + for (let r of m) matches.add(r.itemID); + } + + // Title + creators pass — reuses _compareRows directly with the enriched row + if (targetRow.value) { + let startIdx = _binarySearch(this._titleRows, targetRow.value); + let m = Zotero.Duplicates._checkIfDuplicate( + targetRow, + this._titleRows.slice(startIdx), + Zotero.Duplicates._compareRows + ); + for (let r of m) matches.add(r.itemID); + } + + // Filter out the target item itself if it was a library item + if (targetRow.itemID) matches.delete(targetRow.itemID); + return [...matches]; +}; + + +/** + * Binary search for the first row whose value >= the target value + * in a sorted rows array. + * + * @param {Object[]} rows - Sorted by .value + * @param {String} value - Target value to find + * @return {Integer} - Index of first row with value >= target + */ +function _binarySearch(rows, value) { + let lo = 0, hi = rows.length; + while (lo < hi) { + let mid = (lo + hi) >> 1; + if (rows[mid].value < value) { + lo = mid + 1; + } + else { + hi = mid; + } + } + return lo; +} + /** * Implements the Disjoint Set data structure diff --git a/chrome/content/zotero/xpcom/integration.js b/chrome/content/zotero/xpcom/integration.js index 19deafdfc3..1c8af5ec5e 100644 --- a/chrome/content/zotero/xpcom/integration.js +++ b/chrome/content/zotero/xpcom/integration.js @@ -2454,6 +2454,85 @@ Zotero.Integration.Session.prototype.promptForRetraction = function (citedItem, return checkbox.value; } + +/** + * Relink document and bibliography entries to replacement library items + * + * @param {{oldItemID: Number|String, item: Zotero.Item}[]} replacements + */ +Zotero.Integration.Session.prototype._relinkItems = function (replacements) { + let replacementsByID = new Map( + replacements.map(({ oldItemID, item }) => [String(oldItemID), item]) + ); + let changed = false; + + // Relink every citation occurrence and mark affected fields for update + for (let [index, citation] of Object.entries(this.citationsByIndex)) { + let citationChanged = false; + for (let citationItem of citation.citationItems) { + let oldItemID = String(citationItem.cslItemID ?? citationItem.id); + let item = replacementsByID.get(oldItemID); + if (!item) continue; + + citationItem.id = item.id; + delete citationItem.cslItemID; + citationItem.uris = this.uriMap.getURIsForItemID(item.id); + citationChanged = true; + } + if (citationChanged) { + this.updateIndices[index] = true; + changed = true; + } + } + + // Keep the session index in sync with the mutated citations + this.citationsByItemID = {}; + for (let citation of Object.values(this.citationsByIndex)) { + for (let citationItem of citation.citationItems) { + let itemID = citationItem.cslItemID ?? citationItem.id; + if (!this.citationsByItemID[itemID]) { + this.citationsByItemID[itemID] = []; + } + this.citationsByItemID[itemID].push(citation); + } + } + + // Relink uncited, omitted, and customized bibliography entries + if (this.bibliography) { + let bibliographyDataChanged = false; + let replaceInSet = (set, skipCited = false) => { + for (let [oldItemID, item] of replacementsByID) { + if (!set.delete(oldItemID)) continue; + if (!skipCited || !this.citationsByItemID[item.id]) { + set.add(String(item.id)); + } + changed = true; + bibliographyDataChanged = true; + } + }; + replaceInSet(this.bibliography.uncitedItemIDs, true); + replaceInSet(this.bibliography.omittedItemIDs); + + for (let [oldItemID, item] of replacementsByID) { + if (oldItemID in this.bibliography.customEntryText) { + this.bibliography.customEntryText[item.id] + = this.bibliography.customEntryText[oldItemID]; + delete this.bibliography.customEntryText[oldItemID]; + changed = true; + bibliographyDataChanged = true; + } + } + if (bibliographyDataChanged) { + this.bibliographyDataHasChanged = true; + } + } + + if (changed) { + this.bibliographyHasChanged = true; + } +}; + + /** * Opens the citation explorer */ @@ -2475,21 +2554,23 @@ Zotero.Integration.Session.prototype.openCitationExplorer = async function () { const data = await citationField.unserialize(); return data.citationID === citation.citationID; }, - updateIndex: index => this.updateIndices[index] = true + relinkItems: replacements => this._relinkItems(replacements) }; await Zotero.Integration.displayDialog('chrome://zotero/content/integration/citationExplorer.xhtml', 'resizable', io); if (io.openCitationDialog) { - let citations = await this.cite(io.openCitationDialog); - if (this.data.prefs.delayCitationUpdates) { - for (let citation of citations) { - await this.writeDelayedCitation(citation.field, citation); + try { + await this.cite(io.openCitationDialog); + } + catch (e) { + if (!(e instanceof Zotero.Exception.Alert)) { + Zotero.debug("An error occurred while citing from Citation Explorer. Document will be updated."); + Zotero.logError(e); } - } else { - return this.updateDocument(FORCE_CITATIONS_FALSE, false, false); } } + return this.updateDocument(FORCE_CITATIONS_FALSE, false, false); }; diff --git a/chrome/content/zotero/xpcom/server/server_connector.js b/chrome/content/zotero/xpcom/server/server_connector.js index 301be412a3..a3e7038168 100644 --- a/chrome/content/zotero/xpcom/server/server_connector.js +++ b/chrome/content/zotero/xpcom/server/server_connector.js @@ -1114,8 +1114,8 @@ Zotero.Server.Connector.Ping.prototype = { supportsTagsAutocomplete: true, googleDocsAddNoteEnabled: true, googleDocsAddAnnotationEnabled: true, + googleDocsCitationExplorerEnabled: Zotero.Prefs.get("integration.googleDocsCitationExplorerEnabled"), canUserAddNote: true, - googleDocsCitationExplorerEnabled: false, translatorsHash, sortedTranslatorHash } diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js index 33d5a1cf7f..9b2d882b0e 100644 --- a/chrome/content/zotero/xpcom/utilities_internal.js +++ b/chrome/content/zotero/xpcom/utilities_internal.js @@ -2041,9 +2041,12 @@ Zotero.Utilities.Internal = { /** * Select an object in the library tab of the main window * - * @param {Zotero.DataObject} - Data object (e.g., Zotero.Item) to select + * @param {Zotero.DataObject[]} - Data objects (e.g., Zotero.Item) to select */ - showInLibrary: async function (dataObject) { + showInLibrary: async function (dataObjects) { + if (!Array.isArray(dataObjects)) { + dataObjects = [dataObjects]; + } var pane = Zotero.getActiveZoteroPane(); // Open main window if it's not open (Mac) if (!pane) { @@ -2057,12 +2060,12 @@ Zotero.Utilities.Internal = { }); pane = win.ZoteroPane; } - if (dataObject instanceof Zotero.Item) { - pane.selectItem(dataObject.id); - } - else { - throw new Error("Unimplemented"); - } + pane.selectItems(dataObjects.map(dataObject => { + if (dataObject instanceof Zotero.Item) { + return dataObject.id; + } + throw new Error(`Unsupported data object: ${dataObject}`); + })); // Pull window to foreground Zotero.Utilities.Internal.activate(pane.document.defaultView); diff --git a/chrome/locale/en-US/zotero/integration.ftl b/chrome/locale/en-US/zotero/integration.ftl index cd1142ae5c..990821541e 100644 --- a/chrome/locale/en-US/zotero/integration.ftl +++ b/chrome/locale/en-US/zotero/integration.ftl @@ -18,6 +18,52 @@ integration-editBibliography-wrapper = .aria-description = { -integration-editBibliography-include-uncited } { -integration-editBibliography-exclude-cited } { -integration-editBibliography-edit-reference } + +integration-citationExplorer-window = + .title = Citation Explorer +integration-citationExplorer-citations = Citations +integration-citationExplorer-citation-search = + .placeholder = Citation Content +integration-citationExplorer-citation-column = Citation +integration-citationExplorer-linked-column = Is Linked +integration-citationExplorer-show-in-document = + .value = Show in Document +integration-citationExplorer-edit-citation = + .value = Edit Citation +integration-citationExplorer-items-in-bibliography = Items in Bibliography +integration-citationExplorer-item-search = + .placeholder = Title, Creator, Year +integration-citationExplorer-show-in-zotero = + .value = Show in Zotero +integration-citationExplorer-manage-items = + .value = Add/Link to Collection + +integration-citationExplorerWizard-window = + .title = Add/Link to Collection +integration-citationExplorerWizard-choose-header = Choose an Action and Destination +integration-citationExplorerWizard-choose-description = Choose how to handle items used in this document and where to place them. +integration-citationExplorerWizard-operation-relink = + .label = Relink unlinked items +integration-citationExplorerWizard-operation-relink-description = Link only unlinked document items to matching items, or add them to the selected library or collection. +integration-citationExplorerWizard-operation-add-to-target = + .label = Copy and relink all items +integration-citationExplorerWizard-operation-add-to-target-description = Ensure that all document items are linked to items in the selected library or collection. +integration-citationExplorerWizard-target = Destination: +integration-citationExplorerWizard-review-header = Review Item Actions +integration-citationExplorerWizard-review-description = Review the proposed action for each item. Choose “Do nothing” to leave an item unchanged. +integration-citationExplorerWizard-action-column = Action +integration-citationExplorerWizard-action-add-to-target = Add to { $target } +integration-citationExplorerWizard-action-relink = Relink to existing item “{ $title }” +integration-citationExplorerWizard-action-copy = Copy an existing item from { $library } +integration-citationExplorerWizard-action-choose = Choose Item… +integration-citationExplorerWizard-action-skip = Do nothing +integration-citationExplorerWizard-choose-button = Choose… +integration-citationExplorerWizard-status-finding = Finding matching items… +integration-citationExplorerWizard-status-find-error = Unable to determine item actions. +integration-citationExplorerWizard-status-no-changes = No changes are needed. +integration-citationExplorerWizard-status-applying = Applying changes… +integration-citationExplorerWizard-status-apply-error = An error occurred while applying changes. + integration-citationDialog = Citation Dialog integration-citationDialog-section-open = Open Documents ({ $count }) integration-citationDialog-section-selected = Selected Items ({ $count }/{ $total }) @@ -152,4 +198,3 @@ integration-warning-command-is-running = A word processor integration command is first-run-guidance-citationDialog = Click the bubble or use the ← and ↓ keys to view the citation details and customize options such as page number, prefix, and suffix. You can also add a page number or other locator by including it with your search terms (e.g., “history { $locator }”) or by typing it after the bubble and pressing { return-or-enter }. - diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl index 0bad3e2b73..2ad56d963b 100644 --- a/chrome/locale/en-US/zotero/zotero.ftl +++ b/chrome/locale/en-US/zotero/zotero.ftl @@ -1105,6 +1105,10 @@ select-items-window = .title = Select Items select-items-dialog = .buttonlabelaccept = Select +select-items-suggested-items = Suggested Items +select-items-library-column = Library +select-items-show-in-zotero = + .label = { general-show-in-library } select-items-convertToStandalone = .label = Convert to Standalone select-items-convertToStandaloneAttachment = diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index de5370f78b..1132fde137 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -917,6 +917,9 @@ integration.upgradeTemplate = The %S plugin for %S is outdated. Reinstall the pl integration.mendeleyImport.title = Missing Mendeley Data integration.mendeleyImport.description = %1$S detected that the document you are citing with contains Mendeley citations. %1$S will be able to manage these citations if you import your Mendeley database. integration.mendeleyImport.openImporter = Open Mendeley Importer... +integration.citationExplorer.citationsModified = Citations in your document have been modified since Citation Explorer has been opened and %S will not be able to activate them until you reopen this window. +integration.citationExplorer.unlinkedItems = Unlinked Items +integration.citationExplorer.uncited = Uncited styles.install.title = Install Style styles.install.unexpectedError = An unexpected error occurred while installing "%1$S" diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index c51de7cda2..24ff31f76f 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -155,6 +155,7 @@ pref("extensions.zotero.integration.citationDialogMode", "last-used"); pref("extensions.zotero.integration.citationDialogShowLocatorTip", true); pref("extensions.zotero.integration.annotationDialogIncludeComments", true); pref("extensions.zotero.integration.citationPreviewShown", true); +pref("extensions.zotero.integration.googleDocsCitationExplorerEnabled", false); // Connector settings pref("extensions.zotero.httpServer.enabled", true); diff --git a/scss/_zotero.scss b/scss/_zotero.scss index c3f572158f..7def3dd4ac 100644 --- a/scss/_zotero.scss +++ b/scss/_zotero.scss @@ -26,6 +26,7 @@ @import "components/banners"; @import "components/button"; @import "components/citationExplorer"; +@import "components/citationExplorerWizard"; @import "components/citationDialog"; @import "components/clicky"; @import "components/contextPane"; @@ -115,4 +116,4 @@ @import "elements/itemPane"; @import "elements/itemPaneCustomSection"; @import "elements/contextPane"; -@import "elements/fileRenamingSettings"; \ No newline at end of file +@import "elements/fileRenamingSettings"; diff --git a/scss/abstracts/_layout.scss b/scss/abstracts/_layout.scss index 6b660fa239..4989f28591 100644 --- a/scss/abstracts/_layout.scss +++ b/scss/abstracts/_layout.scss @@ -16,3 +16,7 @@ .flex { flex: 1 } + +.right { + margin-left: auto !important; +} \ No newline at end of file diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index 7615565799..63a811f22a 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -1,29 +1,111 @@ +.citation-explorer-item-tree .virtualized-table .row { + .cell .badge { + font-size: 11px; + padding: 1px 4px; + border-radius: 5px; + display: inline-block; + background: #59ADC44D; + margin-left: 4px; + } + + &.selected .cell .badge { + background: rgba(255, 255, 255, 0.2); + color: var(--accent-white); + } +} + #zotero-citation-explorer-dialog { min-height: 500px; - + min-width: 600px; + --split-button-icon-color: var(--accent-blue); + dialog { max-height: 100vh; + background: var(--material-sidepane); + } + + search-textbox, input, button { + margin: 0; + } + + .citation-explorer-grid { + display: grid; + grid-template-columns: 4fr 6fr; + grid-template-rows: auto auto minmax(0, 1fr) auto; + grid-auto-flow: column; + height: 100%; + margin: -8px; + + .citation-explorer-grid-left { + border-right: var(--material-border-quarternary); + } + } + + #citations-label, #items-in-bibliography-label { + padding: 16px 16px 8px; + align-content: center; + font-size: 15px; + font-weight: 600; + } + + .searchbox-container { + padding: 0 16px 8px; } .virtualized-table-container { height: 100%; - flex: 1; - } - - .xul-border-div { - -moz-box-flex: 1; + border-block: var(--material-border-quarternary); } - #citations-label { - margin: 5px 0 4px; + .virtualized-table .row { + &.odd:not(.selected) { + background-color: var(--material-stripe); + } + + &.even:not(.selected) { + background-color: var(--material-background); + } } - .panel { - border: solid 1px ThreeDShadow; - padding: 5px; + .virtualized-table:focus-within .row.highlighted:not(.selected) { + background: initial !important; + } + + .button-container { + border-bottom: var(--material-border-quarternary); + display: flex; + gap: 8px; + padding: 8px 16px; + min-width: 0; + + .split-menu-button { + min-width: 0; + flex: 0 1 auto; + + [anonid="button-image-and-text-box"] { + min-width: 0; + width: auto; + } + } + } + + .diff-deleted { + color: var(--accent-red); + text-decoration: line-through; + } + + .diff-added { + color: green; } - .search-box { - margin: 2px -1px; + @media (-moz-platform: macos) { + .citation-explorer-grid { + margin: -14px; + } + + .button-container { + padding-bottom: 0px; + margin-bottom: -16px; + } } } diff --git a/scss/components/_citationExplorerWizard.scss b/scss/components/_citationExplorerWizard.scss new file mode 100644 index 0000000000..277d597090 --- /dev/null +++ b/scss/components/_citationExplorerWizard.scss @@ -0,0 +1,86 @@ +#citation-explorer-wizard-window { + display: flex; + min-height: 500px; + + .citation-explorer-wizard { + width: 100%; + height: 100%; + + wizardpage { + display: flex; + flex-direction: column; + } + + .page-description { + display: block; + margin: 0 0 16px; + } + + #item-operation { + margin: 0; + + radio { + margin-inline-start: 0; + } + + .option-description { + display: block; + margin: 2px 0 14px 24px; + color: var(--fill-secondary); + } + } + + .target-picker { + margin-top: 12px; + max-width: 420px; + + label { + margin: 0 0 6px; + } + + #item-target { + margin: 0; + } + } + + .cell.action { + display: flex; + align-items: center; + gap: 6px; + padding-block: 1px; + + .item-action-select { + flex: 1 1 auto; + min-width: 0; + margin: 0; + + &.skip-selected::part(icon) { + color: var(--accent-red); + fill: var(--accent-red) !important; + -moz-context-properties: fill, color; + } + } + + .choose-item-button { + flex: 0 0 auto; + margin: 0; + } + } + + .skip-action > .menu-icon { + color: var(--accent-red); + fill: var(--accent-red) !important; + -moz-context-properties: fill, color; + } + + .row.selected .item-action-select { + color: inherit; + + &::part(icon) { + color: inherit; + fill: currentColor !important; + -moz-context-properties: fill, color; + } + } + } +} diff --git a/scss/components/_icons.scss b/scss/components/_icons.scss index 69e7369af2..9da7a0d4c4 100644 --- a/scss/components/_icons.scss +++ b/scss/components/_icons.scss @@ -41,6 +41,7 @@ $-icons: ( chevron-6: 8, filter: 16, note: 16, + link: 16, x-8: 16, play: 16, pause: 16, diff --git a/scss/components/_item-tree.scss b/scss/components/_item-tree.scss index b6e5b28601..50113126c0 100644 --- a/scss/components/_item-tree.scss +++ b/scss/components/_item-tree.scss @@ -35,14 +35,6 @@ padding-inline-start: 8px; padding-inline-end: calc(8px + var(--scrollbar-width, 0px)); box-sizing: border-box; - // A clear divider below the column headers (the default faint border is lost - // against the white section-header/spacer rows below it). The header already - // carries a second 1px line via ::after, so drop that one to avoid doubling. - border-bottom: var(--material-panedivider); - - &::after { - border-bottom: none; - } .cell.hasAttachment, .cell.numNotes { @@ -406,10 +398,12 @@ $-attachmentIcons: ( attachment-video ); -// Non-item objects that can appear in the trash -$-trashableObjectIcons: ( +// Non-item objects that can appear in the item tree +$-nonItemIcons: ( "collection", - "search" + "search", + "library", + "library-group" ); // Few icons that appear in color in the item tree @@ -475,7 +469,7 @@ $-coloredIcons: ( } } - @each $icon in $-trashableObjectIcons { + @each $icon in $-nonItemIcons { .icon-css.icon-#{$icon} { @include svgicon($icon, $color, "16", "collection-tree"); } diff --git a/scss/components/_virtualized-table.scss b/scss/components/_virtualized-table.scss index c7d8521533..b7446363a3 100644 --- a/scss/components/_virtualized-table.scss +++ b/scss/components/_virtualized-table.scss @@ -60,12 +60,6 @@ padding-inline-end: 4px; } - &:first-child { - --extra-width: var(--first-column-extra-width, 0px); - // No padding on the first cell since it's done via twisty and indent - padding-inline-start: 0; - } - &:first-child, &.primary { display: flex; @@ -243,7 +237,7 @@ background: var(--material-background); height: 1.83333333em; // 22px @ 12px font size overflow: hidden; - border-bottom: 1px solid var(--material-border-quarternary); + border-bottom: var(--material-panedivider); padding-inline: 8px; box-sizing: border-box; padding-inline-end: calc(8px + var(--scrollbar-width, 0px)); @@ -265,23 +259,6 @@ } } - &::after { - content: ""; - display: block; - border-bottom: var(--material-border-quarternary); - height: 1px; - width: 100%; - position: absolute; - top: calc(1.83333333em - 1px); - left: 0; - right: 0; - z-index: 1; - - @include comfortable { - top: calc(2.33333333em - 1px); - } - } - .column-picker { text-align: center; } @@ -522,3 +499,10 @@ } } +.virtualized-tree { + .cell:first-child { + --extra-width: var(--first-column-extra-width, 0px); + // No padding on the first cell since it's done via twisty and indent + padding-inline-start: 0; + } +} \ No newline at end of file diff --git a/scss/elements/_splitMenuButton.scss b/scss/elements/_splitMenuButton.scss index a32208c251..0153402d32 100644 --- a/scss/elements/_splitMenuButton.scss +++ b/scss/elements/_splitMenuButton.scss @@ -1,8 +1,9 @@ .split-menu-button { display: flex; align-items: center; - padding-right: 0; + padding-right: 6px; gap: 6px; + flex-grow: 1; & [anonid="button-image-and-text-box"] { flex: 1; @@ -33,6 +34,7 @@ display: flex; align-items: center; overflow: hidden; + min-width: 8px; } & [anonid="dropmarker-separator"] { @@ -47,4 +49,8 @@ fill: currentColor; -moz-context-properties: fill,fill-opacity; } + + @media (-moz-platform: mac) { + padding-right: 0; + } } diff --git a/scss/mac/_citationExplorer.scss b/scss/mac/_citationExplorer.scss deleted file mode 100644 index 5b499344ce..0000000000 --- a/scss/mac/_citationExplorer.scss +++ /dev/null @@ -1,14 +0,0 @@ -#zotero-citation-explorer-dialog { - tab[visuallyselected="true"]:not(:-moz-window-inactive) { - color: initial !important; - } - - #items-panel { - margin-top: 1.5em; - } - - #citations-label { - margin: 0.5em 0 0.38em; - font-size: 1.2em; - } -} \ No newline at end of file diff --git a/scss/win/_citationExplorer.scss b/scss/win/_citationExplorer.scss deleted file mode 100644 index d3ed61a654..0000000000 --- a/scss/win/_citationExplorer.scss +++ /dev/null @@ -1,13 +0,0 @@ -#zotero-citation-explorer-dialog { - #citations-label { - margin: 3px 0 2px; - } - - tab:focus-visible > .tab-middle { - outline: none; - } - - input[type="button"], button { - margin-top: 7px; - } -} \ No newline at end of file diff --git a/scss/win/components/_input.scss b/scss/win/components/_input.scss index 5646034100..1c0e6e7fca 100644 --- a/scss/win/components/_input.scss +++ b/scss/win/components/_input.scss @@ -127,6 +127,10 @@ } } +search-textbox:not([no-native]) { + padding-inline-start: 0; +} + textarea:not([no-native]) { height: unset; } diff --git a/scss/xulElementPatches/wizard.scss b/scss/xulElementPatches/wizard.scss index 50175ab338..3cd570d90c 100644 --- a/scss/xulElementPatches/wizard.scss +++ b/scss/xulElementPatches/wizard.scss @@ -44,4 +44,5 @@ html|*.wizard-page-box:not(.specificity) { padding: 16px 0; -} \ No newline at end of file + min-height: 0; +} diff --git a/scss/zotero-mac.scss b/scss/zotero-mac.scss index 48f9235271..5b5e3b4719 100644 --- a/scss/zotero-mac.scss +++ b/scss/zotero-mac.scss @@ -13,6 +13,5 @@ @import "mac/components/input"; @import "mac/components/menupopup"; @import "mac/components/menulist"; -@import "mac/citationExplorer"; // Elements diff --git a/scss/zotero-win.scss b/scss/zotero-win.scss index 17448c86bf..43b62ff20e 100644 --- a/scss/zotero-win.scss +++ b/scss/zotero-win.scss @@ -9,7 +9,6 @@ @import "win/createParent"; @import "win/tabBar"; @import "win/titleBar"; -@import "win/citationExplorer"; // Elements diff --git a/test/tests/citationExplorerWizardTest.js b/test/tests/citationExplorerWizardTest.js new file mode 100644 index 0000000000..5be57eab3f --- /dev/null +++ b/test/tests/citationExplorerWizardTest.js @@ -0,0 +1,367 @@ +"use strict"; + +describe('Citation Explorer Wizard', function () { + var dialog; + var group; + var targetCollection; + var io; + var savedItems; + + async function openCitationExplorerWizard(items, uncitedItems, citations) { + io = { + items, + uncitedItems, + citations, + relinkItems: sinon.spy(), + completed: false, + deferred: Zotero.Promise.defer(), + }; + io.wrappedJSObject = io; + dialog = await loadWindow( + 'chrome://zotero/content/integration/citationExplorerWizard.xhtml', + io + ); + await waitForCallback(() => { + return dialog.document.getElementById('item-target').getAttribute('label'); + }); + return dialog.document.getElementById('citation-explorer-wizard'); + } + + /** + * Open the review page for cited items with the specified destination and operation, + * and wait for either an action select or the no-changes message. + */ + async function openReview({ items, target, operation, noChanges = false }) { + let citations = {}; + for (let [index, item] of items.entries()) { + citations[index] = { + citationItems: [{ id: item.cslItemID ?? item.id }], + }; + } + let wizard = await openCitationExplorerWizard(items, [], citations); + dialog.document.getElementById('item-operation').value = operation; + let targetMenuitem = dialog.document.querySelector( + `#item-target-menu menuitem[value="${target.treeViewID}"]` + ); + if (!targetMenuitem) { + throw new Error(`Target ${target.treeViewID} not found`); + } + targetMenuitem.doCommand(); + wizard.getButton('next').click(); + await waitForCallback(() => { + if (wizard.currentPage?.pageid !== 'review-actions') return false; + if (noChanges) { + return dialog.document.querySelector('.items-tree-message')?.textContent + .includes('No changes are needed.'); + } + let options = dialog.document.querySelectorAll('.item-action-select menuitem'); + return options.length && [...options].every(option => option.getAttribute('label')); + }); + return wizard; + } + + function getActionState() { + let select = dialog.document.querySelector('.item-action-select'); + let options = [...select.menupopup.children].map(option => option.getAttribute('label')); + return { + selected: options[Number(select.value)], + options, + }; + } + + function createUnlinkedItem(itemData, cslItemID) { + let item = new Zotero.Item(itemData.itemType); + item.setField('title', itemData.title); + item.setCreators(itemData.creators || []); + item.cslItemID = cslItemID; + return item; + } + + before(async function () { + group = await createGroup({ name: 'Wizard Target Group' }); + targetCollection = await createDataObject('collection', { + libraryID: group.libraryID, + name: 'Wizard Target Collection', + }); + }); + + beforeEach(function () { + dialog = null; + io = null; + savedItems = []; + }); + + afterEach(async function () { + if (dialog && !dialog.closed) { + dialog.close(); + await io.deferred.promise; + } + for (let item of savedItems) { + if (Zotero.Items.get(item.id)) { + await item.eraseTx(); + } + } + }); + + after(async function () { + await group.eraseTx(); + }); + + describe('Destination selection', function () { + it('should default to the library with the most citation occurrences', async function () { + let groupItem = await createDataObject('item', { + libraryID: group.libraryID, + title: 'Repeated Group Citation', + }); + let userItem = await createDataObject('item', { title: 'Repeated User Citation' }); + let uncitedGroupItems = await Promise.all([1, 2].map(index => createDataObject('item', { + libraryID: group.libraryID, + title: `Uncited Group Item ${index}`, + }))); + savedItems.push(groupItem, userItem, ...uncitedGroupItems); + + // Uncited linked items each add one to the destination score. + await openCitationExplorerWizard( + [groupItem, userItem], + uncitedGroupItems, + { + 0: { citationItems: [{ id: groupItem.id }] }, + 1: { citationItems: [{ id: groupItem.id }] }, + 2: { citationItems: [{ id: userItem.id }] }, + 3: { citationItems: [{ id: userItem.id }] }, + 4: { citationItems: [{ id: userItem.id }] }, + } + ); + + assert.equal( + dialog.document.getElementById('item-target').getAttribute('label'), + group.name, + 'Uncited items should make the group library the highest-scoring destination' + ); + assert.isTrue( + dialog.document.getElementById('item-operation-relink').hidden, + 'The relink option should be hidden when all items are linked' + ); + assert.isTrue( + dialog.document.getElementById('item-operation-relink-description').hidden, + 'The relink description should be hidden with its option' + ); + }); + }); + + describe('Review actions', function () { + describe('Unlinked items', function () { + it('should offer and select a destination-library match', async function () { + let itemData = { + itemType: 'book', + title: `Target Match ${Zotero.Utilities.randomString()}`, + }; + let match = await createDataObject('item', itemData); + savedItems.push(match); + let item = createUnlinkedItem(itemData, 'session/target-match'); + + await openReview({ + items: [item], + target: Zotero.Libraries.userLibrary, + operation: 'relink', + }); + + assert.deepEqual(getActionState(), { + selected: `Relink to existing item “${match.getDisplayTitle()}”`, + options: [ + `Relink to existing item “${match.getDisplayTitle()}”`, + 'Choose Item…', + 'Do nothing', + ], + }, 'A destination match should be selected for relinking'); + }); + + it('should offer and select a non-destination-library match for copying', async function () { + let itemData = { + itemType: 'book', + title: `Other Library Match ${Zotero.Utilities.randomString()}`, + }; + let match = await createDataObject('item', { + ...itemData, + libraryID: group.libraryID, + }); + savedItems.push(match); + let item = createUnlinkedItem(itemData, 'session/other-library-match'); + + await openReview({ + items: [item], + target: Zotero.Libraries.userLibrary, + operation: 'relink', + }); + + assert.deepEqual(getActionState(), { + selected: `Copy an existing item from ${group.name}`, + options: [ + `Copy an existing item from ${group.name}`, + `Add to ${Zotero.Libraries.userLibrary.name}`, + 'Choose Item…', + 'Do nothing', + ], + }, 'A non-destination match should be selected for copying and linking'); + }); + + it('should select adding the item from the document', async function () { + let item = createUnlinkedItem({ + itemType: 'book', + title: `Unmatched Document Item ${Zotero.Utilities.randomString()}`, + }, 'session/unmatched'); + + await openReview({ + items: [item], + target: Zotero.Libraries.userLibrary, + operation: 'relink', + }); + + assert.deepEqual(getActionState(), { + selected: `Add to ${Zotero.Libraries.userLibrary.name}`, + options: [ + `Add to ${Zotero.Libraries.userLibrary.name}`, + 'Choose Item…', + 'Do nothing', + ], + }, 'An unmatched unlinked item should default to its document data'); + }); + }); + + describe('Linked items', function () { + it('should omit an item already in the destination library', async function () { + let destinationItem = await createDataObject('item', { + title: `Already in Destination ${Zotero.Utilities.randomString()}`, + }); + let externalItem = await createDataObject('item', { + libraryID: group.libraryID, + title: `External Item ${Zotero.Utilities.randomString()}`, + }); + savedItems.push(destinationItem, externalItem); + + await openReview({ + items: [destinationItem, externalItem], + target: Zotero.Libraries.userLibrary, + operation: 'addToTarget', + }); + + assert.lengthOf( + dialog.document.querySelectorAll('.item-action-select'), + 1, + 'Only the item outside the destination library should have actions' + ); + }); + + it('should show no changes when all items are already in the destination library', async function () { + let items = await Promise.all([1, 2].map(index => createDataObject('item', { + title: `Already in Destination ${index} ${Zotero.Utilities.randomString()}`, + }))); + savedItems.push(...items); + + await openReview({ + items, + target: Zotero.Libraries.userLibrary, + operation: 'addToTarget', + noChanges: true, + }); + + assert.equal( + dialog.document.querySelector('.items-tree-message').textContent, + 'No changes are needed.', + 'Items already in the destination should produce the no-changes state' + ); + }); + + it('should offer and select copying an item from another library', async function () { + let item = await createDataObject('item', { + libraryID: group.libraryID, + title: `Linked Copy ${Zotero.Utilities.randomString()}`, + }); + savedItems.push(item); + + await openReview({ + items: [item], + target: Zotero.Libraries.userLibrary, + operation: 'addToTarget', + }); + + assert.deepEqual(getActionState(), { + selected: `Copy an existing item from ${group.name}`, + options: [ + `Copy an existing item from ${group.name}`, + 'Choose Item…', + 'Do nothing', + ], + }, 'An external linked item should be selected for copying'); + }); + }); + + describe('Collection destinations', function () { + it('should offer and select adding a library item to the collection', async function () { + let item = await createDataObject('item', { + libraryID: group.libraryID, + title: `Add to Collection ${Zotero.Utilities.randomString()}`, + }); + savedItems.push(item); + + await openReview({ + items: [item], + target: targetCollection, + operation: 'addToTarget', + }); + + assert.deepEqual(getActionState(), { + selected: `Add to ${targetCollection.name}`, + options: [ + `Add to ${targetCollection.name}`, + 'Choose Item…', + 'Do nothing', + ], + }, 'An item in the target library should be selected for collection addition'); + }); + + it('should offer and select copying an item from another library', async function () { + let item = await createDataObject('item', { + title: `Copy to Collection ${Zotero.Utilities.randomString()}`, + }); + savedItems.push(item); + + await openReview({ + items: [item], + target: targetCollection, + operation: 'addToTarget', + }); + + assert.deepEqual(getActionState(), { + selected: `Copy an existing item from ${Zotero.Libraries.userLibrary.name}`, + options: [ + `Copy an existing item from ${Zotero.Libraries.userLibrary.name}`, + 'Choose Item…', + 'Do nothing', + ], + }, 'An external item should be selected for copying to the collection library'); + }); + + it('should omit an item already in the collection', async function () { + let item = await createDataObject('item', { + libraryID: group.libraryID, + collections: [targetCollection.id], + title: `Already in Collection ${Zotero.Utilities.randomString()}`, + }); + savedItems.push(item); + + await openReview({ + items: [item], + target: targetCollection, + operation: 'addToTarget', + noChanges: true, + }); + + assert.notExists( + dialog.document.querySelector('.item-action-select'), + 'An item already in the target collection should not have actions' + ); + }); + }); + }); +}); diff --git a/test/tests/duplicatesTest.js b/test/tests/duplicatesTest.js index bb63295d91..8d256f2734 100644 --- a/test/tests/duplicatesTest.js +++ b/test/tests/duplicatesTest.js @@ -60,6 +60,158 @@ describe("Duplicate Items", function () { await waitForNotifierEvent('refresh', 'trash'); } + describe("findDuplicatesOf()", function () { + it("should find duplicates of a Zotero.Item by title + creator", async function () { + var item1 = await createDataObject('item', { + title: 'Test Dedup Title', + creators: [{ + firstName: 'John', + lastName: 'Smith', + creatorType: 'author' + }] + }); + var item2 = await createDataObject('item', { + title: 'Test Dedup Title', + creators: [{ + firstName: 'John', + lastName: 'Smith', + creatorType: 'author' + }] + }); + // Different title, should not match + var item3 = await createDataObject('item', { + title: 'Different Title', + creators: [{ + firstName: 'John', + lastName: 'Smith', + creatorType: 'author' + }] + }); + + var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID); + var dupes = await d.findDuplicatesOf(item1); + assert.include(dupes, item2.id); + assert.notInclude(dupes, item1.id); + assert.notInclude(dupes, item3.id); + }); + + it("should find duplicates of a CSL-JSON item by title + creator", async function () { + var item1 = await createDataObject('item', { + title: 'CSL Dedup Title', + creators: [{ + firstName: 'Jane', + lastName: 'Doe', + creatorType: 'author' + }] + }); + + var cslItem = { + type: 'book', + title: 'CSL Dedup Title', + author: [{ family: 'Doe', given: 'Jane' }] + }; + + var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID); + var dupes = await d.findDuplicatesOf(cslItem); + assert.include(dupes, item1.id); + }); + + it("should find duplicates by DOI", async function () { + var item1 = await createDataObject('item', { + itemType: 'journalArticle', + title: 'Article One' + }); + item1.setField('DOI', '10.1234/test.doi'); + await item1.saveTx(); + + var cslItem = { + type: 'article-journal', + title: 'Completely Different Title', + DOI: '10.1234/test.doi' + }; + + var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID); + var dupes = await d.findDuplicatesOf(cslItem); + assert.include(dupes, item1.id); + }); + + it("should find duplicates by ISBN", async function () { + var item1 = await createDataObject('item', { + itemType: 'book', + title: 'My Book' + }); + item1.setField('ISBN', '978-0-306-40615-7'); + await item1.saveTx(); + + var cslItem = { + type: 'book', + title: 'Some Other Book Title', + ISBN: '978-0-306-40615-7' + }; + + var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID); + var dupes = await d.findDuplicatesOf(cslItem); + assert.include(dupes, item1.id); + }); + + it("should not match items with same title but conflicting years", async function () { + var item1 = await createDataObject('item', { + title: 'Year Conflict Title', + creators: [{ + firstName: 'Alice', + lastName: 'Test', + creatorType: 'author' + }] + }); + item1.setField('date', '2020'); + await item1.saveTx(); + + var cslItem = { + type: 'book', + title: 'Year Conflict Title', + author: [{ family: 'Test', given: 'Alice' }], + issued: { 'date-parts': [[2015]] } + }; + + var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID); + var dupes = await d.findDuplicatesOf(cslItem); + assert.notInclude(dupes, item1.id); + }); + + it("should not match items with same title but different creators", async function () { + var item1 = await createDataObject('item', { + title: 'Creator Mismatch Title', + creators: [{ + firstName: 'Alice', + lastName: 'One', + creatorType: 'author' + }] + }); + + var cslItem = { + type: 'book', + title: 'Creator Mismatch Title', + author: [{ family: 'Two', given: 'Bob' }] + }; + + var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID); + var dupes = await d.findDuplicatesOf(cslItem); + assert.notInclude(dupes, item1.id); + }); + + it("should return empty array when no duplicates exist", async function () { + var cslItem = { + type: 'book', + title: 'Absolutely Unique Title ' + Zotero.Utilities.randomString(), + author: [{ family: 'Nobody', given: 'X' }] + }; + + var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID); + var dupes = await d.findDuplicatesOf(cslItem); + assert.lengthOf(dupes, 0); + }); + }); + describe("Merging", function () { it("should merge two items in duplicates view", async function () { var item1 = await createDataObject('item', { setTitle: true }); diff --git a/test/tests/integrationTest.js b/test/tests/integrationTest.js index d137045c40..1ab8e23e00 100644 --- a/test/tests/integrationTest.js +++ b/test/tests/integrationTest.js @@ -407,7 +407,102 @@ describe("Zotero.Integration", function () { displayDialogStub.restore(); addEditCitationSpy.restore(); }); - + + describe('Session', function () { + describe('#_relinkItems()', function () { + var session; + var firstCitation; + var secondCitation; + var firstReplacement; + var secondReplacement; + + beforeEach(function () { + firstCitation = { + citationItems: [ + { id: 'stale-id', cslItemID: 'embedded/first', uris: ['old:first'] }, + { + id: 'stale-unreplaced', + cslItemID: 'embedded/unreplaced', + uris: ['old:unreplaced'], + }, + { id: 2, uris: ['old:second'] }, + ], + }; + secondCitation = { + citationItems: [ + { id: 'embedded/first', uris: ['old:first'] }, + ], + }; + firstReplacement = { id: 100 }; + secondReplacement = { id: 101 }; + session = { + citationsByIndex: { + 2: firstCitation, + 9: secondCitation, + }, + citationsByItemID: {}, + updateIndices: {}, + uriMap: { + getURIsForItemID: id => [`new:${id}`], + }, + bibliography: { + uncitedItemIDs: new Set(['embedded/second']), + omittedItemIDs: new Set(['embedded/first']), + customEntryText: { + 'embedded/first': 'First custom entry', + 'embedded/second': 'Second custom entry', + }, + }, + bibliographyHasChanged: false, + bibliographyDataHasChanged: false, + }; + }); + + it('should relink every citation occurrence and bibliography entry', function () { + Zotero.Integration.Session.prototype._relinkItems.call(session, [ + { oldItemID: 'embedded/first', item: firstReplacement }, + { oldItemID: 'embedded/second', item: secondReplacement }, + ]); + + assert.equal(firstCitation.citationItems[0].id, firstReplacement.id); + assert.notProperty(firstCitation.citationItems[0], 'cslItemID'); + assert.deepEqual(firstCitation.citationItems[0].uris, ['new:100']); + assert.equal(secondCitation.citationItems[0].id, firstReplacement.id); + assert.deepEqual(secondCitation.citationItems[0].uris, ['new:100']); + assert.deepEqual(Object.keys(session.updateIndices), ['2', '9']); + assert.deepEqual(session.citationsByItemID[firstReplacement.id], [ + firstCitation, + secondCitation, + ]); + assert.deepEqual( + session.citationsByItemID['embedded/unreplaced'], + [firstCitation] + ); + assert.notProperty(session.citationsByItemID, 'stale-unreplaced'); + assert.deepEqual(session.citationsByItemID[2], [firstCitation]); + + assert.deepEqual( + [...session.bibliography.uncitedItemIDs], + [String(secondReplacement.id)] + ); + assert.deepEqual( + [...session.bibliography.omittedItemIDs], + [String(firstReplacement.id)] + ); + assert.equal( + session.bibliography.customEntryText[firstReplacement.id], + 'First custom entry' + ); + assert.equal( + session.bibliography.customEntryText[secondReplacement.id], + 'Second custom entry' + ); + assert.isTrue(session.bibliographyHasChanged); + assert.isTrue(session.bibliographyDataHasChanged); + }); + }); + }); + describe('Interface', function () { describe('#execCommand', function () { var setDocumentDataSpy; diff --git a/test/tests/itemsTest.js b/test/tests/itemsTest.js index 9842f2bd60..bedfe4a9df 100644 --- a/test/tests/itemsTest.js +++ b/test/tests/itemsTest.js @@ -153,6 +153,73 @@ describe("Zotero.Items", function () { }); + describe("#copyToLibrary()", function () { + var group; + var copyChildNotes; + + before(async function () { + group = await createGroup(); + }); + + beforeEach(function () { + copyChildNotes = Zotero.Prefs.get('groups.copyChildNotes'); + Zotero.Prefs.set('groups.copyChildNotes', true); + }); + + afterEach(function () { + Zotero.Prefs.set('groups.copyChildNotes', copyChildNotes); + }); + + after(async function () { + await group.eraseTx(); + }); + + it("should copy and link a regular item into a group and reuse it", async function () { + var item = await createDataObject('item'); + var newItem; + await Zotero.DB.executeTransaction(async function () { + newItem = await Zotero.Items.copyToLibrary(item, group.libraryID); + }); + + assert.instanceOf(newItem, Zotero.Item); + assert.equal(newItem.libraryID, group.libraryID); + assert.equal(await item.getLinkedItem(group.libraryID, true), newItem); + + var reusedItem; + await Zotero.DB.executeTransaction(async function () { + reusedItem = await Zotero.Items.copyToLibrary(item, group.libraryID); + }); + assert.strictEqual(reusedItem, newItem); + }); + + it("should copy a child note", async function () { + var item = await createDataObject('item'); + var note = await createDataObject('item', { itemType: 'note', parentID: item.id }); + var newItem; + await Zotero.DB.executeTransaction(async function () { + newItem = await Zotero.Items.copyToLibrary(item, group.libraryID); + }); + + var notes = Zotero.Items.get(newItem.getNotes()); + assert.lengthOf(notes, 1); + assert.equal(await note.getLinkedItem(group.libraryID, true), notes[0]); + }); + + it("should honor disabled child-note copying", async function () { + var item = await createDataObject('item'); + await createDataObject('item', { itemType: 'note', parentID: item.id }); + var newItem; + await Zotero.DB.executeTransaction(async function () { + newItem = await Zotero.Items.copyToLibrary(item, group.libraryID, { + childNotes: false, + }); + }); + + assert.isEmpty(newItem.getNotes()); + }); + }); + + describe("#copyChildItems()", function () { var group; diff --git a/test/tests/selectItemsDialogTest.js b/test/tests/selectItemsDialogTest.js new file mode 100644 index 0000000000..a2c4a19157 --- /dev/null +++ b/test/tests/selectItemsDialogTest.js @@ -0,0 +1,152 @@ +"use strict"; + +describe("Select Items Dialog", function () { + var candidate1; + var candidate2; + var childNote; + var excluded; + var collection; + var group; + + async function openDialog(itemIDs) { + let io = { + dataOut: null, + itemIDs, + multiSelect: false, + onlyRegularItems: true, + deferred: Zotero.Promise.defer() + }; + let dialogPromise = waitForWindow('chrome://zotero/content/selectItemsDialog.xhtml'); + Services.ww.openWindow(null, 'chrome://zotero/content/selectItemsDialog.xhtml', '', + 'chrome,dialog=no,centerscreen,resizable=yes', io); + let dialog = await dialogPromise; + await waitForCallback(() => dialog.loaded); + return { dialog, io }; + } + + async function selectSuggestedItems(dialog) { + let row = dialog.collectionsView.getRowIndexByID('suggested-items'); + assert.isNumber(row); + await dialog.collectionsView.selectWait(row); + } + + function assertLibraryColumnPosition(dialog) { + let columns = dialog.itemsView._getColumns(); + let libraryColumnIndex = columns.findIndex(column => column.dataKey === 'library'); + let attachmentColumnIndex = columns.findIndex(column => column.dataKey === 'hasAttachment'); + assert.equal(libraryColumnIndex, attachmentColumnIndex - 1); + assert.isAtLeast(parseInt(columns[libraryColumnIndex].width), 120); + } + + before(async function () { + candidate1 = await createDataObject('item', { title: 'Candidate One' }); + group = await createGroup({ name: 'Candidate Library' }); + candidate2 = await createDataObject('item', { + title: 'Candidate Two', + libraryID: group.libraryID + }); + childNote = await createDataObject('item', { itemType: 'note', parentID: candidate1.id }); + excluded = await createDataObject('item', { title: 'Excluded Item' }); + collection = await createDataObject('collection', { name: 'Browsable Collection' }); + }); + + after(async function () { + await group.eraseTx(); + }); + + describe("with suggestions from multiple libraries", function () { + var dialog; + var showInLibrary; + + before(async function () { + ({ dialog } = await openDialog([candidate1.id, candidate2.id])); + showInLibrary = sinon.stub(Zotero.Utilities.Internal, 'showInLibrary').resolves(); + await waitForCallback(() => dialog.itemsView.getRowIndexByID(candidate2.id) !== false); + }); + + beforeEach(async function () { + await selectSuggestedItems(dialog); + await dialog.itemsView.setFilter('search', ''); + dialog.document.getElementById('zotero-tb-search').searchTextbox.value = ''; + dialog.itemsView.selection.clearSelection(); + showInLibrary.resetHistory(); + }); + + after(function () { + showInLibrary.restore(); + if (!dialog.closed) { + dialog.close(); + } + }); + + it("should show Suggested Items above the normal collection tree", function () { + assert.equal(dialog.collectionsView.getRow(0).id, 'suggested-items'); + assert.equal(dialog.collectionsView.getRow(0).type, 'suggestedItems'); + assert.isTrue(dialog.collectionsView.getRow(1).isSeparator()); + assert.isNumber( + dialog.collectionsView.getRowIndexByID(`L${Zotero.Libraries.userLibraryID}`) + ); + assert.isNumber(dialog.collectionsView.getRowIndexByID(`C${collection.id}`)); + assert.isFalse(dialog.document.getElementById('zotero-collections-tree-container').hidden); + assert.isFalse(dialog.document.getElementById('search-toolbar').hidden); + }); + + it("should show only regular candidate items", function () { + assert.isNumber(dialog.itemsView.getRowIndexByID(candidate1.id)); + assert.isNumber(dialog.itemsView.getRowIndexByID(candidate2.id)); + assert.strictEqual(dialog.itemsView.getRowIndexByID(childNote.id), false); + assert.strictEqual(dialog.itemsView.getRowIndexByID(excluded.id), false); + }); + + it("should show the Library column before Attachments", async function () { + let libraryColumn = dialog.itemsView._getColumns() + .find(column => column.dataKey === 'library'); + assert.isOk(libraryColumn); + assert.isFalse(libraryColumn.hidden); + assertLibraryColumnPosition(dialog); + + await dialog.itemsView.selectItem(candidate2.id); + let rowIndex = dialog.itemsView.getRowIndexByID(candidate2.id); + let row = dialog.document.getElementById(`${dialog.itemsView.id}-row-${rowIndex}`); + let icon = row.querySelector('.library .icon-library-group'); + assert.isOk(icon); + let iconStyle = dialog.getComputedStyle(icon); + assert.notEqual(iconStyle.backgroundImage, 'none'); + assert.equal(iconStyle.width, '16px'); + assert.equal(iconStyle.height, '16px'); + }); + + it("should enable Show in Library for a selected suggestion", async function () { + let button = dialog.document.querySelector("dialog button[dlgtype='extra1']"); + assert.isFalse(button.hidden); + assert.isTrue(button.disabled); + + await dialog.itemsView.selectItem(candidate2.id); + assert.isFalse(button.disabled); + button.click(); + await waitForCallback(() => showInLibrary.called); + sinon.assert.calledOnceWithExactly(showInLibrary, [candidate2]); + }); + }); + + describe("with suggestions only from My Library", function () { + var dialog; + + before(async function () { + ({ dialog } = await openDialog([candidate1.id])); + await waitForCallback(() => dialog.itemsView.getRowIndexByID(candidate1.id) !== false); + }); + + after(function () { + if (!dialog.closed) { + dialog.close(); + } + }); + + it("should not add the Library column", function () { + let libraryColumn = dialog.itemsView._getColumns() + .find(column => column.dataKey === 'library'); + assert.isUndefined(libraryColumn); + }); + }); +});