This commit is contained in:
Adomas Venčkauskas 2026-09-24 21:10:23 -04:00 • committed by GitHub
commit ea2e511d55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3336 additions and 882 deletions

View file

@ -51,6 +51,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
static defaultProps = {
customRows: [],
dragAndDrop: false,
filterLibraryIDs: false,
hideSources: [],
@ -61,6 +62,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
static propTypes = {
onSelectionChange: PropTypes.func.isRequired,
customRows: PropTypes.array,
dragAndDrop: PropTypes.bool,
filterLibraryIDs: PropTypes.array,
hideSources: PropTypes.array,
@ -491,7 +493,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
ref: ref => this.tree = ref,
treeboxRef: ref => this._treebox = ref,
renderItem: this.renderItem,
alternatingRowColors: null,
onSelectionChange: this._handleSelectionChange,
isSelectable: this.isSelectable,
@ -603,6 +604,26 @@ var CollectionTree = class CollectionTree extends LibraryTree {
);
added += await this._expandRow(newRows, added - 1);
}
// Custom rows, e.g. Suggested Items in select dialog
let customRows = this.props.customRows.map((customRow) => {
let row = new Zotero.CollectionTreeRow(
this,
customRow.type,
customRow.ref,
customRow.level,
customRow.isOpen
);
row._id = customRow.id;
return Object.assign(row, customRow.properties);
});
let addedCustomRows = customRows.length;
if (addedCustomRows) {
customRows.push(new Zotero.CollectionTreeRow(this, 'separator', false, 0));
addedCustomRows++;
newRows.unshift(...customRows);
added += addedCustomRows;
}
this.selection.selectEventsSuppressed = true;
// If the focused row does not match the filter, create a hidden dummy row at the bottom
@ -1504,6 +1525,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
getIconName(index) {
const treeRow = this.getRow(index);
// Custom row icons
if (treeRow.iconName) {
return treeRow.iconName;
}
let collectionType = treeRow.type;
let icon = collectionType;
@ -2025,159 +2050,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return true;
}
/**
* Copy a given item into another library. Used when we need to create a copy of a collection
* in another library if collection is drag-dropped into a group it is not a part of.
*/
async _copyItem({ item, targetLibraryID, targetTreeRow, options }) {
// Check if there's already a copy of this item in the library
var linkedItem = await item.getLinkedItem(targetLibraryID, true);
if (linkedItem) {
return linkedItem.id;
/*
// TODO: support tags, related, attachments, etc.
// Overlay source item fields on unsaved clone of linked item
var newItem = item.clone(false, linkedItem.clone(true));
newItem.setField('dateAdded', item.dateAdded);
newItem.setField('dateModified', item.dateModified);
var diff = newItem.diff(linkedItem, false, ["dateAdded", "dateModified"]);
if (!diff) {
// Check if creators changed
var creatorsChanged = false;
var creators = item.getCreators();
var linkedCreators = linkedItem.getCreators();
if (creators.length != linkedCreators.length) {
Zotero.debug('Creators have changed');
creatorsChanged = true;
}
else {
for (var i=0; i<creators.length; i++) {
if (!creators[i].ref.equals(linkedCreators[i].ref)) {
Zotero.debug('changed');
creatorsChanged = true;
break;
}
}
}
if (!creatorsChanged) {
Zotero.debug("Linked item hasn't changed -- skipping conflict resolution");
continue;
}
}
toReconcile.push([newItem, linkedItem]);
continue;
*/
}
// Standalone attachment
if (item.isAttachment()) {
var linkMode = item.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping standalone linked file attachment on drag");
return false;
}
if (!targetTreeRow.filesEditable) {
Zotero.debug("Skipping standalone file attachment on drag");
return false;
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(item, targetLibraryID);
if (options.annotations) {
await Zotero.Items.copyChildItems(item, newAttachment);
}
return newAttachment.id;
}
// Create new clone item in target library
var newItem = item.clone(targetLibraryID, { skipTags: !options.tags });
var newItemID = await newItem.save({
skipSelect: true
});
// Record link
await newItem.addLinkedItem(item);
if (item.isNote()) {
if (Zotero.Libraries.get(newItem.libraryID).filesEditable) {
await Zotero.Notes.copyEmbeddedImages(item, newItem);
}
return newItemID;
}
// For regular items, add child items if prefs and permissions allow
// Child notes
if (options.childNotes) {
var noteIDs = item.getNotes();
var notes = Zotero.Items.get(noteIDs);
for (let note of notes) {
let newNote = note.clone(targetLibraryID, { skipTags: !options.tags });
newNote.parentID = newItemID;
await newNote.save({
skipSelect: true
})
if (Zotero.Libraries.get(newNote.libraryID).filesEditable) {
await Zotero.Notes.copyEmbeddedImages(note, newNote);
}
await newNote.addLinkedItem(note);
}
}
// Child attachments
if (options.childLinks || options.childFileAttachments) {
var attachmentIDs = item.getAttachments();
var attachments = Zotero.Items.get(attachmentIDs);
for (let attachment of attachments) {
var linkMode = attachment.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping child linked file attachment on drag");
continue;
}
// Skip imported files if we don't have pref and permissions
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
if (!options.childLinks) {
Zotero.debug("Skipping child link attachment on drag");
continue;
}
}
else {
if (!options.childFileAttachments
|| (!targetTreeRow.filesEditable && !targetTreeRow.isPublications())) {
Zotero.debug("Skipping child file attachment on drag");
continue;
}
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(
attachment, targetLibraryID, newItemID
);
if (options.annotations) {
await Zotero.Items.copyChildItems(attachment, newAttachment);
}
}
}
return newItemID;
}
/**
* Helper function used by executeCollectionCopy to recursively copy collections from one library
* into another, or from one collection to another within the same library.
*/
async _copyCollections({ descendents, parentID, addItems, targetLibraryID, targetTreeRow, copyOptions }) {
async _copyCollections({ descendents, parentID, addItems, targetLibraryID, copyOptions }) {
for (var desc of descendents) {
// Collections
if (desc.type == 'collection') {
@ -2199,7 +2076,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
parentID: collectionID,
addItems,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
@ -2210,12 +2086,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
let id = desc.id;
// Actually copy items only if moving to another library
if (item.libraryID !== targetLibraryID) {
id = await this._copyItem({
item,
targetLibraryID,
targetTreeRow,
options: copyOptions
});
let copiedItem = await Zotero.Items.copyToLibrary(
item, targetLibraryID, copyOptions
);
id = copiedItem && copiedItem.id;
// Standalone attachments might not get copied
if (!id) {
continue;
@ -2254,10 +2128,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
* @param {Zotero.Collection} collection - collection to copy
* @param {String} targetCollectionID - id of the collection to copy to
* @param {String} targetLibraryID - id of the library to copy to
* @param {Zotero.CollectionTreeRow } targetTreeRow - tree row of the target
* @param {Object} copyOptions - options how to perform the copy - see onDrop for an example
*/
async executeCollectionCopy({ collection, targetCollectionID, targetLibraryID, targetTreeRow, copyOptions }) {
async executeCollectionCopy({ collection, targetCollectionID, targetLibraryID, copyOptions }) {
await Zotero.DB.executeTransaction(async () => {
var collections = [{
id: collection.id,
@ -2271,7 +2144,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
parentID: targetCollectionID,
addItems,
targetLibraryID,
targetTreeRow,
copyOptions
});
for (let [collectionID, items] of addItems.entries()) {
@ -2371,7 +2243,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
@ -2490,12 +2361,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return Zotero.DB.executeTransaction(async () => {
let copiedItemIDs = [];
for (let item of chunk) {
var id = await this._copyItem({
item,
targetLibraryID,
targetTreeRow,
options: copyOptions
});
let copiedItem = await Zotero.Items.copyToLibrary(
item, targetLibraryID, copyOptions
);
let id = copiedItem && copiedItem.id;
// Standalone attachments might not get copied
if (!id) {
continue;

View file

@ -63,6 +63,7 @@ module.exports = class {
this.scrollDirection = 0;
this.scrollOffset = 0;
this.overscanCount = 2;
this._invalidating = false;
this._lastItemCount = null;
this._rowOffsets = [[0, 0]];
@ -113,6 +114,8 @@ module.exports = class {
* Rerender items within the scrollbox. Call sparingly
*/
invalidate() {
if (this._invalidating) return;
this._invalidating = true;
// Removes any items out of view and adds the ones not in view
let oldRenderedRows = new Set(this._renderedRows.keys());
this.render();
@ -122,6 +125,7 @@ module.exports = class {
if (!oldRenderedRows.has(index)) continue;
this.rerenderItem(index);
}
this._invalidating = false;
}
/**

View file

@ -58,6 +58,20 @@
connectedCallback() {
this.append(this.contentFragment);
// Prevent DOM-attached mouse handlers from running in the dropmarker area
for (const eventType of ['mousedown', 'mouseup', 'click']) {
const handler = this.getAttribute('on' + eventType);
if (!handler) {
continue;
}
this['on' + eventType] = null;
this.addEventListener(eventType, (event) => {
if (!this._isEventInDropmarkerBox(event)) {
eval(handler).bind(this);
}
});
}
}
disconnectedCallback() {

View file

@ -703,7 +703,6 @@ class LibraryLayout extends Layout {
this.itemsView = await CollectionViewItemTree.init(itemsTree, {
id: "citationDialog",
dragAndDrop: DIALOG_STATE.isCitingItems(),
persistColumns: true,
columnPicker: true,
onSelectionChange: () => {
libraryLayout.updateSelectedItems();

View file

@ -27,67 +27,65 @@ const React = require('react');
const ReactDOM = require('react-dom');
const diff = require('diff');
const VirtualizedTable = require('components/virtualized-table');
const { getCSSIcon, IconAttachSmall } = require('components/icons');
// TODO: Create a custom row provider for citationExplorer to use with base ItemTree.
// Currently uses changeCollectionTreeRow which only exists on CollectionViewItemTree,
// so this is broken until we either switch to CollectionViewItemTree or create a
// simple row provider that can display arbitrary items.
const ItemTree = require('zotero/itemTree');
const { getColumnDefinitionsByDataKey } = require('zotero/itemTreeColumns');
const { getCSSIcon, CSSIcon } = require('components/icons');
const { COLUMNS } = require('zotero/itemTreeColumns');
const { makeRowRenderer } = VirtualizedTable;
const {
CitationExplorerItemTree,
CitationExplorerItemTreeRow,
LibraryItemTreeRow,
UnlinkedItemsTreeRow,
UNLINKED_ITEMS_ID,
} = require('zotero/integration/citationExplorerItemTree');
// ////////////////////////////////////////////////////////////////////////////
//
// Citation Explorer UI
//
// ////////////////////////////////////////////////////////////////////////////
let io, citations, items, uncitedItems, citationList, itemList;
let citationRows = [];
let itemRows = [];
let uncitedItemRows = [];
let _addToTarget;
let disableCitationActivate;
let selectedTab = 0;
const citationColumns = [
{
dataKey: 'title',
label: 'integration-citationExplorer-citation-column',
type: 'html'
},
{
dataKey: 'isLinked',
label: 'Is Linked',
iconLabel: <IconAttachSmall/>,
label: 'integration-citationExplorer-linked-column',
iconLabel: <CSSIcon name="link" className="icon-16"/>,
width: 26,
staticWidth: true,
fixedWidth: true,
renderCell: (index, data, column) => {
let icon = getCSSIcon('IconCross');
let icon = getCSSIcon('cross');
if (data) {
icon = getCSSIcon('IconTick');
icon = getCSSIcon('tick');
}
icon.className += ` cell ${column.className}`;
icon.className += ` cell icon-16 ${column.className}`;
return icon;
}
},
{
dataKey: 'title',
label: "Citation",
type: 'html'
},
];
let itemColumns = getColumnDefinitionsByDataKey(['title', 'firstCreator', 'date']);
itemColumns.push({
dataKey: 'isLinked',
label: 'Is Linked',
iconLabel: <IconAttachSmall/>,
width: 26,
staticWidth: true,
fixedWidth: true,
renderCell: (index, data, column) => {
let icon = getCSSIcon('IconCross');
if (data) {
icon = getCSSIcon('IconTick');
}
icon.className += ` cell ${column.className}`;
return icon;
}
});
itemColumns[1].sortDirection = 1;
// All standard Zotero columns, with only title/firstCreator/year visible by default.
const defaultVisibleColumns = new Set(['title', 'firstCreator', 'year']);
let itemColumns = COLUMNS
.filter(col => col.dataKey !== 'feed')
.map(col => Object.assign({}, col, {
hidden: !defaultVisibleColumns.has(col.dataKey),
}));
// Default sort by creator ascending
let creatorCol = itemColumns.find(c => c.dataKey === 'firstCreator');
if (creatorCol) creatorCol.sortDirection = 1;
window.ZoteroDocumentCitations = {
window.ZoteroCitationExplorer = {
init: async function () {
this._highlightedCitations = new Set();
this._filteredCitations = new Set();
@ -97,27 +95,28 @@ window.ZoteroDocumentCitations = {
document.querySelector('#button-edit-citation').addEventListener('click', this.onCitationEdit.bind(this));
document.querySelector('#button-show-in-zotero').addEventListener('click', this.onItemActivate.bind(this));
document.querySelector('#button-relink-item').addEventListener('click', this.onItemRelink.bind(this));
let lastTranslationTarget = Zotero.Prefs.get('documentCitations.lastAddToTarget');
if (lastTranslationTarget) {
let id = parseInt(lastTranslationTarget.substr(1));
if (lastTranslationTarget[0] == "L") {
_addToTarget = Zotero.Libraries.get(id);
}
else if (lastTranslationTarget[0] == "C") {
_addToTarget = Zotero.Collections.get(id);
}
}
if (!_addToTarget) {
_addToTarget = Zotero.Libraries.userLibrary;
}
this.setAddToButton();
document.querySelector('#button-manage-items').addEventListener('click', this.onManageItems.bind(this));
io = window.arguments[0].wrappedJSObject;
citations = Object.values(io.citations);
items = io.items;
uncitedItems = io.uncitedItems;
// Load library data for all items
let librariesNeeded = new Set();
for (let item of [...items, ...uncitedItems]) {
if (item.libraryID) {
librariesNeeded.add(item.libraryID);
}
}
for (let libraryID of librariesNeeded) {
let library = Zotero.Libraries.get(libraryID);
if (!library.getDataLoaded('item')) {
Zotero.debug("Waiting for items to load for library " + library.libraryID);
await library.waitForDataLoad('item');
}
}
await this._initMappings();
await this.refreshCitationList();
await this.refreshItemList();
@ -132,8 +131,9 @@ window.ZoteroDocumentCitations = {
// init VirtualizedTable
if (!citationList) {
await new Promise((resolve) => {
ReactDOM.createRoot(document.querySelector('#citation-list')).render(<VirtualizedTable
id="citation-list"
const domElem = document.querySelector('#citation-list-container');
ReactDOM.createRoot(domElem).render(<VirtualizedTable
id="citation-explorer-citations"
ref={(ref) => {
citationList = ref;
resolve();
@ -148,36 +148,41 @@ window.ZoteroDocumentCitations = {
onSelectionChange={this.onCitationSelectionChange.bind(this)}
getRowString={index => this._renderedCitationRows[index].title}
/>);
// Remove focus from itemList if focus is on citationList
// to prevent highlighting in both lists
domElem.addEventListener("focusin", (event) => {
itemList?.selection.clearSelection()
});
});
}
citationList.invalidate();
},
refreshItemList: async function () {
let rows = selectedTab === 0 ? itemRows : uncitedItemRows;
rows.forEach((item) => {
item.isLinked = !item.cslItemID;
});
let filteredRows = itemRows.filter(row => !this._filteredItems.has(row.id));
let filteredItems = rows.filter(item => !this._filteredItems.has(item.id));
if (!itemList) {
let domElem = document.querySelector('#item-list');
itemList = await ItemTree.init(domElem, {
id: "document-collections",
regularOnly: true,
let domElem = document.querySelector('#zotero-items-tree');
itemList = await CitationExplorerItemTree.init(domElem, {
id: "citation-explorer-items",
regularOnly: false,
columns: itemColumns,
columnPicker: true,
shouldListenForNotifications: false,
onSelectionChange: this.onItemSelectionChange.bind(this),
onActivate: this.onItemActivate.bind(this),
emptyMessage: Zotero.getString('pane.items.loading')
emptyMessage: Zotero.getString('pane.items.loading'),
});
await itemList.waitForLoad();
// Remove focus from citationList if focus is on itemList
// to prevent highlighting in both lists
domElem.addEventListener("focusin", () => {
citationList?.selection.clearSelection();
});
document.querySelector("item-tree-menu-bar").init(itemList);
}
await itemList.changeCollectionTreeRow({
getItems: async () => filteredItems,
isSearch: () => true,
isSearchMode: () => true,
});
await itemList.setItems(filteredRows);
},
onCitationFilter: async function () {
@ -194,8 +199,8 @@ window.ZoteroDocumentCitations = {
onItemFilter: async function () {
let searchString = this._normalizeSearch(document.querySelector('#item-search').value);
let itemStrings = itemRows.map((item) => {
return [item.getField('title'), item.getField('firstCreator'), item.getField('date')].join(' ');
let itemStrings = itemRows.map((row) => {
return [row.ref.getField('title'), row.ref.getField('firstCreator'), row.ref.getField('date')].join(' ');
});
this._filteredItems = new Set();
itemStrings.forEach((str, index) => {
@ -206,68 +211,40 @@ window.ZoteroDocumentCitations = {
await this.refreshItemList();
},
onSelectTab: async function (selectedIndex) {
if (selectedIndex === selectedTab) return;
selectedTab = selectedIndex;
if (selectedTab) {
this._highlightedCitations = new Set();
document.querySelector('#button-show-in-zotero').hidden = true;
document.querySelector('#button-relink-item').hidden = false;
document.querySelector('#button-addTo-library').style.display = 'none';
}
await this.refreshCitationList();
await this.refreshItemList();
},
_initMappings: async function () {
const itemMap = {};
itemRows = items.map((item) => {
let citedIn = [];
return new Proxy(item, {
get(target, prop) {
if (prop == 'id' && !target.id) {
return target.cslItemID;
}
if (prop == 'citedIn') {
return citedIn;
}
return Reflect.get(...arguments);
}
});
});
uncitedItemRows = uncitedItems.map((item) => {
return new Proxy(item, {
get(target, prop) {
if (prop == 'citedIn') {
return [];
}
return Reflect.get(...arguments);
}
});
let cslItemID = item.id ? null : item.cslItemID;
let row = new CitationExplorerItemTreeRow(item, 0, false, { cslItemID });
itemMap[item.id || item.cslItemID] = row;
return row;
});
// Add uncited items
for (let item of uncitedItems) {
let cslItemID = item.id ? null : item.cslItemID;
let row = new CitationExplorerItemTreeRow(item, 0, false, { citedIn: [], cslItemID });
itemRows.push(row);
}
// Build citation rows and populate citedIn
citationRows = await Promise.all(citations
.map(async (citation, citationIndex) => {
let isLinked = true;
let citedItems = [];
// check if all citation items are linked
for (let citationItem of citation.citationItems) {
itemRows.forEach((itemRow, itemIndex) => {
if ([itemRow.id, itemRow.cslItemID].includes(citationItem.id)) {
citedItems.push(itemIndex);
itemRow.citedIn.push(citationIndex);
}
});
if (typeof citationItem.id != 'number') {
isLinked = false;
break;
let key = citationItem.id || citationItem.cslItemID;
let row = itemMap[key];
if (row) {
row.citedIn.push(citationIndex);
}
isLinked = isLinked && typeof citationItem.id == 'number';
}
let title = await citation.field.getText();
if (citation.properties.plainCitation != title) {
let d = diff(citation.properties.plainCitation, title);
title = d.map(([type, text]) => {
if (type == 0) return text;
if (type == -1) return `<span style="color: red; text-decoration: line-through">${text}</span>`;
if (type == 1) return `<span style="color: green">${text}</span>`;
if (type == -1) return `<span class="diff-deleted">${text}</span>`;
if (type == 1) return `<span class="diff-added">${text}</span>`;
}).join('');
}
return {
@ -296,7 +273,9 @@ window.ZoteroDocumentCitations = {
return;
}
}
catch (e) { }
catch (e) {
Zotero.logError(e);
}
// An error got thrown or wrong citation got activated, which means that some citations got deleted
// and now the citation explorer dialog is not showing correct citations and citation
// activation is not going to work right.
@ -328,6 +307,9 @@ window.ZoteroDocumentCitations = {
}
}
itemList.setHighlightedRows(highlightedItems);
const noneSelected = citationList.selection.selected.size === 0;
document.querySelector('#button-show-in-document').disabled = noneSelected;
document.querySelector('#button-edit-citation').disabled = noneSelected;
},
onCitationEdit: async function () {
@ -342,158 +324,114 @@ window.ZoteroDocumentCitations = {
* @private
*/
onItemSelectionChange: async function () {
if (selectedTab === 1) return;
const getGroupKey = (row) => {
if (!row) return null;
if (row instanceof UnlinkedItemsTreeRow) return UNLINKED_ITEMS_ID;
if (row instanceof CitationExplorerItemTreeRow) {
return row.isLinked
? (row.ref.libraryID ?? Zotero.Libraries.userLibraryID)
: UNLINKED_ITEMS_ID;
}
if (row instanceof LibraryItemTreeRow) {
return row.ref.libraryID ?? Zotero.Libraries.userLibraryID;
}
return null;
};
let selectedIndexes = [...itemList.selection.selected];
if (selectedIndexes.length > 1) {
let focusedIndex = itemList.selection.focused;
if (focusedIndex == null || focusedIndex < 0 || !itemList.getRow(focusedIndex)) {
focusedIndex = selectedIndexes[0];
}
let focusedRow = itemList.getRow(focusedIndex);
let allowedIndexes = [];
// Deny multi-selection that includes container rows
if (focusedRow instanceof LibraryItemTreeRow) {
allowedIndexes = [focusedIndex];
}
else {
let focusedGroup = getGroupKey(focusedRow);
allowedIndexes = selectedIndexes.filter((index) => {
let row = itemList.getRow(index);
return row instanceof CitationExplorerItemTreeRow
&& getGroupKey(row) === focusedGroup;
});
}
if (!allowedIndexes.length) {
allowedIndexes = [focusedIndex];
}
if (allowedIndexes.length !== selectedIndexes.length) {
let oldSelected = new Set(itemList.selection.selected);
itemList.selection.selected = new Set(allowedIndexes);
if (!allowedIndexes.includes(focusedIndex)) {
itemList.selection.focused = allowedIndexes[0] ?? focusedIndex;
}
itemList.selection.pivot = itemList.selection.focused;
let toInvalidate = new Set([...oldSelected, ...allowedIndexes]);
for (let index of toInvalidate) {
itemList.tree?.invalidateRow(index);
}
selectedIndexes = allowedIndexes;
}
}
let selectedRows = selectedIndexes
.map(index => itemList.getRow(index))
.filter(row => row instanceof CitationExplorerItemTreeRow);
this._highlightedCitations = new Set();
for (let selectedItemIndex of itemList.selection.selected) {
for (let citationIndex of itemRows[selectedItemIndex].citedIn) {
for (let row of selectedRows) {
for (let citationIndex of row.citedIn) {
this._highlightedCitations.add(citations[citationIndex].citationID);
}
}
const item = itemList.getRow(itemList.selection.focused).ref;
const isUnlinked = typeof item.id != 'number';
const isMultiple = itemList.selection.selected.size > 1;
document.querySelector('#button-show-in-zotero').hidden = isMultiple || isUnlinked;
document.querySelector('#button-relink-item').hidden = isMultiple || !isUnlinked;
document.querySelector('#button-addTo-library').style.display = (isMultiple || !isUnlinked) ? 'none' : 'inherit';
let focusedRow = itemList.getRow(itemList.selection.focused);
let isItemRow = focusedRow instanceof CitationExplorerItemTreeRow;
let isUnlinked = isItemRow && !focusedRow.isLinked;
let noneItemsSelected = selectedRows.length === 0;
document.querySelector('#button-show-in-zotero').disabled = noneItemsSelected || !isItemRow || isUnlinked;
await this.refreshCitationList();
},
onItemActivate: async function () {
if (itemList.selection.selected.size > 1) return;
const item = itemList.getRow(itemList.selection.focused).ref;
if (typeof item.id != 'number') {
this.onItemRelink();
let focusedRow = itemList.getRow(itemList.selection.focused);
if (focusedRow instanceof LibraryItemTreeRow) return;
if (!focusedRow.isLinked && itemList.selection.count === 1) {
this.onManageItems();
}
else {
await Zotero.Utilities.Internal.showInLibrary(item);
let selectedItems = [...itemList.selection.selected]
.map(index => itemList.getRow(index))
.filter(row => row instanceof CitationExplorerItemTreeRow)
.map(row => row.ref);
await Zotero.Utilities.Internal.showInLibrary(selectedItems);
}
},
onItemRelink: async function () {
let io = { dataIn: null, dataOut: null, multiSelect: false, deferred: Zotero.Promise.defer() };
window.openDialog('chrome://zotero/content/selectItemsDialog.xhtml', '',
'chrome,dialog=no,centerscreen,resizable=yes', io);
await io.deferred.promise;
if (!io.dataOut || !io.dataOut.length) {
return;
onManageItems: async function () {
let wizardIO = Object.assign({}, io, {
completed: false,
deferred: Zotero.Promise.defer(),
});
wizardIO.wrappedJSObject = wizardIO;
window.openDialog(
'chrome://zotero/content/integration/citationExplorerWizard.xhtml',
'',
'chrome,dialog=no,centerscreen,resizable=yes',
wizardIO
);
await wizardIO.deferred.promise;
if (wizardIO.completed) {
window.close();
}
let items = await Zotero.Items.getAsync(io.dataOut);
if (!items.length) {
return;
}
let treeRow = itemList.getRow(itemList.selection.focused);
const oldItemID = treeRow.id;
const itemIdx = itemRows.findIndex(row => row.id === treeRow.id);
this._linkItem(items[0], oldItemID, itemIdx);
await this._initMappings();
await this.refreshCitationList();
await this.refreshItemList();
},
async addToLibraryAndLink() {
var collectionID = _addToTarget.objectType == 'collection' ? _addToTarget.id : undefined;
for (let index of itemList.selection.selected) {
let treeRow = itemList.getRow(index);
const oldItemID = treeRow.id;
const itemIdx = itemRows.findIndex(row => row.id === treeRow.id);
// Save item
let item = treeRow.ref.clone(_addToTarget.libraryID);
if (collectionID) {
item.addToCollection(collectionID);
}
await item.saveTx();
this._linkItem(item, oldItemID, itemIdx);
}
await this._initMappings();
await this.refreshCitationList();
await this.refreshItemList();
},
_linkItem(item, oldItemID, itemIdx) {
// For all citations where the item is cited
for (let citationIndex of itemRows[itemIdx].citedIn) {
let citation = citations[citationIndex];
let citationItemIdx = citation.citationItems.findIndex(i => i.id == oldItemID);
let citationItem = citation.citationItems[citationItemIdx];
// Update the citation with the new item
citationItem.id = item.id;
citationItem.uris = Zotero.Integration.currentSession.uriMap.getURIsForItemID(citationItem.id);
// Mark citation for an update with citeproc and write changes to doc
io.updateIndex(citationIndex);
}
items[itemIdx] = item;
},
buildAddToLibraryContextMenu(event) {
var menu = document.querySelector('#item-addTo-menu');
// Don't trigger rebuilding on nested popupmenu open/close
if (event.target != menu) {
return;
}
// Clear previous items
while (menu.firstChild) {
menu.removeChild(menu.firstChild);
}
let target = Zotero.Prefs.get('documentCitations.lastAddToTarget');
if (!target) {
target = "L" + Zotero.Libraries.userLibraryID;
}
var libraries = Zotero.Libraries.getAll();
for (let library of libraries) {
if (!library.editable || library.libraryType == 'publications') {
continue;
}
Zotero.Utilities.Internal.createMenuForTarget(
library,
menu,
target,
function(event, libraryOrCollection) {
if (event.target.tagName == 'menu') {
Zotero.Promise.coroutine(function* () {
// Simulate menuitem flash on OS X
if (Zotero.isMac) {
event.target.setAttribute('_moz-menuactive', false);
yield Zotero.Promise.delay(50);
event.target.setAttribute('_moz-menuactive', true);
yield Zotero.Promise.delay(50);
event.target.setAttribute('_moz-menuactive', false);
yield Zotero.Promise.delay(50);
event.target.setAttribute('_moz-menuactive', true);
}
menu.hidePopup();
ZoteroDocumentCitations.setAddToTarget(libraryOrCollection);
event.stopPropagation();
})();
}
else {
ZoteroDocumentCitations.setAddToTarget(libraryOrCollection);
event.stopPropagation();
}
}
);
}
},
setAddToTarget(translationTarget) {
_addToTarget = translationTarget;
Zotero.Prefs.set('documentCitations.lastAddToTarget', translationTarget.treeViewID);
this.setAddToButton();
},
setAddToButton() {
var label = Zotero.getString('pane.item.addTo', _addToTarget.name);
var elem = document.querySelector('#button-addTo-library');
elem.label = label;
elem.title = label;
elem.image = _addToTarget.treeViewImage;
},
@ -510,5 +448,5 @@ window.ZoteroDocumentCitations = {
};
window.addEventListener('DOMContentLoaded', function () {
ZoteroDocumentCitations.init();
});
ZoteroCitationExplorer.init();
});

View file

@ -25,76 +25,78 @@
***** END LICENSE BLOCK *****
-->
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/integration.css" 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"?>
<?xml-stylesheet href="chrome://zotero/skin/integration.css" type="text/css"?>
<!DOCTYPE window SYSTEM "chrome://zotero/locale/zotero.dtd">
<xul:window
id="zotero-citation-explorer-dialog"
class="zotero-dialog-window"
orient="vertical"
title="Citation Explorer"
width="750" height="450"
data-l10n-id="integration-citationExplorer-window"
width="800" height="600"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
persist="screenX screenY width height"
resizable="true">
<!-- do not wait for itemTree to add the menubar, otherwise it does not appear on the first dialog open -->
<xul:item-tree-menu-bar/>
<xul:dialog
buttons="accept" buttonpack="end">
<linkset>
<link rel="localization" href="browser/menubar.ftl"/>
<link rel="localization" href="browser/browserSets.ftl"/>
<link rel="localization" href="zotero.ftl"/>
<link rel="localization" href="reader.ftl"/>
<link rel="localization" href="integration.ftl"/>
</linkset>
<script>
var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
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/citationExplorer.js", this);
</script>
<div class="vbox flex xul-border-div">
<div class="hbox flex">
<div class="vbox flex">
<div id="citations-label">Citations</div>
<div class="vbox flex panel" style="border-right: none">
<xul:search-textbox id="citation-search" class="search-box" timeout="250" oncommand="ZoteroDocumentCitations.onCitationFilter()" dir="reverse"/>
<div id="citation-list-container" class="virtualized-table-container">
<div id="citation-list"></div>
</div>
<div class="hbox button-container">
<input type="button" id="button-show-in-document" value="Show in Document"/>
<input type="button" id="button-edit-citation" value="Edit Citation"/>
</div>
</div>
</div>
<div class="splitter"></div>
<div class="vbox flex">
<xul:tabbox onselect="ZoteroDocumentCitations.onSelectTab(this.selectedIndex)">
<xul:tabs>
<xul:tab id="tab-cited-items" label="Cited Items"/>
<xul:tab id="tab-uncited-items" label="Uncited Items"/>
</xul:tabs>
<!--We are making our own tabpanel UI, but the onselect event above won't fire without these-->
<xul:tabpanels hidden="true"><xul:tabpanel/><xul:tabpanel/></xul:tabpanels>
</xul:tabbox>
<div id="items-panel" class="vbox flex panel">
<xul:search-textbox id="item-search" class="search-box" timeout="250" oncommand="ZoteroDocumentCitations.onItemFilter()" dir="reverse"/>
<div id="item-list-container" class="virtualized-table-container">
<div id="item-list"></div>
</div>
<div class="hbox button-container">
<input type="button" id="button-show-in-zotero" value="Show in Zotero"/>
<input type="button" id="button-relink-item" value="Relink Item" hidden="true"/>
<button is="split-menu-button" id="button-addTo-library"
onclick="ZoteroDocumentCitations.addToLibraryAndLink()"
popup="item-addTo-menu" style="display: none"/>
<xul:popupset>
<xul:menupopup id="item-addTo-menu" onpopupshowing="ZoteroDocumentCitations.buildAddToLibraryContextMenu(event);"/>
</xul:popupset>
</div>
</div>
</div>
<div class="citation-explorer-grid">
<!-- Left Column -->
<div class="citation-explorer-grid-left grid-padded-section" id="citations-label" data-l10n-id="integration-citationExplorer-citations"></div>
<div class="searchbox-container citation-explorer-grid-left grid-padded-section">
<xul:search-textbox id="citation-search" data-l10n-id="integration-citationExplorer-citation-search" data-l10n-attrs="placeholder" class="search-box" timeout="250" oncommand="ZoteroCitationExplorer.onCitationFilter()" dir="reverse"/>
</div>
<div class="virtualized-table-container citation-explorer-grid-left">
<div id="citation-list-container"></div>
</div>
<div class="button-container citation-explorer-grid-left grid-padded-section">
<input type="button" id="button-show-in-document" data-l10n-id="integration-citationExplorer-show-in-document" disabled="true"/>
<input type="button" id="button-edit-citation" class="right" data-l10n-id="integration-citationExplorer-edit-citation" disabled="true"/>
</div>
<!-- Right Column -->
<div class="grid-padded-section" id="items-in-bibliography-label" data-l10n-id="integration-citationExplorer-items-in-bibliography"></div>
<div class="searchbox-container grid-padded-section">
<xul:search-textbox id="item-search" data-l10n-id="integration-citationExplorer-item-search" data-l10n-attrs="placeholder" class="search-box" timeout="250" oncommand="ZoteroCitationExplorer.onItemFilter()" dir="reverse"/>
</div>
<div class="virtualized-table-container">
<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" data-l10n-id="integration-citationExplorer-show-in-zotero" disabled="true"/>
<input type="button" id="button-manage-items" class="right" data-l10n-id="integration-citationExplorer-manage-items"/>
</div>
</div>
</xul:dialog>

View file

@ -0,0 +1,349 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2023 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
const { getCSSIcon } = require('components/icons');
const ItemTree = require('zotero/itemTree');
const { ItemTreeRowProvider } = ItemTree;
const { ItemTreeRow, ZoteroItemTreeRow } = require('zotero/itemTreeRow');
// ////////////////////////////////////////////////////////////////////////////
//
// Citation Explorer Row Classes
//
// ////////////////////////////////////////////////////////////////////////////
const UNLINKED_ITEMS_ID = 'UNLINKED_ITEMS';
/**
* Sort unlinked items first, then libraries by libraryID.
* Used as CitationExplorerItemTree's default compareItems().
*/
function getLibraryGroupOrder(row) {
if (row.ref?.treeViewID === UNLINKED_ITEMS_ID
|| (row.ref?.cslItemID && !row.ref?.id)) {
return -1;
}
if (row.ref instanceof Zotero.Library) {
return row.ref.libraryID ?? Zotero.Libraries.userLibraryID;
}
return row.ref?.libraryID ?? Zotero.Libraries.userLibraryID;
}
/**
* Container row wrapping a Zotero.Library.
*/
class LibraryItemTreeRow extends ItemTreeRow {
constructor(library, items, isOpen = true) {
super(library, 0, isOpen); // library has treeViewID = "L<id>"
this._items = items;
}
get type() {
return 'library';
}
get sortChildren() {
return true;
}
isContainer() {
return true;
}
isContainerEmpty() {
return this._items.length === 0;
}
getChildItems() {
return this._items;
}
getField(field) {
if (field === 'title') return this.getDisplayTitle();
return '';
}
getDisplayTitle() {
return this.ref.name;
}
getIcon() {
let library = this.ref;
let iconKey = 'library';
if (library.libraryType === 'group') iconKey = 'library-group';
else if (library.libraryType === 'publications') iconKey = 'publications';
let icon = getCSSIcon(iconKey);
icon.classList.add('icon-item-type');
return icon;
}
renderRow(div, index, columns, rowData, renderCtx) {
let titleColumn = columns.find(c => c.dataKey === 'title') || columns[0];
let cell = renderCtx.renderCell(index, this.getDisplayTitle(), titleColumn, true);
div.appendChild(cell);
}
}
/**
* Pseudo-library container for unlinked CSL-only items.
*/
class UnlinkedItemsTreeRow extends LibraryItemTreeRow {
constructor(items, isOpen = true) {
super({ id: UNLINKED_ITEMS_ID, treeViewID: UNLINKED_ITEMS_ID }, items, isOpen);
}
get type() {
return 'unlinked-items';
}
getDisplayTitle() {
return Zotero.getString('integration.citationExplorer.unlinkedItems');
}
getIcon() {
let icon = getCSSIcon('cross');
icon.classList.add('icon-item-type');
return icon;
}
}
/**
* Item row with citation-tracking metadata and "Uncited" badge.
*/
class CitationExplorerItemTreeRow extends ZoteroItemTreeRow {
constructor(ref, level, isOpen, { citedIn = [], cslItemID = null } = {}) {
let id = ref.treeViewID ?? cslItemID ?? 'csl-' + Zotero.Utilities.randomString(8);
super(ref, level, isOpen, id);
this.citedIn = citedIn;
this.cslItemID = cslItemID;
}
getField(field) {
if (field === 'action') return '';
return super.getField(field);
}
get isLinked() {
return !this.cslItemID;
}
isContainer() {
return false;
}
isContainerEmpty() {
return true;
}
renderPrimaryCell(index, data, column) {
let cell = super.renderPrimaryCell(index, data, column);
if (!this.citedIn.length) {
let badge = document.createElement('span');
badge.classList.add('badge', 'badge-uncited');
badge.textContent = Zotero.getString('integration.citationExplorer.uncited');
let textSpan = cell.querySelector('.cell-text');
if (textSpan) textSpan.after(badge);
else cell.appendChild(badge);
}
return cell;
}
}
// ////////////////////////////////////////////////////////////////////////////
//
// Citation Explorer Row Provider
//
// ////////////////////////////////////////////////////////////////////////////
/**
* Row provider that groups items by library with expandable containers.
* Maintains lookup maps so row metadata survives collapse/re-expand.
*/
class CitationExplorerRowProvider extends ItemTreeRowProvider {
constructor(itemTree) {
super(itemTree);
this._sourceItems = [];
this._unlinkedItems = [];
this._citedInByID = new Map();
this._cslItemIDByID = new Map();
this._containerOpenState = new Map();
}
/**
* Override createRow() — the factory hook.
* Called by _toggleOpenState() on collapse/re-expand.
* Reconstructs rows with metadata from lookup maps.
*/
createRow(ref, level, isOpen) {
if (ref.treeViewID === UNLINKED_ITEMS_ID) {
return new UnlinkedItemsTreeRow(this._unlinkedItems, isOpen);
}
if (ref instanceof Zotero.Library) {
let items = this._sourceItems.filter((item) => {
let key = item.treeViewID ?? item.cslItemID;
return (item.libraryID ?? Zotero.Libraries.userLibraryID) === ref.libraryID
&& !this._cslItemIDByID.has(key);
});
return new LibraryItemTreeRow(ref, items, isOpen);
}
let key = ref.treeViewID ?? ref.cslItemID;
return new CitationExplorerItemTreeRow(ref, level, isOpen, {
citedIn: this._citedInByID.get(key) || [],
cslItemID: this._cslItemIDByID.get(key) || null,
});
}
/**
* Replace displayed items. Groups by library, sorts, and updates view.
* @param {CitationExplorerItemTreeRow[]} itemRows
*/
async setItems(itemRows) {
this._citedInByID = new Map();
this._cslItemIDByID = new Map();
this._sourceItems = [];
for (let row of itemRows) {
let key = row.id;
this._citedInByID.set(key, row.citedIn);
if (row.cslItemID) this._cslItemIDByID.set(key, row.cslItemID);
this._sourceItems.push(row.ref);
}
this._rebuildRows();
await this.itemTree._ensureSortContextReady();
// _sort() builds the comparator, sorts top-level rows, then
// _restoreOpenState() reopens library containers with children
// sorted by the same comparator via _toggleOpenState()
this._sort(null);
await this.runListeners('update', true, { restoreSelection: false });
}
/**
* Build _rows from _sourceItems. Use grouped container mode when there are
* multiple libraries OR any unlinked items.
*/
_rebuildRows() {
for (let row of this._rows) {
if (row instanceof LibraryItemTreeRow) {
this._containerOpenState.set(row.id, row.isContainerOpen());
}
}
const byLibrary = new Map();
this._unlinkedItems = [];
for (const item of this._sourceItems) {
let key = item.treeViewID ?? item.cslItemID;
if (this._cslItemIDByID.has(key)) {
this._unlinkedItems.push(item);
continue;
}
const libID = item.libraryID ?? Zotero.Libraries.userLibraryID;
if (!byLibrary.has(libID)) byLibrary.set(libID, []);
byLibrary.get(libID).push(item);
}
const sortedLibIDs = [...byLibrary.keys()].sort((a, b) => a - b);
const showContainers = this._unlinkedItems.length > 0 || sortedLibIDs.length > 1;
this._rows = [];
if (showContainers) {
if (this._unlinkedItems.length) {
let isOpen = this._containerOpenState.get(UNLINKED_ITEMS_ID) ?? true;
this._rows.push(this.createRow({ treeViewID: UNLINKED_ITEMS_ID }, 0, isOpen));
}
for (const libID of sortedLibIDs) {
let library = Zotero.Libraries.get(libID);
let isOpen = this._containerOpenState.get(library.treeViewID) ?? true;
this._rows.push(this.createRow(library, 0, isOpen));
// Children will be added by _sort() → _restoreOpenState() →
// _toggleOpenState(), which sorts them using the cached comparator
}
}
else {
for (const item of this._sourceItems) {
this._rows.push(this.createRow(item, 0, false));
}
}
this.refreshRowMap();
}
}
// ////////////////////////////////////////////////////////////////////////////
//
// Citation Explorer Item Tree
//
// ////////////////////////////////////////////////////////////////////////////
/**
* ItemTree subclass that uses CitationExplorerRowProvider.
*/
class CitationExplorerItemTree extends ItemTree {
static defaultProps = {
...ItemTree.defaultProps,
compareItems: (a, b) => getLibraryGroupOrder(a) - getLibraryGroupOrder(b),
};
constructor(props) {
super(props);
this.rowProvider = new CitationExplorerRowProvider(this);
this._setRowProviderUpdateHandler();
}
async setItems(items) {
await this.rowProvider.setItems(items);
return this.waitForLoad();
}
isSelectable(index, _selectAll = false) {
return !!this.getRow(index);
}
handleKeyDown(event) {
if (!super.handleKeyDown(event)) return false;
if (event.key === ' '
&& this.props.onActionMenuOpen?.()) {
return false;
}
return true;
}
_renderItem(index, selection, oldDiv = null, columns = []) {
let div = super._renderItem(index, selection, oldDiv, columns);
let row = this.getRow(index);
div.classList.toggle('library-container-row', row instanceof LibraryItemTreeRow);
return div;
}
}
module.exports = {
CitationExplorerItemTree,
CitationExplorerItemTreeRow,
LibraryItemTreeRow,
UnlinkedItemsTreeRow,
UNLINKED_ITEMS_ID,
getLibraryGroupOrder,
};

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

@ -27,6 +27,7 @@ const { noop, getDragTargetOrient } = require("components/utils");
const PropTypes = require("prop-types");
const React = require('react');
const ReactDOM = require('react-dom');
const cx = require('classnames');
const LibraryTree = require('./libraryTree');
const VirtualizedTable = require('components/virtualized-table');
const { VirtualizedTree, formatColumnName } = VirtualizedTable;
@ -922,6 +923,7 @@ var ItemTree = class ItemTree extends LibraryTree {
regularOnly: false,
multiSelect: true,
shouldListenForNotifications: true,
autoSelect: true,
columns: COLUMNS,
onContextMenu: noop,
onActivate: noop,
@ -939,6 +941,8 @@ var ItemTree = class ItemTree extends LibraryTree {
regularOnly: PropTypes.bool,
multiSelect: PropTypes.bool,
shouldListenForNotifications: PropTypes.bool,
// Whether to automatically select the focused row when the tree is focused via tab key
autoSelect: PropTypes.bool,
columns: PropTypes.array,
onSelectionChange: PropTypes.func,
onContextMenu: PropTypes.func,
@ -1408,7 +1412,7 @@ var ItemTree = class ItemTree extends LibraryTree {
* Select the first row when the tree is tabbed into with no selection.
*/
handleKeyUp = (event) => {
if (!Zotero.locked && event.code === 'Tab' && this.selection.count == 0) {
if (!Zotero.locked && this.props.autoSelect && (event.code === 'Tab' || event.key.includes("Arrow")) && this.selection.count == 0) {
this.selection.select(this.selection.focused);
}
};
@ -1446,8 +1450,10 @@ var ItemTree = class ItemTree extends LibraryTree {
renderItem: this._renderItem.bind(this),
hide: showMessage,
key: "virtualized-table",
className: cx([{ "regular-only": this.props.regularOnly }]),
showHeader: true,
alternateRowColors: true,
columns: this._getColumns(),
onColumnPickerMenu: this._displayColumnPickerMenu.bind(this),
onColumnSort: this.isSortable ? this._handleColumnSort : null,
@ -2036,20 +2042,22 @@ var ItemTree = class ItemTree extends LibraryTree {
moreItems.push(columnMenuitemElements[column.dataKey]);
}
}
if (moreItems.length) {
// Sort fields and move to submenu
var collation = Zotero.getLocaleCollation();
moreItems.sort(function (a, b) {
return collation.compareString(1, a.getAttribute('label'), b.getAttribute('label'));
});
moreItems.forEach(function (elem) {
moreMenuPopup.appendChild(menupopup.removeChild(elem));
});
// Sort fields and move to submenu
var collation = Zotero.getLocaleCollation();
moreItems.sort(function (a, b) {
return collation.compareString(1, a.getAttribute('label'), b.getAttribute('label'));
});
moreItems.forEach(function (elem) {
moreMenuPopup.appendChild(menupopup.removeChild(elem));
});
let sep = document.createXULElement('menuseparator');
menupopup.appendChild(sep);
moreMenu.appendChild(moreMenuPopup);
menupopup.appendChild(moreMenu);
let sep = document.createXULElement('menuseparator');
menupopup.appendChild(sep);
moreMenu.appendChild(moreMenuPopup);
menupopup.appendChild(moreMenu);
}
}
catch (e) {
Zotero.logError(e);

View file

@ -25,11 +25,14 @@
import CollectionTree from 'zotero/collectionTree';
import CollectionViewItemTree from 'zotero/collectionViewItemTree';
const { getCSSIcon } = require('components/icons');
const { COLUMNS } = require('zotero/itemTreeColumns');
var itemsView;
var collectionsView;
var loaded;
var io;
var suggestedItemsTempTable;
const isSelectItemsDialog = !!document.querySelector('#zotero-select-items-dialog');
const isEditBibliographyDialog = !!document.querySelector('#zotero-edit-bibliography-dialog');
const isAddEditItemsDialog = !!document.querySelector('#zotero-add-citation-dialog');
@ -62,9 +65,82 @@ var doLoad = async function () {
if(io.wrappedJSObject) io = io.wrappedJSObject;
if(io.addBorder) document.getElementsByTagName("dialog")[0].style.border = "1px solid black";
if(io.singleSelection) document.getElementById("zotero-items-tree").setAttribute("seltype", "single");
let columns = COLUMNS;
let customRows = [];
if (io.itemIDs) {
let items = await Zotero.Items.getAsync(io.itemIDs);
let itemIDs = items.map(item => item.id);
let libraryIDs = new Set(items.map(item => item.libraryID));
suggestedItemsTempTable = await Zotero.Search.idsToTempTable(itemIDs, { idColumn: 'id' });
let search = new Zotero.Search();
search.name = Zotero.getString('select-items-suggested-items');
// Called by CollectionViewItemTree when the custom row is loaded or filtered
let getSuggestedItems = async function () {
let ids = [];
for (let libraryID of libraryIDs) {
let filteredSearch = new Zotero.Search();
filteredSearch.libraryID = libraryID;
filteredSearch.addCondition('tempTable', 'is', suggestedItemsTempTable);
if (this.searchText) {
let condition = 'quicksearch-'
+ (this.searchMode || Zotero.Prefs.get('search.quicksearch-mode'));
filteredSearch.addCondition(condition, 'contains', this.searchText);
}
for (let tag of this.tags) {
filteredSearch.addCondition('tag', 'is', tag);
}
ids.push(...await filteredSearch.search());
}
return Zotero.Items.getAsync(ids);
};
customRows.push({
id: 'suggested-items',
type: 'suggestedItems',
ref: search,
properties: {
iconName: 'duplicates',
// This cross-library row has no libraryID
isWithinGroup: () => false,
getItems: getSuggestedItems
}
});
if ([...libraryIDs].some(id => id != Zotero.Libraries.userLibraryID)) {
let libraryColumn = {
dataKey: 'library',
label: 'select-items-library-column',
showInColumnPicker: true,
defaultIn: ['suggestedItems'],
enabledIn: ['suggestedItems'],
width: '180',
minWidth: 120,
zoteroPersist: ['width', 'hidden', 'sortDirection'],
renderCell(index, libraryName, column, _isFirstColumn, doc) {
let item = this.getRow(index).ref;
let library = Zotero.Libraries.get(item.libraryID);
let cell = doc.createElement('span');
cell.className = `cell ${column.className}`;
let icon = getCSSIcon(library.libraryType === 'group' ? 'library-group' : 'library');
icon.classList.add('cell-icon', 'item-icon', 'icon-item-type');
let text = doc.createElement('span');
text.className = 'cell-text';
text.textContent = libraryName;
cell.append(icon, text);
return cell;
}
};
let attachmentColumnIndex = COLUMNS.findIndex(column => column.dataKey === 'hasAttachment');
columns = [
...COLUMNS.slice(0, attachmentColumnIndex),
libraryColumn,
...COLUMNS.slice(attachmentColumnIndex)
];
}
}
itemsView = await CollectionViewItemTree.init(document.getElementById('zotero-items-tree'), {
onSelectionChange: () => {
updateShowInZoteroButton();
if (isEditBibliographyDialog) {
Zotero_Bibliography_Dialog.treeItemSelected();
}
@ -78,8 +154,15 @@ var doLoad = async function () {
id: io.itemTreeID || "select-items-dialog",
dragAndDrop: false,
regularOnly: io.onlyRegularItems,
columns,
columnPicker: true,
multiSelect: io.multiSelect,
getExtraField: (item, field) => {
if (field === 'library' && item.libraryID) {
return Zotero.Libraries.get(item.libraryID).name;
}
return undefined;
},
emptyMessage: Zotero.getString('pane.items.loading')
});
itemsView.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
@ -88,19 +171,34 @@ var doLoad = async function () {
const hideSources = io.hideCollections || ['duplicates', 'trash', 'feeds'];
collectionsView = await CollectionTree.init(document.getElementById('zotero-collections-tree'), {
onSelectionChange: () => onCollectionSelected(),
customRows,
filterLibraryIDs,
hideSources
});
await collectionsView.makeVisible();
if (io.select) {
if (io.itemIDs) {
await collectionsView.selectWait(collectionsView.getRowIndexByID('suggested-items'));
}
else if (io.select) {
await collectionsView.selectItem(io.select);
}
Zotero.updateQuickSearchBox(document);
document.addEventListener('dialogaccept', doAccept);
if (io.itemIDs) {
let showInZoteroButton = document.querySelector("dialog button[dlgtype='extra1']");
document.l10n.setAttributes(showInZoteroButton, 'select-items-show-in-zotero');
showInZoteroButton.addEventListener('click', async () => {
await Zotero.Utilities.Internal.showInLibrary(itemsView.getSelectedItems());
});
updateShowInZoteroButton();
}
else if (!io.extraButtons?.some(button => button.type === 'extra1')) {
document.querySelector("dialog button[dlgtype='extra1']")?.setAttribute('hidden', true);
}
if (isSelectItemsDialog) {
// Set proper tab order. It is only needed in selectItemsDialog -- other dialogs' focus order is correct
@ -126,13 +224,25 @@ var doLoad = async function () {
loaded = true;
};
function doUnload()
{
collectionsView.unregister();
if(itemsView)
itemsView.unregister();
io.deferred && io.deferred.resolve();
function doUnload() {
collectionsView?.unregister();
itemsView?.unregister();
if (suggestedItemsTempTable) {
Zotero.DB.queryAsync(
`DROP TABLE IF EXISTS ${suggestedItemsTempTable}`,
false,
{ noCache: true }
).catch(e => Zotero.logError(e));
}
io?.deferred?.resolve();
}
function updateShowInZoteroButton() {
let button = document.querySelector("dialog button[dlgtype='extra1']");
if (!button || !io?.itemIDs || !collectionsView) return;
let row = collectionsView.getRow(collectionsView.selection.focused);
button.hidden = row?.type !== 'suggestedItems';
button.disabled = button.hidden || !itemsView?.getSelectedItems().length;
}
var onCollectionSelected = async function () {
@ -142,21 +252,31 @@ var onCollectionSelected = async function () {
if (itemsView && itemsView.collectionTreeRows[0]?.id == collectionTreeRow.id) {
return;
}
document.getElementById('zotero-tb-search').onCollectionSelected();
collectionTreeRow.setSearch('');
Zotero.Prefs.set('lastViewedFolder', collectionTreeRow.id);
if (collectionTreeRow.type !== 'suggestedItems') {
Zotero.Prefs.set('lastViewedFolder', collectionTreeRow.id);
}
itemsView.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
// Load library data if necessary
var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID);
if (!library.getDataLoaded('item')) {
Zotero.debug("Waiting for items to load for library " + library.libraryID);
await library.waitForDataLoad('item');
if (collectionTreeRow.type !== 'suggestedItems') {
var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID);
if (!library.getDataLoaded('item')) {
Zotero.debug("Waiting for items to load for library " + library.libraryID);
await library.waitForDataLoad('item');
}
}
// Prevent a race-condition if rapidly clicking on different libraries without loaded
// item data
if (collectionsView.getRow(collectionsView.selection.focused)?.id !== collectionTreeRow.id) {
return;
}
await itemsView.changeCollectionTreeRows([collectionTreeRow]);
itemsView.clearItemsPaneMessage();
updateShowInZoteroButton();
};
function onSearch()

View file

@ -46,7 +46,7 @@
<dialog
id="select-items-dialog"
orient="vertical"
buttons="cancel,accept"
buttons="extra1,cancel,accept"
data-l10n-id="select-items-dialog"
data-l10n-attrs="buttonlabelaccept"
>

View file

@ -967,6 +967,128 @@ Zotero.Items = function () {
};
/**
* Copy an item to another library, including enabled child items
*
* Requires a transaction
*
* @param {Zotero.Item} item
* @param {Integer} targetLibraryID
* @param {Object} [options]
* @param {Boolean} [options.tags]
* @param {Boolean} [options.childNotes]
* @param {Boolean} [options.childLinks]
* @param {Boolean} [options.childFileAttachments]
* @param {Boolean} [options.annotations]
* @return {Promise<Zotero.Item|false>} The copied or existing linked item, or false if an
* attachment was skipped
*/
this.copyToLibrary = async function (item, targetLibraryID, options = {}) {
Zotero.DB.requireTransaction();
if (!item.isRegularItem() && !item.isNote() && !item.isAttachment()) {
throw new Error("Only regular items, notes, and attachments can be copied between libraries");
}
// Check if there's already a copy of this item in the library
let linkedItem = await item.getLinkedItem(targetLibraryID, true);
if (linkedItem) {
// TODO: Support reconciling tags, related items, attachments, etc.
return linkedItem;
}
let targetLibrary = Zotero.Libraries.get(targetLibraryID);
let {
tags: copyTags = Zotero.Prefs.get('groups.copyTags'),
childNotes: copyChildNotes = Zotero.Prefs.get('groups.copyChildNotes'),
childLinks: copyChildLinks = Zotero.Prefs.get('groups.copyChildLinks'),
childFileAttachments: copyChildFileAttachments
= Zotero.Prefs.get('groups.copyChildFileAttachments'),
annotations: copyAnnotations = Zotero.Prefs.get('groups.copyAnnotations'),
} = options;
// Standalone attachment
if (item.isAttachment()) {
// Skip linked files
if (item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping standalone linked file attachment on copy");
return false;
}
if (!targetLibrary.filesEditable) {
Zotero.debug("Skipping standalone file attachment on copy");
return false;
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(item, targetLibraryID);
if (copyAnnotations) {
await this.copyChildItems(item, newAttachment);
}
return newAttachment;
}
// Create new clone item in target library
let newItem = item.clone(targetLibraryID, { skipTags: !copyTags });
await newItem.save({ skipSelect: true });
// Record link
await newItem.addLinkedItem(item);
if (item.isNote()) {
if (targetLibrary.filesEditable) {
await Zotero.Notes.copyEmbeddedImages(item, newItem);
}
return newItem;
}
// For regular items, add child items if prefs and permissions allow
// Child notes
if (copyChildNotes) {
for (let note of Zotero.Items.get(item.getNotes())) {
let newNote = note.clone(targetLibraryID, { skipTags: !copyTags });
newNote.parentID = newItem.id;
await newNote.save({ skipSelect: true });
if (targetLibrary.filesEditable) {
await Zotero.Notes.copyEmbeddedImages(note, newNote);
}
await newNote.addLinkedItem(note);
}
}
// Child attachments
if (copyChildLinks || copyChildFileAttachments) {
for (let attachment of Zotero.Items.get(item.getAttachments())) {
let linkMode = attachment.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping child linked file attachment on copy");
continue;
}
// Skip imported files if we don't have pref and permissions
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
if (!copyChildLinks) {
Zotero.debug("Skipping child link attachment on copy");
continue;
}
}
else if (!copyChildFileAttachments || !targetLibrary.filesEditable) {
Zotero.debug("Skipping child file attachment on copy");
continue;
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(
attachment, targetLibraryID, newItem.id
);
if (copyAnnotations) {
await this.copyChildItems(attachment, newAttachment);
}
}
}
return newItem;
};
/**
* Copy child items from one item to another (e.g., in another library)
*

View file

@ -23,21 +23,77 @@
***** END LICENSE BLOCK *****
*/
Zotero.Duplicates = function (libraryID) {
if (typeof libraryID == 'undefined') {
throw ("libraryID not provided in Zotero.Duplicates constructor");
Zotero.Duplicates = function (libraryIDs) {
if (typeof libraryIDs == 'undefined') {
throw ("libraryID(s) not provided in Zotero.Duplicates constructor");
}
if (!libraryID) {
libraryID = Zotero.Libraries.userLibraryID;
if (!libraryIDs) {
libraryIDs = Zotero.Libraries.userLibraryID;
}
this._libraryID = libraryID;
if (!Array.isArray(libraryIDs)) {
libraryIDs = [libraryIDs];
}
if (!libraryIDs.length) {
throw ("libraryIDs must contain at least one libraryID");
}
this._libraryIDs = libraryIDs;
}
Zotero.Duplicates.prototype.__defineGetter__('name', function () { return Zotero.getString('pane.collections.duplicate'); });
Zotero.Duplicates.prototype.__defineGetter__('libraryID', function () { return this._libraryID; });
Zotero.Duplicates.prototype.__defineGetter__('libraryIDs', function () { return this._libraryIDs; });
Zotero.Duplicates.prototype.__defineGetter__('libraryID', function () {
if (this._libraryIDs.length > 1) {
throw ("libraryID is not available when Zotero.Duplicates includes multiple libraries");
}
return this._libraryIDs[0];
});
Zotero.Duplicates.prototype._getLibraryCondition = function (field = 'libraryID') {
if (this._libraryIDs.length == 1) {
return {
sql: `${field}=?`,
params: [this._libraryIDs[0]]
};
}
return {
sql: `${field} IN (${this._libraryIDs.map(() => '?').join(', ')})`,
params: this._libraryIDs.slice()
};
};
Zotero.Duplicates.normalizeString = function (str) {
// Make sure we have a string and not an integer
str = str + "";
if (str === "") {
return "";
}
str = Zotero.Utilities.removeDiacritics(str)
.replace(/[ !-/:-@[-`{-~]+/g, ' ') // Convert (ASCII) punctuation to spaces
.trim()
.toLowerCase();
return str;
};
Zotero.Duplicates._sortByValue = function (a, b) {
if ((a.value === null && b.value !== null)
|| (a.value === undefined && b.value !== undefined)
|| a.value < b.value) {
return -1;
}
if (a.value === b.value) return 0;
return 1;
};
/**
* Get duplicates, populate a temporary table, and return a search based
@ -74,7 +130,9 @@ Zotero.Duplicates.prototype.getSearchObject = async function () {
}
var s = new Zotero.Search;
s.libraryID = this._libraryID;
if (this._libraryIDs.length == 1) {
s.libraryID = this.libraryID;
}
s.addCondition('tempTable', 'is', table);
return s;
};
@ -98,327 +156,417 @@ Zotero.Duplicates.prototype._getObjectFromID = function (id) {
}
Zotero.Duplicates.prototype._findDuplicates = async function () {
Zotero.debug("Finding duplicates");
/**
* The comparison function for title-based duplicate matching.
*
* Reads metadata directly from the row objects (which are enriched with
* doi/isbn/year/creators in _loadCaches and findDuplicatesOf), so it has
* no dependency on instance caches.
*
* Assumes rows are sorted by normalized title. Returns:
* -1: not a match, stop comparing (title mismatch in sorted order)
* 0: not a match, but keep looking (title matches but metadata conflicts)
* 1: match
*
* @param {Object} a - Enriched row {itemID, value, doi?, isbn?, year?, creators?}
* @param {Object} b - Enriched row {itemID, value, doi?, isbn?, year?, creators?}
* @return {Integer}
*/
Zotero.Duplicates._compareRows = function (a, b) {
var aTitle = a.value;
var bTitle = b.value;
var start = Date.now();
var self = this;
this._sets = new Zotero.DisjointSetForest;
var sets = this._sets;
function normalizeString(str) {
// Make sure we have a string and not an integer
str = str + "";
if (str === "") {
return "";
}
str = Zotero.Utilities.removeDiacritics(str)
.replace(/[ !-/:-@[-`{-~]+/g, ' ') // Convert (ASCII) punctuation to spaces
.trim()
.toLowerCase();
return str;
// If we stripped one of the strings completely, we can't compare them
if (!aTitle || !bTitle) {
return -1;
}
function sortByValue(a, b) {
if((a.value === null && b.value !== null)
|| (a.value === undefined && b.value !== undefined)
|| a.value < b.value) {
return -1;
}
if(a.value === b.value) return 0;
if (aTitle !== bTitle) {
return -1; // everything is sorted by title, so if this mismatches, everything following will too
}
// If both items have a DOI and they don't match, it's not a dupe
if (a.doi && b.doi && a.doi != b.doi) {
return 0;
}
// If both items have an ISBN and they don't match, it's not a dupe
if (a.isbn && b.isbn && a.isbn != b.isbn) {
return 0;
}
// If both items have a year and they're off by more than one, it's not a dupe
if (a.year && b.year && Math.abs(a.year - b.year) > 1) {
return 0;
}
// Match if neither has creators
if (!a.creators && !b.creators) {
return 1;
}
/**
* @param {Function} compareRows Comparison function, if not exact match
* @param {Boolean} reprocessMatches Compare every row against every other,
* without skipping ahead to the last match.
* This is necessary for multi-dimensional
* matches such as title + at least one creator.
* Without it, only one set of matches would be
* found per matching title, since items with
* different creators wouldn't match the first
* set and the next start row would be a
* different title.
*/
function processRows(rows, compareRows, reprocessMatches) {
if (!rows.length) {
return;
}
// One has creators and the other doesn't — not a dupe
if (!a.creators || !b.creators) {
return 0;
}
// Check for at least one match on last name + first initial of first name
for (let i = 0; i < a.creators.length; i++) {
let aCreator = a.creators[i];
let aLastName = aCreator.lastName;
let aFirstInitial = aCreator.firstInitial || "";
for (var i = 0, len = rows.length; i < len; i++) {
var j = i + 1, lastMatch = false;
while (j < len) {
if (compareRows) {
var match = compareRows(rows[i], rows[j]);
// Not a match, and don't try any more with this i value
if (match == -1) {
break;
}
// Not a match, but keep looking
if (match == 0) {
j++;
continue;
}
}
// If no comparison function, check for exact match
else {
if (!rows[i].value || !rows[j].value
|| (rows[i].value !== rows[j].value)
) {
break;
}
}
sets.union(
self._getObjectFromID(rows[i].itemID),
self._getObjectFromID(rows[j].itemID)
);
lastMatch = j;
j++;
}
if (!reprocessMatches && lastMatch) {
i = lastMatch;
for (let j = 0; j < b.creators.length; j++) {
let bCreator = b.creators[j];
let bLastName = bCreator.lastName;
let bFirstInitial = bCreator.firstInitial || "";
if (aLastName === bLastName && aFirstInitial === bFirstInitial) {
return 1;
}
}
}
return 0;
};
/**
* Check if a target row has duplicates among the given rows.
*
* This is the inner loop of processRows, extracted so it can be reused
* by findDuplicatesOf. Rows must be sorted by value.
*
* @param {Object} targetRow - Row with .itemID and .value
* @param {Object[]} rows - Sorted rows to compare against
* @param {Function} [compareRows] - Comparison function returning -1/0/1.
* If omitted, checks for exact value match.
* @return {Object[]} - Array of matching rows
*/
Zotero.Duplicates._checkIfDuplicate = function (targetRow, rows, compareRows) {
let matches = [];
for (let j = 0; j < rows.length; j++) {
if (compareRows) {
let match = compareRows(targetRow, rows[j]);
// Not a match, and don't try any more
if (match == -1) {
break;
}
// Not a match, but keep looking
if (match == 0) {
continue;
}
}
// If no comparison function, check for exact match
else {
if (!targetRow.value || !rows[j].value
|| (targetRow.value !== rows[j].value)
) {
break;
}
}
matches.push(rows[j]);
}
return matches;
};
/**
* Load all data needed for duplicate detection from the database.
*
* Populates:
* this._isbnRows - sorted [{itemID, value}] for ISBN exact-match pass
* this._doiRows - sorted [{itemID, value}] for DOI exact-match pass
* this._titleRows - sorted enriched rows for title+creators pass:
* [{itemID, value, doi?, isbn?, year?, creators?}]
* this._itemCache - {itemID: {doi?, isbn?, year?, creators?}} — consolidated
* metadata used to enrich title rows
*/
Zotero.Duplicates.prototype._loadCaches = async function () {
var normalizeString = Zotero.Duplicates.normalizeString;
var sortByValue = Zotero.Duplicates._sortByValue;
let libraryCondition = this._getLibraryCondition();
this._itemCache = {};
var getCacheEntry = (itemID) => {
if (!this._itemCache[itemID]) this._itemCache[itemID] = {};
return this._itemCache[itemID];
};
// Match books by ISBN
var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) "
+ "WHERE libraryID=? AND itemTypeID=? AND fieldID=? "
+ `WHERE ${libraryCondition.sql} AND itemTypeID=? AND fieldID=? `
+ "AND itemID NOT IN (SELECT itemID FROM deletedItems)";
var rows = await Zotero.DB.queryAsync(
sql,
[
this._libraryID,
...libraryCondition.params,
Zotero.ItemTypes.getID('book'),
Zotero.ItemFields.getID('ISBN')
]
);
var isbnCache = {};
if (rows.length) {
let newRows = [];
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
let newVal = Zotero.Utilities.cleanISBN('' + row.value);
if (!newVal) continue;
// Canonicalize to ISBN-13 so an ISBN-10 and its ISBN-13 equivalent match
newVal = Zotero.Utilities.toISBN13(newVal);
isbnCache[row.itemID] = newVal;
newRows.push({
itemID: row.itemID,
value: newVal
});
}
newRows.sort(sortByValue);
processRows(newRows);
this._isbnRows = [];
for (let row of rows) {
let cleaned = Zotero.Utilities.cleanISBN('' + row.value);
if (!cleaned) continue;
// Canonicalize to ISBN-13 so an ISBN-10 and its ISBN-13 equivalent match
cleaned = Zotero.Utilities.toISBN13(cleaned);
getCacheEntry(row.itemID).isbn = cleaned;
this._isbnRows.push({ itemID: row.itemID, value: cleaned });
}
this._isbnRows.sort(sortByValue);
// DOI
var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) "
+ "WHERE libraryID=? AND fieldID=? AND value LIKE ? "
+ "AND itemID NOT IN (SELECT itemID FROM deletedItems)";
var rows = await Zotero.DB.queryAsync(
sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) "
+ `WHERE ${libraryCondition.sql} AND fieldID=? AND value LIKE ? `
+ "AND itemID NOT IN (SELECT itemID FROM deletedItems)";
rows = await Zotero.DB.queryAsync(
sql,
[
this._libraryID,
...libraryCondition.params,
Zotero.ItemFields.getID('DOI'),
'10.%'
]
);
var doiCache = {};
if (rows.length) {
let newRows = [];
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
// DOIs are case insensitive
let newVal = (row.value + '').trim().toUpperCase();
doiCache[row.itemID] = newVal;
newRows.push({
itemID: row.itemID,
value: newVal
});
}
newRows.sort(sortByValue);
processRows(newRows);
this._doiRows = [];
for (let row of rows) {
// DOIs are case insensitive
let doi = (row.value + '').trim().toUpperCase();
getCacheEntry(row.itemID).doi = doi;
this._doiRows.push({ itemID: row.itemID, value: doi });
}
this._doiRows.sort(sortByValue);
// Get years
var dateFields = [Zotero.ItemFields.getID('date')].concat(
Zotero.ItemFields.getTypeFieldsFromBase('date')
);
var sql = "SELECT itemID, SUBSTR(value, 1, 4) AS year FROM items "
+ "JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) "
+ "WHERE libraryID=? AND fieldID IN ("
+ dateFields.map(() => '?').join() + ") "
+ "AND SUBSTR(value, 1, 4) != '0000' "
+ "AND itemID NOT IN (SELECT itemID FROM deletedItems) "
+ "ORDER BY value";
var rows = await Zotero.DB.queryAsync(sql, [this._libraryID].concat(dateFields));
var yearCache = {};
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
yearCache[row.itemID] = row.year;
var dateFields = [
Zotero.ItemFields.getID('date'),
...Zotero.ItemFields.getTypeFieldsFromBase('date')
];
sql = "SELECT itemID, SUBSTR(value, 1, 4) AS year FROM items "
+ "JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) "
+ `WHERE ${libraryCondition.sql} AND fieldID IN (`
+ dateFields.map(() => '?').join() + ") "
+ "AND SUBSTR(value, 1, 4) != '0000' "
+ "AND itemID NOT IN (SELECT itemID FROM deletedItems) "
+ "ORDER BY value";
rows = await Zotero.DB.queryAsync(sql, [...libraryCondition.params, ...dateFields]);
for (let row of rows) {
getCacheEntry(row.itemID).year = row.year;
}
var itemTypeAttachment = Zotero.ItemTypes.getID('attachment');
var itemTypeNote = Zotero.ItemTypes.getID('note');
// Get all creators and group by itemID
sql = "SELECT itemID, lastName, firstName, fieldMode FROM items "
+ "JOIN itemCreators USING (itemID) "
+ "JOIN creators USING (creatorID) "
+ `WHERE ${libraryCondition.sql} AND itemTypeID NOT IN (${itemTypeAttachment}, ${itemTypeNote}) AND `
+ "itemID NOT IN (SELECT itemID FROM deletedItems)"
+ "ORDER BY itemID, orderIndex";
let creatorRows = await Zotero.DB.queryAsync(sql, libraryCondition.params);
for (let row of creatorRows) {
let entry = getCacheEntry(row.itemID);
if (!entry.creators) entry.creators = [];
entry.creators.push({
lastName: normalizeString(row.lastName),
firstInitial: row.fieldMode == 0 ? normalizeString(row.firstName).charAt(0) : false
});
}
// Match on normalized title
var titleIDs = Zotero.ItemFields.getTypeFieldsFromBase('title');
titleIDs.push(Zotero.ItemFields.getID('title'));
var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) "
+ "WHERE libraryID=? AND fieldID IN "
+ "(" + titleIDs.join(', ') + ") "
+ `AND itemTypeID NOT IN (${itemTypeAttachment}, ${itemTypeNote}) `
+ "AND itemID NOT IN (SELECT itemID FROM deletedItems)";
var rows = await Zotero.DB.queryAsync(sql, [this._libraryID]);
if (rows.length) {
//normalize all values ahead of time
rows = rows.map(function (row) {
return {
itemID: row.itemID,
value: normalizeString(row.value)
};
});
//sort rows by normalized values
rows.sort(sortByValue);
// Get all creators and separate by itemID
//
// We won't need all of these, but otherwise we would have to make processRows()
// asynchronous, which would be too slow
let creatorRowsCache = {};
let sql = "SELECT itemID, lastName, firstName, fieldMode FROM items "
+ "JOIN itemCreators USING (itemID) "
+ "JOIN creators USING (creatorID) "
+ `WHERE libraryID=? AND itemTypeID NOT IN (${itemTypeAttachment}, ${itemTypeNote}) AND `
+ "itemID NOT IN (SELECT itemID FROM deletedItems)"
+ "ORDER BY itemID, orderIndex";
let creatorRows = await Zotero.DB.queryAsync(sql, this._libraryID);
let lastItemID;
let itemCreators = [];
for (let i = 0; i < creatorRows.length; i++) {
let row = creatorRows[i];
if (lastItemID && row.itemID != lastItemID) {
if (itemCreators.length) {
creatorRowsCache[lastItemID] = itemCreators;
itemCreators = [];
}
}
lastItemID = row.itemID;
itemCreators.push({
lastName: normalizeString(row.lastName),
firstInitial: row.fieldMode == 0 ? normalizeString(row.firstName).charAt(0) : false
});
sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) "
+ `WHERE ${libraryCondition.sql} AND fieldID IN `
+ "(" + titleIDs.join(', ') + ") "
+ `AND itemTypeID NOT IN (${itemTypeAttachment}, ${itemTypeNote}) `
+ "AND itemID NOT IN (SELECT itemID FROM deletedItems)";
rows = await Zotero.DB.queryAsync(sql, libraryCondition.params);
// Normalize titles and enrich with metadata from the cache
this._titleRows = rows.map((row) => {
let entry = this._itemCache[row.itemID] || {};
return {
itemID: row.itemID,
value: normalizeString(row.value),
...entry
};
});
// Sort rows by normalized values
this._titleRows.sort(sortByValue);
};
/**
* Process sorted rows, finding duplicates and unioning them into sets.
*
* @param {Object[]} rows - Sorted rows with .itemID and .value
* @param {Function} [compareRows] - Comparison function returning -1/0/1.
* If omitted, checks for exact value match.
* @param {Boolean} [reprocessMatches] - If true, don't skip ahead past matches.
* Needed for multi-dimensional comparisons
* (e.g. title + creators) where items with
* the same title but different creators
* must still be compared individually.
*/
Zotero.Duplicates.prototype._processRows = function (rows, compareRows, reprocessMatches) {
for (let i = 0, len = rows.length; i < len; i++) {
let matches = Zotero.Duplicates._checkIfDuplicate(
rows[i], rows.slice(i + 1), compareRows
);
for (let m of matches) {
this._sets.union(
this._getObjectFromID(rows[i].itemID),
this._getObjectFromID(m.itemID)
);
}
// Add final item creators
if (itemCreators.length) {
creatorRowsCache[lastItemID] = itemCreators;
if (!reprocessMatches && matches.length) {
i += matches.length;
}
processRows(rows, function (a, b) {
var aTitle = a.value;
var bTitle = b.value;
// If we stripped one of the strings completely, we can't compare them
if(!aTitle || !bTitle) {
return -1;
}
if (aTitle !== bTitle) {
return -1; //everything is sorted by title, so if this mismatches, everything following will too
}
// If both items have a DOI and they don't match, it's not a dupe
if (typeof doiCache[a.itemID] != 'undefined'
&& typeof doiCache[b.itemID] != 'undefined'
&& doiCache[a.itemID] != doiCache[b.itemID]) {
return 0;
}
// If both items have an ISBN and they don't match, it's not a dupe
if (typeof isbnCache[a.itemID] != 'undefined'
&& typeof isbnCache[b.itemID] != 'undefined'
&& isbnCache[a.itemID] != isbnCache[b.itemID]) {
return 0;
}
// If both items have a year and they're off by more than one, it's not a dupe
if (typeof yearCache[a.itemID] != 'undefined'
&& typeof yearCache[b.itemID] != 'undefined'
&& Math.abs(yearCache[a.itemID] - yearCache[b.itemID]) > 1) {
return 0;
}
// Check for at least one match on last name + first initial of first name
var aCreatorRows, bCreatorRows;
if (typeof creatorRowsCache[a.itemID] != 'undefined') {
aCreatorRows = creatorRowsCache[a.itemID];
}
if (typeof creatorRowsCache[b.itemID] != 'undefined') {
bCreatorRows = creatorRowsCache[b.itemID];
}
// Match if no creators
if (!aCreatorRows && !bCreatorRows) {
return 1;
}
if (!aCreatorRows || !bCreatorRows) {
return 0;
}
for (let i = 0; i < aCreatorRows.length; i++) {
let aCreatorRow = aCreatorRows[i];
let aLastName = aCreatorRow.lastName;
let aFirstInitial = aCreatorRow.firstInitial || "";
for (let j = 0; j < bCreatorRows.length; j++) {
let bCreatorRow = bCreatorRows[j];
let bLastName = bCreatorRow.lastName;
let bFirstInitial = bCreatorRow.firstInitial || "";
if (aLastName === bLastName && aFirstInitial === bFirstInitial) {
return 1;
}
}
}
return 0;
}, true);
}
};
Zotero.Duplicates.prototype._findDuplicates = async function () {
Zotero.debug("Finding duplicates");
// Match on exact fields
/*var fields = [''];
for (let field of fields) {
var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) "
+ "JOIN itemDataValues USING (valueID) "
+ "WHERE libraryID=? AND fieldID=? "
+ "AND itemID NOT IN (SELECT itemID FROM deletedItems) "
+ "ORDER BY value";
var rows = yield Zotero.DB.queryAsync(sql, [this._libraryID, Zotero.ItemFields.getID(field)]);
processRows(rows);
}*/
var start = Date.now();
await this._loadCaches();
this._sets = new Zotero.DisjointSetForest;
this._processRows(this._isbnRows);
this._processRows(this._doiRows);
this._processRows(this._titleRows, Zotero.Duplicates._compareRows, true);
Zotero.debug("Found duplicates in " + (Date.now() - start) + " ms");
};
/**
* Build an enriched row (suitable for _compareRows) from a Zotero.Item.
*
* @param {Zotero.Item} item - A saved or unsaved Zotero.Item
* @return {Object} - {itemID, value, doi?, isbn?, year?, creators?}
*/
Zotero.Duplicates._rowFromItem = function (item) {
var normalizeString = Zotero.Duplicates.normalizeString;
var rawDOI = item.getField('DOI');
var doi = rawDOI ? (rawDOI + '').trim().toUpperCase() : undefined;
if (doi && !doi.startsWith('10.')) doi = undefined;
var rawISBN = item.getField('ISBN');
var isbn = rawISBN ? Zotero.Utilities.cleanISBN('' + rawISBN) : undefined;
isbn = isbn ? Zotero.Utilities.toISBN13(isbn) : undefined;
var year = item.getField('year') || undefined;
var creators = item.getCreators();
var normalizedCreators = creators.length
? creators.map(c => ({
lastName: normalizeString(c.lastName || ''),
firstInitial: c.fieldMode === 0 ? normalizeString(c.firstName || '').charAt(0) : false
}))
: undefined;
return {
itemID: item.id || null,
value: normalizeString(item.getField('title', false, true)),
doi: doi,
isbn: isbn,
year: year,
creators: normalizedCreators
};
};
/**
* Find items in the library that are duplicates of the given item.
*
* @param {Zotero.Item|Object} itemOrCSLJSON - A Zotero.Item, or a CSL-JSON object
* @return {Promise<Integer[]>} - Array of matching itemIDs
*/
Zotero.Duplicates.prototype.findDuplicatesOf = async function (itemOrCSLJSON) {
var item;
if (itemOrCSLJSON instanceof Zotero.Item) {
item = itemOrCSLJSON;
}
else {
item = new Zotero.Item();
Zotero.Utilities.Item.itemFromCSLJSON(item, itemOrCSLJSON);
}
await this._loadCaches();
var targetRow = Zotero.Duplicates._rowFromItem(item);
var matches = new Set();
// ISBN exact-match pass
if (targetRow.isbn) {
let startIdx = _binarySearch(this._isbnRows, targetRow.isbn);
let m = Zotero.Duplicates._checkIfDuplicate(
{ value: targetRow.isbn },
this._isbnRows.slice(startIdx)
);
for (let r of m) matches.add(r.itemID);
}
// DOI exact-match pass
if (targetRow.doi) {
let startIdx = _binarySearch(this._doiRows, targetRow.doi);
let m = Zotero.Duplicates._checkIfDuplicate(
{ value: targetRow.doi },
this._doiRows.slice(startIdx)
);
for (let r of m) matches.add(r.itemID);
}
// Title + creators pass — reuses _compareRows directly with the enriched row
if (targetRow.value) {
let startIdx = _binarySearch(this._titleRows, targetRow.value);
let m = Zotero.Duplicates._checkIfDuplicate(
targetRow,
this._titleRows.slice(startIdx),
Zotero.Duplicates._compareRows
);
for (let r of m) matches.add(r.itemID);
}
// Filter out the target item itself if it was a library item
if (targetRow.itemID) matches.delete(targetRow.itemID);
return [...matches];
};
/**
* Binary search for the first row whose value >= the target value
* in a sorted rows array.
*
* @param {Object[]} rows - Sorted by .value
* @param {String} value - Target value to find
* @return {Integer} - Index of first row with value >= target
*/
function _binarySearch(rows, value) {
let lo = 0, hi = rows.length;
while (lo < hi) {
let mid = (lo + hi) >> 1;
if (rows[mid].value < value) {
lo = mid + 1;
}
else {
hi = mid;
}
}
return lo;
}
/**
* Implements the Disjoint Set data structure

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,21 +2554,23 @@ Zotero.Integration.Session.prototype.openCitationExplorer = async function () {
const data = await citationField.unserialize();
return data.citationID === citation.citationID;
},
updateIndex: index => this.updateIndices[index] = true
relinkItems: replacements => this._relinkItems(replacements)
};
await Zotero.Integration.displayDialog('chrome://zotero/content/integration/citationExplorer.xhtml', 'resizable', io);
if (io.openCitationDialog) {
let citations = await this.cite(io.openCitationDialog);
if (this.data.prefs.delayCitationUpdates) {
for (let citation of citations) {
await this.writeDelayedCitation(citation.field, citation);
try {
await this.cite(io.openCitationDialog);
}
catch (e) {
if (!(e instanceof Zotero.Exception.Alert)) {
Zotero.debug("An error occurred while citing from Citation Explorer. Document will be updated.");
Zotero.logError(e);
}
} else {
return this.updateDocument(FORCE_CITATIONS_FALSE, false, false);
}
}
return this.updateDocument(FORCE_CITATIONS_FALSE, false, false);
};

View file

@ -1114,8 +1114,8 @@ Zotero.Server.Connector.Ping.prototype = {
supportsTagsAutocomplete: true,
googleDocsAddNoteEnabled: true,
googleDocsAddAnnotationEnabled: true,
googleDocsCitationExplorerEnabled: Zotero.Prefs.get("integration.googleDocsCitationExplorerEnabled"),
canUserAddNote: true,
googleDocsCitationExplorerEnabled: false,
translatorsHash,
sortedTranslatorHash
}

View file

@ -2041,9 +2041,12 @@ Zotero.Utilities.Internal = {
/**
* Select an object in the library tab of the main window
*
* @param {Zotero.DataObject} - Data object (e.g., Zotero.Item) to select
* @param {Zotero.DataObject[]} - Data objects (e.g., Zotero.Item) to select
*/
showInLibrary: async function (dataObject) {
showInLibrary: async function (dataObjects) {
if (!Array.isArray(dataObjects)) {
dataObjects = [dataObjects];
}
var pane = Zotero.getActiveZoteroPane();
// Open main window if it's not open (Mac)
if (!pane) {
@ -2057,12 +2060,12 @@ Zotero.Utilities.Internal = {
});
pane = win.ZoteroPane;
}
if (dataObject instanceof Zotero.Item) {
pane.selectItem(dataObject.id);
}
else {
throw new Error("Unimplemented");
}
pane.selectItems(dataObjects.map(dataObject => {
if (dataObject instanceof Zotero.Item) {
return dataObject.id;
}
throw new Error(`Unsupported data object: ${dataObject}`);
}));
// Pull window to foreground
Zotero.Utilities.Internal.activate(pane.document.defaultView);

View file

@ -18,6 +18,52 @@ integration-editBibliography-wrapper =
.aria-description = { -integration-editBibliography-include-uncited }
{ -integration-editBibliography-exclude-cited }
{ -integration-editBibliography-edit-reference }
integration-citationExplorer-window =
.title = Citation Explorer
integration-citationExplorer-citations = Citations
integration-citationExplorer-citation-search =
.placeholder = Citation Content
integration-citationExplorer-citation-column = Citation
integration-citationExplorer-linked-column = Is Linked
integration-citationExplorer-show-in-document =
.value = Show in Document
integration-citationExplorer-edit-citation =
.value = Edit Citation
integration-citationExplorer-items-in-bibliography = Items in Bibliography
integration-citationExplorer-item-search =
.placeholder = Title, Creator, Year
integration-citationExplorer-show-in-zotero =
.value = Show in Zotero
integration-citationExplorer-manage-items =
.value = Add/Link to Collection
integration-citationExplorerWizard-window =
.title = Add/Link to Collection
integration-citationExplorerWizard-choose-header = Choose an Action and Destination
integration-citationExplorerWizard-choose-description = Choose how to handle items used in this document and where to place them.
integration-citationExplorerWizard-operation-relink =
.label = Relink unlinked items
integration-citationExplorerWizard-operation-relink-description = Link only unlinked document items to matching items, or add them to the selected library or collection.
integration-citationExplorerWizard-operation-add-to-target =
.label = Copy and relink all items
integration-citationExplorerWizard-operation-add-to-target-description = Ensure that all document items are linked to items in the selected library or collection.
integration-citationExplorerWizard-target = Destination:
integration-citationExplorerWizard-review-header = Review Item Actions
integration-citationExplorerWizard-review-description = Review the proposed action for each item. Choose “Do nothing” to leave an item unchanged.
integration-citationExplorerWizard-action-column = Action
integration-citationExplorerWizard-action-add-to-target = Add to { $target }
integration-citationExplorerWizard-action-relink = Relink to existing item “{ $title }”
integration-citationExplorerWizard-action-copy = Copy an existing item from { $library }
integration-citationExplorerWizard-action-choose = Choose Item…
integration-citationExplorerWizard-action-skip = Do nothing
integration-citationExplorerWizard-choose-button = Choose…
integration-citationExplorerWizard-status-finding = Finding matching items…
integration-citationExplorerWizard-status-find-error = Unable to determine item actions.
integration-citationExplorerWizard-status-no-changes = No changes are needed.
integration-citationExplorerWizard-status-applying = Applying changes…
integration-citationExplorerWizard-status-apply-error = An error occurred while applying changes.
integration-citationDialog = Citation Dialog
integration-citationDialog-section-open = Open Documents ({ $count })
integration-citationDialog-section-selected = Selected Items ({ $count }/{ $total })
@ -152,4 +198,3 @@ integration-warning-command-is-running = A word processor integration command is
first-run-guidance-citationDialog = Click the bubble or use the ← and ↓ keys to view the citation details and customize options such as page number, prefix, and suffix.
You can also add a page number or other locator by including it with your search terms (e.g., “history { $locator }”) or by typing it after the bubble and pressing { return-or-enter }.

View file

@ -1105,6 +1105,10 @@ select-items-window =
.title = Select Items
select-items-dialog =
.buttonlabelaccept = Select
select-items-suggested-items = Suggested Items
select-items-library-column = Library
select-items-show-in-zotero =
.label = { general-show-in-library }
select-items-convertToStandalone =
.label = Convert to Standalone
select-items-convertToStandaloneAttachment =

View file

@ -917,6 +917,9 @@ integration.upgradeTemplate = The %S plugin for %S is outdated. Reinstall the pl
integration.mendeleyImport.title = Missing Mendeley Data
integration.mendeleyImport.description = %1$S detected that the document you are citing with contains Mendeley citations. %1$S will be able to manage these citations if you import your Mendeley database.
integration.mendeleyImport.openImporter = Open Mendeley Importer...
integration.citationExplorer.citationsModified = Citations in your document have been modified since Citation Explorer has been opened and %S will not be able to activate them until you reopen this window.
integration.citationExplorer.unlinkedItems = Unlinked Items
integration.citationExplorer.uncited = Uncited
styles.install.title = Install Style
styles.install.unexpectedError = An unexpected error occurred while installing "%1$S"

View file

@ -155,6 +155,7 @@ pref("extensions.zotero.integration.citationDialogMode", "last-used");
pref("extensions.zotero.integration.citationDialogShowLocatorTip", true);
pref("extensions.zotero.integration.annotationDialogIncludeComments", true);
pref("extensions.zotero.integration.citationPreviewShown", true);
pref("extensions.zotero.integration.googleDocsCitationExplorerEnabled", false);
// Connector settings
pref("extensions.zotero.httpServer.enabled", true);

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

@ -16,3 +16,7 @@
.flex {
flex: 1
}
.right {
margin-left: auto !important;
}

View file

@ -1,29 +1,111 @@
.citation-explorer-item-tree .virtualized-table .row {
.cell .badge {
font-size: 11px;
padding: 1px 4px;
border-radius: 5px;
display: inline-block;
background: #59ADC44D;
margin-left: 4px;
}
&.selected .cell .badge {
background: rgba(255, 255, 255, 0.2);
color: var(--accent-white);
}
}
#zotero-citation-explorer-dialog {
min-height: 500px;
min-width: 600px;
--split-button-icon-color: var(--accent-blue);
dialog {
max-height: 100vh;
background: var(--material-sidepane);
}
search-textbox, input, button {
margin: 0;
}
.citation-explorer-grid {
display: grid;
grid-template-columns: 4fr 6fr;
grid-template-rows: auto auto minmax(0, 1fr) auto;
grid-auto-flow: column;
height: 100%;
margin: -8px;
.citation-explorer-grid-left {
border-right: var(--material-border-quarternary);
}
}
#citations-label, #items-in-bibliography-label {
padding: 16px 16px 8px;
align-content: center;
font-size: 15px;
font-weight: 600;
}
.searchbox-container {
padding: 0 16px 8px;
}
.virtualized-table-container {
height: 100%;
flex: 1;
}
.xul-border-div {
-moz-box-flex: 1;
border-block: var(--material-border-quarternary);
}
#citations-label {
margin: 5px 0 4px;
.virtualized-table .row {
&.odd:not(.selected) {
background-color: var(--material-stripe);
}
&.even:not(.selected) {
background-color: var(--material-background);
}
}
.panel {
border: solid 1px ThreeDShadow;
padding: 5px;
.virtualized-table:focus-within .row.highlighted:not(.selected) {
background: initial !important;
}
.button-container {
border-bottom: var(--material-border-quarternary);
display: flex;
gap: 8px;
padding: 8px 16px;
min-width: 0;
.split-menu-button {
min-width: 0;
flex: 0 1 auto;
[anonid="button-image-and-text-box"] {
min-width: 0;
width: auto;
}
}
}
.diff-deleted {
color: var(--accent-red);
text-decoration: line-through;
}
.diff-added {
color: green;
}
.search-box {
margin: 2px -1px;
@media (-moz-platform: macos) {
.citation-explorer-grid {
margin: -14px;
}
.button-container {
padding-bottom: 0px;
margin-bottom: -16px;
}
}
}

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

@ -41,6 +41,7 @@ $-icons: (
chevron-6: 8,
filter: 16,
note: 16,
link: 16,
x-8: 16,
play: 16,
pause: 16,

View file

@ -35,14 +35,6 @@
padding-inline-start: 8px;
padding-inline-end: calc(8px + var(--scrollbar-width, 0px));
box-sizing: border-box;
// A clear divider below the column headers (the default faint border is lost
// against the white section-header/spacer rows below it). The header already
// carries a second 1px line via ::after, so drop that one to avoid doubling.
border-bottom: var(--material-panedivider);
&::after {
border-bottom: none;
}
.cell.hasAttachment,
.cell.numNotes {
@ -406,10 +398,12 @@ $-attachmentIcons: (
attachment-video
);
// Non-item objects that can appear in the trash
$-trashableObjectIcons: (
// Non-item objects that can appear in the item tree
$-nonItemIcons: (
"collection",
"search"
"search",
"library",
"library-group"
);
// Few icons that appear in color in the item tree
@ -475,7 +469,7 @@ $-coloredIcons: (
}
}
@each $icon in $-trashableObjectIcons {
@each $icon in $-nonItemIcons {
.icon-css.icon-#{$icon} {
@include svgicon($icon, $color, "16", "collection-tree");
}

View file

@ -60,12 +60,6 @@
padding-inline-end: 4px;
}
&:first-child {
--extra-width: var(--first-column-extra-width, 0px);
// No padding on the first cell since it's done via twisty and indent
padding-inline-start: 0;
}
&:first-child,
&.primary {
display: flex;
@ -243,7 +237,7 @@
background: var(--material-background);
height: 1.83333333em; // 22px @ 12px font size
overflow: hidden;
border-bottom: 1px solid var(--material-border-quarternary);
border-bottom: var(--material-panedivider);
padding-inline: 8px;
box-sizing: border-box;
padding-inline-end: calc(8px + var(--scrollbar-width, 0px));
@ -265,23 +259,6 @@
}
}
&::after {
content: "";
display: block;
border-bottom: var(--material-border-quarternary);
height: 1px;
width: 100%;
position: absolute;
top: calc(1.83333333em - 1px);
left: 0;
right: 0;
z-index: 1;
@include comfortable {
top: calc(2.33333333em - 1px);
}
}
.column-picker {
text-align: center;
}
@ -522,3 +499,10 @@
}
}
.virtualized-tree {
.cell:first-child {
--extra-width: var(--first-column-extra-width, 0px);
// No padding on the first cell since it's done via twisty and indent
padding-inline-start: 0;
}
}

View file

@ -1,8 +1,9 @@
.split-menu-button {
display: flex;
align-items: center;
padding-right: 0;
padding-right: 6px;
gap: 6px;
flex-grow: 1;
& [anonid="button-image-and-text-box"] {
flex: 1;
@ -33,6 +34,7 @@
display: flex;
align-items: center;
overflow: hidden;
min-width: 8px;
}
& [anonid="dropmarker-separator"] {
@ -47,4 +49,8 @@
fill: currentColor;
-moz-context-properties: fill,fill-opacity;
}
@media (-moz-platform: mac) {
padding-right: 0;
}
}

View file

@ -1,14 +0,0 @@
#zotero-citation-explorer-dialog {
tab[visuallyselected="true"]:not(:-moz-window-inactive) {
color: initial !important;
}
#items-panel {
margin-top: 1.5em;
}
#citations-label {
margin: 0.5em 0 0.38em;
font-size: 1.2em;
}
}

View file

@ -1,13 +0,0 @@
#zotero-citation-explorer-dialog {
#citations-label {
margin: 3px 0 2px;
}
tab:focus-visible > .tab-middle {
outline: none;
}
input[type="button"], button {
margin-top: 7px;
}
}

View file

@ -127,6 +127,10 @@
}
}
search-textbox:not([no-native]) {
padding-inline-start: 0;
}
textarea:not([no-native]) {
height: unset;
}

View file

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

View file

@ -13,6 +13,5 @@
@import "mac/components/input";
@import "mac/components/menupopup";
@import "mac/components/menulist";
@import "mac/citationExplorer";
// Elements

View file

@ -9,7 +9,6 @@
@import "win/createParent";
@import "win/tabBar";
@import "win/titleBar";
@import "win/citationExplorer";
// Elements

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

@ -60,6 +60,158 @@ describe("Duplicate Items", function () {
await waitForNotifierEvent('refresh', 'trash');
}
describe("findDuplicatesOf()", function () {
it("should find duplicates of a Zotero.Item by title + creator", async function () {
var item1 = await createDataObject('item', {
title: 'Test Dedup Title',
creators: [{
firstName: 'John',
lastName: 'Smith',
creatorType: 'author'
}]
});
var item2 = await createDataObject('item', {
title: 'Test Dedup Title',
creators: [{
firstName: 'John',
lastName: 'Smith',
creatorType: 'author'
}]
});
// Different title, should not match
var item3 = await createDataObject('item', {
title: 'Different Title',
creators: [{
firstName: 'John',
lastName: 'Smith',
creatorType: 'author'
}]
});
var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID);
var dupes = await d.findDuplicatesOf(item1);
assert.include(dupes, item2.id);
assert.notInclude(dupes, item1.id);
assert.notInclude(dupes, item3.id);
});
it("should find duplicates of a CSL-JSON item by title + creator", async function () {
var item1 = await createDataObject('item', {
title: 'CSL Dedup Title',
creators: [{
firstName: 'Jane',
lastName: 'Doe',
creatorType: 'author'
}]
});
var cslItem = {
type: 'book',
title: 'CSL Dedup Title',
author: [{ family: 'Doe', given: 'Jane' }]
};
var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID);
var dupes = await d.findDuplicatesOf(cslItem);
assert.include(dupes, item1.id);
});
it("should find duplicates by DOI", async function () {
var item1 = await createDataObject('item', {
itemType: 'journalArticle',
title: 'Article One'
});
item1.setField('DOI', '10.1234/test.doi');
await item1.saveTx();
var cslItem = {
type: 'article-journal',
title: 'Completely Different Title',
DOI: '10.1234/test.doi'
};
var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID);
var dupes = await d.findDuplicatesOf(cslItem);
assert.include(dupes, item1.id);
});
it("should find duplicates by ISBN", async function () {
var item1 = await createDataObject('item', {
itemType: 'book',
title: 'My Book'
});
item1.setField('ISBN', '978-0-306-40615-7');
await item1.saveTx();
var cslItem = {
type: 'book',
title: 'Some Other Book Title',
ISBN: '978-0-306-40615-7'
};
var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID);
var dupes = await d.findDuplicatesOf(cslItem);
assert.include(dupes, item1.id);
});
it("should not match items with same title but conflicting years", async function () {
var item1 = await createDataObject('item', {
title: 'Year Conflict Title',
creators: [{
firstName: 'Alice',
lastName: 'Test',
creatorType: 'author'
}]
});
item1.setField('date', '2020');
await item1.saveTx();
var cslItem = {
type: 'book',
title: 'Year Conflict Title',
author: [{ family: 'Test', given: 'Alice' }],
issued: { 'date-parts': [[2015]] }
};
var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID);
var dupes = await d.findDuplicatesOf(cslItem);
assert.notInclude(dupes, item1.id);
});
it("should not match items with same title but different creators", async function () {
var item1 = await createDataObject('item', {
title: 'Creator Mismatch Title',
creators: [{
firstName: 'Alice',
lastName: 'One',
creatorType: 'author'
}]
});
var cslItem = {
type: 'book',
title: 'Creator Mismatch Title',
author: [{ family: 'Two', given: 'Bob' }]
};
var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID);
var dupes = await d.findDuplicatesOf(cslItem);
assert.notInclude(dupes, item1.id);
});
it("should return empty array when no duplicates exist", async function () {
var cslItem = {
type: 'book',
title: 'Absolutely Unique Title ' + Zotero.Utilities.randomString(),
author: [{ family: 'Nobody', given: 'X' }]
};
var d = new Zotero.Duplicates(Zotero.Libraries.userLibraryID);
var dupes = await d.findDuplicatesOf(cslItem);
assert.lengthOf(dupes, 0);
});
});
describe("Merging", function () {
it("should merge two items in duplicates view", async function () {
var item1 = await createDataObject('item', { setTitle: true });

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;

View file

@ -153,6 +153,73 @@ describe("Zotero.Items", function () {
});
describe("#copyToLibrary()", function () {
var group;
var copyChildNotes;
before(async function () {
group = await createGroup();
});
beforeEach(function () {
copyChildNotes = Zotero.Prefs.get('groups.copyChildNotes');
Zotero.Prefs.set('groups.copyChildNotes', true);
});
afterEach(function () {
Zotero.Prefs.set('groups.copyChildNotes', copyChildNotes);
});
after(async function () {
await group.eraseTx();
});
it("should copy and link a regular item into a group and reuse it", async function () {
var item = await createDataObject('item');
var newItem;
await Zotero.DB.executeTransaction(async function () {
newItem = await Zotero.Items.copyToLibrary(item, group.libraryID);
});
assert.instanceOf(newItem, Zotero.Item);
assert.equal(newItem.libraryID, group.libraryID);
assert.equal(await item.getLinkedItem(group.libraryID, true), newItem);
var reusedItem;
await Zotero.DB.executeTransaction(async function () {
reusedItem = await Zotero.Items.copyToLibrary(item, group.libraryID);
});
assert.strictEqual(reusedItem, newItem);
});
it("should copy a child note", async function () {
var item = await createDataObject('item');
var note = await createDataObject('item', { itemType: 'note', parentID: item.id });
var newItem;
await Zotero.DB.executeTransaction(async function () {
newItem = await Zotero.Items.copyToLibrary(item, group.libraryID);
});
var notes = Zotero.Items.get(newItem.getNotes());
assert.lengthOf(notes, 1);
assert.equal(await note.getLinkedItem(group.libraryID, true), notes[0]);
});
it("should honor disabled child-note copying", async function () {
var item = await createDataObject('item');
await createDataObject('item', { itemType: 'note', parentID: item.id });
var newItem;
await Zotero.DB.executeTransaction(async function () {
newItem = await Zotero.Items.copyToLibrary(item, group.libraryID, {
childNotes: false,
});
});
assert.isEmpty(newItem.getNotes());
});
});
describe("#copyChildItems()", function () {
var group;

View file

@ -0,0 +1,152 @@
"use strict";
describe("Select Items Dialog", function () {
var candidate1;
var candidate2;
var childNote;
var excluded;
var collection;
var group;
async function openDialog(itemIDs) {
let io = {
dataOut: null,
itemIDs,
multiSelect: false,
onlyRegularItems: true,
deferred: Zotero.Promise.defer()
};
let dialogPromise = waitForWindow('chrome://zotero/content/selectItemsDialog.xhtml');
Services.ww.openWindow(null, 'chrome://zotero/content/selectItemsDialog.xhtml', '',
'chrome,dialog=no,centerscreen,resizable=yes', io);
let dialog = await dialogPromise;
await waitForCallback(() => dialog.loaded);
return { dialog, io };
}
async function selectSuggestedItems(dialog) {
let row = dialog.collectionsView.getRowIndexByID('suggested-items');
assert.isNumber(row);
await dialog.collectionsView.selectWait(row);
}
function assertLibraryColumnPosition(dialog) {
let columns = dialog.itemsView._getColumns();
let libraryColumnIndex = columns.findIndex(column => column.dataKey === 'library');
let attachmentColumnIndex = columns.findIndex(column => column.dataKey === 'hasAttachment');
assert.equal(libraryColumnIndex, attachmentColumnIndex - 1);
assert.isAtLeast(parseInt(columns[libraryColumnIndex].width), 120);
}
before(async function () {
candidate1 = await createDataObject('item', { title: 'Candidate One' });
group = await createGroup({ name: 'Candidate Library' });
candidate2 = await createDataObject('item', {
title: 'Candidate Two',
libraryID: group.libraryID
});
childNote = await createDataObject('item', { itemType: 'note', parentID: candidate1.id });
excluded = await createDataObject('item', { title: 'Excluded Item' });
collection = await createDataObject('collection', { name: 'Browsable Collection' });
});
after(async function () {
await group.eraseTx();
});
describe("with suggestions from multiple libraries", function () {
var dialog;
var showInLibrary;
before(async function () {
({ dialog } = await openDialog([candidate1.id, candidate2.id]));
showInLibrary = sinon.stub(Zotero.Utilities.Internal, 'showInLibrary').resolves();
await waitForCallback(() => dialog.itemsView.getRowIndexByID(candidate2.id) !== false);
});
beforeEach(async function () {
await selectSuggestedItems(dialog);
await dialog.itemsView.setFilter('search', '');
dialog.document.getElementById('zotero-tb-search').searchTextbox.value = '';
dialog.itemsView.selection.clearSelection();
showInLibrary.resetHistory();
});
after(function () {
showInLibrary.restore();
if (!dialog.closed) {
dialog.close();
}
});
it("should show Suggested Items above the normal collection tree", function () {
assert.equal(dialog.collectionsView.getRow(0).id, 'suggested-items');
assert.equal(dialog.collectionsView.getRow(0).type, 'suggestedItems');
assert.isTrue(dialog.collectionsView.getRow(1).isSeparator());
assert.isNumber(
dialog.collectionsView.getRowIndexByID(`L${Zotero.Libraries.userLibraryID}`)
);
assert.isNumber(dialog.collectionsView.getRowIndexByID(`C${collection.id}`));
assert.isFalse(dialog.document.getElementById('zotero-collections-tree-container').hidden);
assert.isFalse(dialog.document.getElementById('search-toolbar').hidden);
});
it("should show only regular candidate items", function () {
assert.isNumber(dialog.itemsView.getRowIndexByID(candidate1.id));
assert.isNumber(dialog.itemsView.getRowIndexByID(candidate2.id));
assert.strictEqual(dialog.itemsView.getRowIndexByID(childNote.id), false);
assert.strictEqual(dialog.itemsView.getRowIndexByID(excluded.id), false);
});
it("should show the Library column before Attachments", async function () {
let libraryColumn = dialog.itemsView._getColumns()
.find(column => column.dataKey === 'library');
assert.isOk(libraryColumn);
assert.isFalse(libraryColumn.hidden);
assertLibraryColumnPosition(dialog);
await dialog.itemsView.selectItem(candidate2.id);
let rowIndex = dialog.itemsView.getRowIndexByID(candidate2.id);
let row = dialog.document.getElementById(`${dialog.itemsView.id}-row-${rowIndex}`);
let icon = row.querySelector('.library .icon-library-group');
assert.isOk(icon);
let iconStyle = dialog.getComputedStyle(icon);
assert.notEqual(iconStyle.backgroundImage, 'none');
assert.equal(iconStyle.width, '16px');
assert.equal(iconStyle.height, '16px');
});
it("should enable Show in Library for a selected suggestion", async function () {
let button = dialog.document.querySelector("dialog button[dlgtype='extra1']");
assert.isFalse(button.hidden);
assert.isTrue(button.disabled);
await dialog.itemsView.selectItem(candidate2.id);
assert.isFalse(button.disabled);
button.click();
await waitForCallback(() => showInLibrary.called);
sinon.assert.calledOnceWithExactly(showInLibrary, [candidate2]);
});
});
describe("with suggestions only from My Library", function () {
var dialog;
before(async function () {
({ dialog } = await openDialog([candidate1.id]));
await waitForCallback(() => dialog.itemsView.getRowIndexByID(candidate1.id) !== false);
});
after(function () {
if (!dialog.closed) {
dialog.close();
}
});
it("should not add the Library column", function () {
let libraryColumn = dialog.itemsView._getColumns()
.find(column => column.dataKey === 'library');
assert.isUndefined(libraryColumn);
});
});
});