diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 8126912e2d..dfafe9004c 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -47,7 +47,6 @@ const { let io, citations, items, uncitedItems, citationList, itemList; let citationRows = []; let itemRows = []; -let _addToTarget; let disableCitationActivate; const citationColumns = [ @@ -96,22 +95,7 @@ window.ZoteroCitationExplorer = { 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('citationExplorer.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); @@ -188,19 +172,6 @@ window.ZoteroCitationExplorer = { onSelectionChange: this.onItemSelectionChange.bind(this), onActivate: this.onItemActivate.bind(this), emptyMessage: Zotero.getString('pane.items.loading'), - compareItems: (a, b) => { - let getGroupOrder = (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; - }; - return getGroupOrder(a) - getGroupOrder(b); - }, }); await itemList.waitForLoad(); // Remove focus from citationList if focus is on itemList @@ -423,11 +394,8 @@ window.ZoteroCitationExplorer = { let isItemRow = focusedRow instanceof CitationExplorerItemTreeRow; let isUnlinked = isItemRow && !focusedRow.isLinked; let noneItemsSelected = selectedRows.length === 0; - let canRelink = selectedRows.length === 1 && selectedRows[0] === focusedRow && isUnlinked; document.querySelector('#button-show-in-zotero').disabled = noneItemsSelected || !isItemRow || isUnlinked; - document.querySelector('#button-relink-item').disabled = !canRelink; - document.querySelector('#button-addTo-library').disabled = noneItemsSelected; await this.refreshCitationList(); }, @@ -437,7 +405,7 @@ window.ZoteroCitationExplorer = { if (focusedRow instanceof LibraryItemTreeRow) return; if (!focusedRow.isLinked && itemList.selection.count === 1) { - this.onItemRelink(); + this.onManageItems(); } else { let selectedItems = [...itemList.selection.selected] @@ -448,153 +416,22 @@ window.ZoteroCitationExplorer = { } }, - onItemRelink: async function () { - let treeRow = itemList.getRow(itemList.selection.focused); - let oldItemID = treeRow.id; - - let libraryIDs = Zotero.Libraries.getAll() - .filter(library => library.libraryType != 'feed') - .map(library => library.libraryID); - let itemIDs = await new Zotero.Duplicates(libraryIDs).findDuplicatesOf(treeRow.ref); - let io = { - dataIn: null, - dataOut: null, - itemIDs: itemIDs.length ? itemIDs : undefined, - multiSelect: false, - onlyRegularItems: true, - 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; - } - const itemIdx = itemRows.findIndex(row => row.id === oldItemID); - this._linkItem(items[0], oldItemID, itemIdx); - - await this._initMappings(); - await this.refreshCitationList(); - await this.refreshItemList(); - await itemList.selectItem(items[0].id); - }, - - async addToLibraryAndLink() { - var collectionID = _addToTarget.objectType == 'collection' ? _addToTarget.id : undefined; - - // Load library data - let targetLibraryID = _addToTarget.libraryID || _addToTarget.library.libraryID; - let library = Zotero.Libraries.get(targetLibraryID); - if (!library.getDataLoaded('item')) { - Zotero.debug("Waiting for items to load for library " + library.libraryID); - await library.waitForDataLoad('item'); - } - - for (let index of itemList.selection.selected) { - let treeRow = itemList.getRow(index); - if (treeRow instanceof LibraryItemTreeRow) continue; - const oldItemID = treeRow.id; - const itemIdx = itemRows.findIndex(row => row.id === oldItemID); - - // 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('citationExplorer.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(); - - ZoteroCitationExplorer.setAddToTarget(libraryOrCollection); - event.stopPropagation(); - })(); - } - else { - ZoteroCitationExplorer.setAddToTarget(libraryOrCollection); - event.stopPropagation(); - } - } - ); - } - }, - - setAddToTarget(translationTarget) { - _addToTarget = translationTarget; - Zotero.Prefs.set('citationExplorer.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; }, diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index c1de69fac7..79b6da489d 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -90,18 +90,12 @@
-
+
- -
diff --git a/chrome/content/zotero/integration/citationExplorerItemTree.js b/chrome/content/zotero/integration/citationExplorerItemTree.js index 81f4109100..a003e64528 100644 --- a/chrome/content/zotero/integration/citationExplorerItemTree.js +++ b/chrome/content/zotero/integration/citationExplorerItemTree.js @@ -36,6 +36,21 @@ const { ItemTreeRow, ZoteroItemTreeRow } = require('zotero/itemTreeRow'); 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. */ @@ -66,7 +81,7 @@ class LibraryItemTreeRow extends ItemTreeRow { } getField(field) { - if (field === 'title') return this.ref.name; + if (field === 'title') return this.getDisplayTitle(); return ''; } @@ -107,11 +122,6 @@ class UnlinkedItemsTreeRow extends LibraryItemTreeRow { return Zotero.getString('integration.citationExplorer.unlinkedItems'); } - getField(field) { - if (field === 'title') return this.getDisplayTitle(); - return ''; - } - getIcon() { let icon = getCSSIcon('cross'); icon.classList.add('icon-item-type'); @@ -130,6 +140,11 @@ class CitationExplorerItemTreeRow extends ZoteroItemTreeRow { this.cslItemID = cslItemID; } + getField(field) { + if (field === 'action') return ''; + return super.getField(field); + } + get isLinked() { return !this.cslItemID; } @@ -144,10 +159,10 @@ class CitationExplorerItemTreeRow extends ZoteroItemTreeRow { renderPrimaryCell(index, data, column) { let cell = super.renderPrimaryCell(index, data, column); - if (this.citedIn.length === 0) { + if (!this.citedIn.length) { let badge = document.createElement('span'); badge.classList.add('badge', 'badge-uncited'); - badge.textContent = 'Uncited'; + badge.textContent = Zotero.getString('integration.citationExplorer.uncited'); let textSpan = cell.querySelector('.cell-text'); if (textSpan) textSpan.after(badge); else cell.appendChild(badge); @@ -286,6 +301,11 @@ class CitationExplorerRowProvider extends ItemTreeRowProvider { * 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); @@ -301,6 +321,15 @@ class CitationExplorerItemTree extends ItemTree { 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); @@ -315,5 +344,6 @@ module.exports = { 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/xpcom/integration.js b/chrome/content/zotero/xpcom/integration.js index 92757aca74..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,7 +2554,7 @@ 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); diff --git a/chrome/locale/en-US/zotero/integration.ftl b/chrome/locale/en-US/zotero/integration.ftl index cd1142ae5c..819ddd4700 100644 --- a/chrome/locale/en-US/zotero/integration.ftl +++ b/chrome/locale/en-US/zotero/integration.ftl @@ -18,6 +18,33 @@ integration-editBibliography-wrapper = .aria-description = { -integration-editBibliography-include-uncited } { -integration-editBibliography-exclude-cited } { -integration-editBibliography-edit-reference } + +integration-citationExplorerWizard-window = + .title = Add/Link Document Items +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 +179,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.properties b/chrome/locale/en-US/zotero/zotero.properties index defedbc315..1132fde137 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -919,6 +919,7 @@ integration.mendeleyImport.description = %1$S detected that the document you 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/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/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index c270d738f9..63a811f22a 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -1,3 +1,19 @@ +.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; @@ -55,22 +71,6 @@ background: initial !important; } - .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); - } - } - .button-container { border-bottom: var(--material-border-quarternary); display: flex; 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/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/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/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;