mirror of
https://github.com/zotero/zotero.git
synced 2026-10-03 02:21:49 +00:00
Add wizard for managing document items
This commit is contained in:
parent
de6ed1a8f2
commit
f37d75f178
14 changed files with 1511 additions and 217 deletions
|
|
@ -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;
|
||||
},
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
694
chrome/content/zotero/integration/citationExplorerWizard.js
Normal file
694
chrome/content/zotero/integration/citationExplorerWizard.js
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
86
scss/components/_citationExplorerWizard.scss
Normal file
86
scss/components/_citationExplorerWizard.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,4 +44,5 @@
|
|||
|
||||
html|*.wizard-page-box:not(.specificity) {
|
||||
padding: 16px 0;
|
||||
}
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
|
|||
367
test/tests/citationExplorerWizardTest.js
Normal file
367
test/tests/citationExplorerWizardTest.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue