Add wizard for managing document items

This commit is contained in:
Adomas Venčkauskas 2026-08-13 10:35:46 +03:00
parent de6ed1a8f2
commit f37d75f178
14 changed files with 1511 additions and 217 deletions

View file

@ -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;
},

View file

@ -90,18 +90,12 @@
</div>
<div class="virtualized-table-container">
<div id="zotero-items-tree"></div>
<div id="zotero-items-tree" class="citation-explorer-item-tree"></div>
</div>
<div class="button-container grid-padded-section">
<input type="button" id="button-show-in-zotero" value="Show in Zotero" disabled="true"/>
<input type="button" id="button-relink-item" class="right" value="Relink Item" disabled="true"/>
<button is="split-menu-button" id="button-addTo-library"
onclick="ZoteroCitationExplorer.addToLibraryAndLink()"
popup="item-addTo-menu" disabled="true"/>
<xul:popupset>
<xul:menupopup id="item-addTo-menu" onpopupshowing="ZoteroCitationExplorer.buildAddToLibraryContextMenu(event);"/>
</xul:popupset>
<input type="button" id="button-manage-items" class="right" value="Add/Link to Library…"/>
</div>
</div>
</xul:dialog>

View file

@ -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,
};

View file

@ -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 <http://www.gnu.org/licenses/>.
***** 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<Number|String, CitationExplorerWizardItemData>} */
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;
},
};

View file

@ -0,0 +1,83 @@
<?xml version="1.0"?>
<!--
***** 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 <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
-->
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/overlay.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/zotero.css"?>
<window
id="citation-explorer-wizard-window"
width="1000"
height="650"
persist="screenX screenY width height"
data-l10n-id="integration-citationExplorerWizard-window"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:html="http://www.w3.org/1999/xhtml"
onload="ZoteroCitationExplorerWizard.init()"
onunload="ZoteroCitationExplorerWizard.unload()"
>
<linkset>
<html:link rel="localization" href="branding/brand.ftl"/>
<html:link rel="localization" href="zotero.ftl"/>
<html:link rel="localization" href="integration.ftl"/>
</linkset>
<script>
Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/customElements.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/integration/citationExplorerWizard.js", this);
</script>
<!-- Add the View menu before ItemTree initializes so it appears on first open -->
<item-tree-menu-bar/>
<wizard id="citation-explorer-wizard" class="citation-explorer-wizard zotero-dialog-window">
<wizardpage pageid="choose-operation" data-header-label-id="integration-citationExplorerWizard-choose-header">
<html:p class="page-description" data-l10n-id="integration-citationExplorerWizard-choose-description"/>
<radiogroup id="item-operation" align="start">
<radio id="item-operation-relink" value="relink" data-l10n-id="integration-citationExplorerWizard-operation-relink"/>
<html:p id="item-operation-relink-description" class="option-description" data-l10n-id="integration-citationExplorerWizard-operation-relink-description"/>
<radio value="addToTarget" data-l10n-id="integration-citationExplorerWizard-operation-add-to-target"/>
<html:p class="option-description" data-l10n-id="integration-citationExplorerWizard-operation-add-to-target-description"/>
</radiogroup>
<vbox class="target-picker">
<label control="item-target" data-l10n-id="integration-citationExplorerWizard-target"/>
<menulist id="item-target" native="true">
<menupopup id="item-target-menu"/>
</menulist>
</vbox>
</wizardpage>
<wizardpage class="review-page" pageid="review-actions" data-header-label-id="integration-citationExplorerWizard-review-header">
<html:p class="page-description" data-l10n-id="integration-citationExplorerWizard-review-description"/>
<html:div class="virtualized-table-container flex">
<html:div id="zotero-items-tree" class="citation-explorer-item-tree"/>
</html:div>
</wizardpage>
</wizard>
</window>

View file

@ -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);

View file

@ -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 }.

View file

@ -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"

View file

@ -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";
@import "elements/fileRenamingSettings";

View file

@ -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;

View file

@ -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;
}
}
}
}

View file

@ -44,4 +44,5 @@
html|*.wizard-page-box:not(.specificity) {
padding: 16px 0;
}
min-height: 0;
}

View file

@ -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'
);
});
});
});
});

View file

@ -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;