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;