mirror of
https://github.com/zotero/zotero.git
synced 2026-10-03 02:21:49 +00:00
Merge 3d1ece74e4 into cc951f9916
This commit is contained in:
commit
ea2e511d55
40 changed files with 3336 additions and 882 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
349
chrome/content/zotero/integration/citationExplorerItemTree.js
Normal file
349
chrome/content/zotero/integration/citationExplorerItemTree.js
Normal 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,
|
||||
};
|
||||
|
||||
694
chrome/content/zotero/integration/citationExplorerWizard.js
Normal file
694
chrome/content/zotero/integration/citationExplorerWizard.js
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2026 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
const { COLUMNS } = require('zotero/itemTreeColumns');
|
||||
const {
|
||||
CitationExplorerItemTree,
|
||||
CitationExplorerItemTreeRow,
|
||||
LibraryItemTreeRow,
|
||||
} = require('zotero/integration/citationExplorerItemTree');
|
||||
|
||||
const ACTION_ADD_TO_COLLECTION = 'addToCollection';
|
||||
const ACTION_RELINK_TO_EXISTING = 'relinkToExisting';
|
||||
const ACTION_COPY_FROM_OTHER_LIBRARY = 'copyFromOtherLibrary';
|
||||
const ACTION_ADD_FROM_DOCUMENT = 'addFromDocument';
|
||||
const ACTION_CHOOSE = 'choose';
|
||||
const ACTION_SKIP = 'skip';
|
||||
|
||||
/**
|
||||
* An action offered for a document item.
|
||||
*
|
||||
* @typedef {Object} ItemAction
|
||||
* @property {String} type - One of the ACTION_* constants above.
|
||||
* @property {Zotero.Item} [item] - Existing item to relink to or copy from. Actions that
|
||||
* create or skip an item do not need one.
|
||||
*/
|
||||
|
||||
/**
|
||||
* State used to determine and apply the action for a document item.
|
||||
*
|
||||
* @typedef {Object} CitationExplorerWizardItemData
|
||||
* @property {Zotero.Item} item
|
||||
* @property {Boolean} isCited - Used for Uncited badge
|
||||
* @property {ItemAction[]} availableActions
|
||||
* @property {Number} selectedActionIndex
|
||||
* @property {Number[]} candidateItemIDs - IDs of matching library items suggested by duplicate
|
||||
* detection. Used to seed actions and item picker.
|
||||
*/
|
||||
|
||||
const ITEM_TREE_COLUMNS = ['title', 'firstCreator', 'year'].map((dataKey) => {
|
||||
let column = COLUMNS.find(col => col.dataKey === dataKey);
|
||||
return Object.assign({}, column, {
|
||||
hidden: false,
|
||||
sortDirection: dataKey === 'title' ? 1 : 0,
|
||||
});
|
||||
});
|
||||
ITEM_TREE_COLUMNS.find(column => column.dataKey === 'title').flex = 1;
|
||||
ITEM_TREE_COLUMNS.find(column => column.dataKey === 'firstCreator').width = 90;
|
||||
ITEM_TREE_COLUMNS.find(column => column.dataKey === 'year').width = 70;
|
||||
|
||||
let io;
|
||||
let wizard;
|
||||
let itemTree;
|
||||
let targetCollection;
|
||||
|
||||
/** @type {Map<Number|String, CitationExplorerWizardItemData>} */
|
||||
let itemData = new Map();
|
||||
let actionDisplay = new WeakMap();
|
||||
let isApplying = false;
|
||||
let isChoosingItem = false;
|
||||
|
||||
window.ZoteroCitationExplorerWizard = {
|
||||
async init() {
|
||||
io = window.arguments[0].wrappedJSObject ?? window.arguments[0];
|
||||
wizard = document.getElementById('citation-explorer-wizard');
|
||||
this._initMappings();
|
||||
|
||||
window.addEventListener('close', (event) => {
|
||||
if (isApplying) event.preventDefault();
|
||||
});
|
||||
wizard.getPageById('choose-operation')
|
||||
.addEventListener('pageshow', this.onChoosePageShow.bind(this));
|
||||
wizard.getPageById('review-actions')
|
||||
.addEventListener('pageshow', this.onReviewPageShow.bind(this));
|
||||
wizard.addEventListener('wizardfinish', this.onFinish.bind(this));
|
||||
|
||||
// wizard.shadowRoot content isn't exposed to our CSS
|
||||
wizard.shadowRoot
|
||||
.querySelector('.wizard-header-label').style.fontSize = '16px';
|
||||
|
||||
// The first page is already shown by the time the window's load handler runs.
|
||||
this.onChoosePageShow();
|
||||
},
|
||||
|
||||
unload() {
|
||||
itemTree?.unregister();
|
||||
io?.deferred?.resolve();
|
||||
},
|
||||
|
||||
onChoosePageShow() {
|
||||
let operation = document.getElementById('item-operation');
|
||||
let hasUnlinkedItems = [...itemData.values()].some(data => !data.item.id);
|
||||
document.getElementById('item-operation-relink').hidden = !hasUnlinkedItems;
|
||||
document.getElementById('item-operation-relink-description').hidden = !hasUnlinkedItems;
|
||||
if (!operation.value || (!hasUnlinkedItems && operation.value === 'relink')) {
|
||||
operation.value = hasUnlinkedItems ? 'relink' : 'addToTarget';
|
||||
}
|
||||
targetCollection ??= this._getDefaultLibrary();
|
||||
this.buildTargetMenu();
|
||||
wizard.canRewind = false;
|
||||
},
|
||||
|
||||
async onReviewPageShow() {
|
||||
wizard.canRewind = false;
|
||||
wizard.canAdvance = false;
|
||||
wizard.getButton('finish').disabled = true;
|
||||
await this.initItemTree();
|
||||
await itemTree.setItemsPaneMessage(
|
||||
await document.l10n.formatValue('integration-citationExplorerWizard-status-finding')
|
||||
);
|
||||
|
||||
try {
|
||||
await this._initActions();
|
||||
await this._setItemTreeItems();
|
||||
wizard.canRewind = true;
|
||||
this._updateCanFinish();
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
await itemTree.setItemsPaneMessage(
|
||||
await document.l10n.formatValue('integration-citationExplorerWizard-status-find-error')
|
||||
);
|
||||
wizard.canRewind = true;
|
||||
}
|
||||
},
|
||||
|
||||
_initMappings() {
|
||||
itemData = new Map();
|
||||
for (let item of [...io.items, ...io.uncitedItems]) {
|
||||
let itemID = this._getItemID(item);
|
||||
// See CitationExplorerWizardItemData
|
||||
itemData.set(itemID, {
|
||||
item,
|
||||
isCited: false,
|
||||
availableActions: [],
|
||||
selectedActionIndex: -1,
|
||||
candidateItemIDs: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (let citation of Object.values(io.citations)) {
|
||||
for (let citationItem of citation.citationItems) {
|
||||
let data = itemData.get(this._getItemID(citationItem));
|
||||
data.isCited = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_getItemID(item) {
|
||||
return item.id || item.cslItemID;
|
||||
},
|
||||
|
||||
_getLibraries() {
|
||||
return Zotero.Libraries.getAll().filter(library => library.editable
|
||||
&& library.libraryType !== 'publications');
|
||||
},
|
||||
|
||||
/**
|
||||
* Default to the eligible library with the highest number of linked citation
|
||||
* occurrences. Uncited linked items count once.
|
||||
*/
|
||||
_getDefaultLibrary() {
|
||||
let libraries = this._getLibraries();
|
||||
if (!libraries.length) return null;
|
||||
|
||||
let counts = new Map();
|
||||
for (let citation of Object.values(io.citations)) {
|
||||
for (let citationItem of citation.citationItems) {
|
||||
let item = itemData.get(this._getItemID(citationItem))?.item;
|
||||
if (item?.id) {
|
||||
counts.set(item.libraryID, (counts.get(item.libraryID) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let item of io.uncitedItems) {
|
||||
if (item.id) {
|
||||
counts.set(item.libraryID, (counts.get(item.libraryID) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return libraries.reduce((defaultLibrary, library) => {
|
||||
return (counts.get(library.libraryID) || 0)
|
||||
> (counts.get(defaultLibrary.libraryID) || 0)
|
||||
? library
|
||||
: defaultLibrary;
|
||||
}, libraries[0]);
|
||||
},
|
||||
|
||||
buildTargetMenu() {
|
||||
let targetField = document.getElementById('item-target');
|
||||
let menu = document.getElementById('item-target-menu');
|
||||
menu.replaceChildren();
|
||||
|
||||
let selectTarget = (event, libraryOrCollection) => {
|
||||
targetCollection = libraryOrCollection;
|
||||
targetField.open = false;
|
||||
event.stopPropagation();
|
||||
this.buildTargetMenu();
|
||||
};
|
||||
for (let library of this._getLibraries()) {
|
||||
Zotero.Utilities.Internal.createMenuForTarget(
|
||||
library,
|
||||
menu,
|
||||
targetCollection?.treeViewID,
|
||||
selectTarget,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
|
||||
// A collection can be nested in a submenu, so the menulist doesn't derive its
|
||||
// displayed value from a direct child menuitem.
|
||||
targetField.selectedIndex = -1;
|
||||
if (targetCollection) {
|
||||
targetField.setAttribute('label', targetCollection.name);
|
||||
targetField.setAttribute('image', targetCollection.treeViewImage);
|
||||
}
|
||||
else {
|
||||
targetField.removeAttribute('label');
|
||||
targetField.removeAttribute('image');
|
||||
}
|
||||
wizard.canAdvance = !!targetCollection;
|
||||
},
|
||||
|
||||
async initItemTree() {
|
||||
if (itemTree) return;
|
||||
|
||||
let actionColumn = {
|
||||
dataKey: 'action',
|
||||
label: await document.l10n.formatValue(
|
||||
'integration-citationExplorerWizard-action-column'
|
||||
),
|
||||
width: 360,
|
||||
minWidth: 280,
|
||||
renderCell: (index, _data, column, _isFirstColumn, doc) => {
|
||||
return this._renderActionCell(itemTree.getRow(index), column, doc);
|
||||
},
|
||||
};
|
||||
|
||||
itemTree = await CitationExplorerItemTree.init(
|
||||
document.getElementById('zotero-items-tree'),
|
||||
{
|
||||
id: 'citation-explorer-item-actions',
|
||||
regularOnly: false,
|
||||
columns: [...ITEM_TREE_COLUMNS, actionColumn],
|
||||
columnPicker: false,
|
||||
multiSelect: false,
|
||||
shouldListenForNotifications: false,
|
||||
onActivate: () => this._openActionMenuForFocusedRow(),
|
||||
onActionMenuOpen: () => this._openActionMenuForFocusedRow(),
|
||||
}
|
||||
);
|
||||
await itemTree.waitForLoad();
|
||||
},
|
||||
|
||||
async _initActions() {
|
||||
actionDisplay = new WeakMap();
|
||||
let targetLibrary = Zotero.Libraries.get(targetCollection.libraryID);
|
||||
if (!targetLibrary.getDataLoaded('item')) {
|
||||
await targetLibrary.waitForDataLoad('item');
|
||||
}
|
||||
|
||||
let operation = document.getElementById('item-operation').value;
|
||||
for (let data of itemData.values()) {
|
||||
data.availableActions = [];
|
||||
data.selectedActionIndex = -1;
|
||||
data.candidateItemIDs = [];
|
||||
|
||||
if (operation === 'relink' && data.item.id) continue;
|
||||
|
||||
let result = await this._getItemActions(data.item);
|
||||
data.availableActions = result.availableActions;
|
||||
data.candidateItemIDs = result.candidateItemIDs;
|
||||
for (let action of data.availableActions) {
|
||||
actionDisplay.set(action, await this._getActionDisplay(action));
|
||||
}
|
||||
if (data.availableActions.length) data.selectedActionIndex = 0;
|
||||
}
|
||||
},
|
||||
|
||||
async _getItemActions(item) {
|
||||
let availableActions = [
|
||||
{ type: ACTION_CHOOSE },
|
||||
{ type: ACTION_SKIP },
|
||||
];
|
||||
let candidateItemIDs = [];
|
||||
let targetLibraryID = targetCollection.libraryID;
|
||||
let collectionID = targetCollection.objectType === 'collection'
|
||||
? targetCollection.id
|
||||
: null;
|
||||
|
||||
// Item already in target library, add to library.
|
||||
if (item.id && item.libraryID === targetLibraryID) {
|
||||
if (collectionID && !item.inCollection(collectionID)) {
|
||||
availableActions.unshift({ type: ACTION_ADD_TO_COLLECTION });
|
||||
return { availableActions, candidateItemIDs };
|
||||
}
|
||||
return { availableActions: [], candidateItemIDs };
|
||||
}
|
||||
|
||||
let linkedItem = item.id
|
||||
? await item.getLinkedItem(targetLibraryID, true)
|
||||
: null;
|
||||
// Exists in the target library, as a linked item, so relink.
|
||||
if (linkedItem) {
|
||||
candidateItemIDs.push(linkedItem.id);
|
||||
availableActions.unshift({ type: ACTION_RELINK_TO_EXISTING, item: linkedItem });
|
||||
return { availableActions, candidateItemIDs };
|
||||
}
|
||||
|
||||
let libraryIDs = Zotero.Libraries.getAll()
|
||||
.filter(library => library.libraryType !== 'feed')
|
||||
.map(library => library.libraryID);
|
||||
let duplicates = new Zotero.Duplicates(libraryIDs);
|
||||
candidateItemIDs = await duplicates.findDuplicatesOf(item);
|
||||
let candidateItems = await Zotero.Items.getAsync(candidateItemIDs);
|
||||
let targetMatch = candidateItems.find(candidate => candidate.libraryID === targetLibraryID);
|
||||
|
||||
// Exists in the target library as a duplicate item, so offer to relink
|
||||
if (targetMatch) {
|
||||
availableActions.unshift({
|
||||
type: ACTION_RELINK_TO_EXISTING,
|
||||
item: targetMatch,
|
||||
});
|
||||
return { availableActions, candidateItemIDs };
|
||||
}
|
||||
|
||||
// If it doesn't exist in the target library, we offer to copy from another library
|
||||
// add from the document, or choose a different item in the item selector.
|
||||
|
||||
// Offer at most one copy source per library. The current linked item is the
|
||||
// preferred source for its library even if another candidate also exists there.
|
||||
let copySources = item.id ? [item] : [];
|
||||
let copySourceLibraryIDs = new Set(copySources.map(source => source.libraryID));
|
||||
for (let candidate of candidateItems) {
|
||||
if (candidate.libraryID !== targetLibraryID
|
||||
&& !copySourceLibraryIDs.has(candidate.libraryID)) {
|
||||
copySources.push(candidate);
|
||||
copySourceLibraryIDs.add(candidate.libraryID);
|
||||
}
|
||||
}
|
||||
availableActions.unshift(...copySources.map(source => ({
|
||||
type: ACTION_COPY_FROM_OTHER_LIBRARY,
|
||||
item: source,
|
||||
})));
|
||||
|
||||
if (!item.id) {
|
||||
availableActions.splice(-2, 0, { type: ACTION_ADD_FROM_DOCUMENT });
|
||||
}
|
||||
else {
|
||||
candidateItemIDs.push(item.id);
|
||||
}
|
||||
return { availableActions, candidateItemIDs };
|
||||
},
|
||||
|
||||
async _setItemTreeItems() {
|
||||
let rows = [...itemData.entries()]
|
||||
.filter(([, data]) => data.availableActions.length)
|
||||
.map(([itemID, data]) => new CitationExplorerItemTreeRow(data.item, 0, false, {
|
||||
// The wizard only needs citation presence for the Uncited badge, while Citation
|
||||
// Explorer rows store actual citation indexes for selection highlighting.
|
||||
citedIn: data.isCited ? [0] : [],
|
||||
cslItemID: data.item.id ? null : itemID,
|
||||
}));
|
||||
|
||||
await itemTree.clearItemsPaneMessage();
|
||||
await itemTree.setItems(rows);
|
||||
if (!rows.length) {
|
||||
await itemTree.setItemsPaneMessage(
|
||||
await document.l10n.formatValue('integration-citationExplorerWizard-status-no-changes')
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
_openActionMenuForFocusedRow() {
|
||||
let index = itemTree.selection.focused;
|
||||
let row = itemTree.getRow(index);
|
||||
if (!row || row instanceof LibraryItemTreeRow) return false;
|
||||
|
||||
let rowElement = document.getElementById(`${itemTree.id}-row-${index}`);
|
||||
let menulist = rowElement?.querySelector('.item-action-select');
|
||||
if (!menulist) return false;
|
||||
menulist.open = true;
|
||||
// Focusing before opening can rerender the virtualized row and discard the control.
|
||||
menulist.focus();
|
||||
return true;
|
||||
},
|
||||
|
||||
async _getActionDisplay(action) {
|
||||
let l10nID;
|
||||
let l10nArgs;
|
||||
let icon;
|
||||
switch (action.type) {
|
||||
case ACTION_ADD_TO_COLLECTION:
|
||||
case ACTION_ADD_FROM_DOCUMENT:
|
||||
l10nID = 'integration-citationExplorerWizard-action-add-to-target';
|
||||
l10nArgs = { target: targetCollection.name };
|
||||
icon = targetCollection.treeViewImage;
|
||||
break;
|
||||
|
||||
case ACTION_RELINK_TO_EXISTING:
|
||||
l10nID = 'integration-citationExplorerWizard-action-relink';
|
||||
l10nArgs = { title: action.item.getDisplayTitle() };
|
||||
icon = targetCollection.treeViewImage;
|
||||
break;
|
||||
|
||||
case ACTION_COPY_FROM_OTHER_LIBRARY:
|
||||
l10nID = 'integration-citationExplorerWizard-action-copy';
|
||||
l10nArgs = { library: action.item.library.name };
|
||||
icon = action.item.library.treeViewImage;
|
||||
break;
|
||||
|
||||
case ACTION_CHOOSE:
|
||||
l10nID = 'integration-citationExplorerWizard-action-choose';
|
||||
break;
|
||||
|
||||
case ACTION_SKIP:
|
||||
l10nID = 'integration-citationExplorerWizard-action-skip';
|
||||
icon = 'chrome://zotero/skin/16/universal/cross.svg';
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown item action '${action.type}'`);
|
||||
}
|
||||
return {
|
||||
label: await document.l10n.formatValue(l10nID, l10nArgs),
|
||||
icon,
|
||||
};
|
||||
},
|
||||
|
||||
_renderActionCell(row, column, doc) {
|
||||
let cell = doc.createElement('span');
|
||||
cell.className = `cell ${column.className}`;
|
||||
if (row instanceof LibraryItemTreeRow) return cell;
|
||||
|
||||
let data = itemData.get(row.id);
|
||||
let selectedAction = this._getSelectedAction(row.id);
|
||||
let select = doc.createXULElement('menulist');
|
||||
select.className = 'item-action-select';
|
||||
select.setAttribute('native', 'true');
|
||||
select.setAttribute('tabindex', '-1');
|
||||
select.classList.toggle('skip-selected', selectedAction.type === ACTION_SKIP);
|
||||
let popup = doc.createXULElement('menupopup');
|
||||
for (let [actionIndex, action] of data.availableActions.entries()) {
|
||||
let { label, icon } = actionDisplay.get(action);
|
||||
let option = doc.createXULElement('menuitem');
|
||||
option.value = String(actionIndex);
|
||||
option.setAttribute('label', label);
|
||||
if (icon) {
|
||||
option.setAttribute('image', icon);
|
||||
option.classList.add('menuitem-iconic');
|
||||
}
|
||||
option.classList.toggle('skip-action', action.type === ACTION_SKIP);
|
||||
option.addEventListener('command', async (event) => {
|
||||
event.stopPropagation();
|
||||
await this._onActionChanged(row.id, actionIndex);
|
||||
});
|
||||
popup.appendChild(option);
|
||||
}
|
||||
popup.addEventListener('popuphidden', () => {
|
||||
if (!isChoosingItem) itemTree.tree.focus();
|
||||
});
|
||||
select.appendChild(popup);
|
||||
select.value = String(data.selectedActionIndex);
|
||||
select.addEventListener('mousedown', event => event.stopPropagation());
|
||||
select.addEventListener('mouseup', event => event.stopPropagation());
|
||||
select.addEventListener('keydown', event => event.stopPropagation());
|
||||
cell.appendChild(select);
|
||||
|
||||
if (selectedAction.type === ACTION_CHOOSE) {
|
||||
let chooseButton = doc.createElement('button');
|
||||
chooseButton.className = 'choose-item-button';
|
||||
chooseButton.tabIndex = -1;
|
||||
doc.l10n.setAttributes(
|
||||
chooseButton,
|
||||
'integration-citationExplorerWizard-choose-button'
|
||||
);
|
||||
chooseButton.addEventListener('mousedown', event => event.stopPropagation());
|
||||
chooseButton.addEventListener('mouseup', event => event.stopPropagation());
|
||||
chooseButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
await this._chooseItem(row.id);
|
||||
this._invalidateItemRow(row.id);
|
||||
this._updateCanFinish();
|
||||
itemTree.tree.focus();
|
||||
});
|
||||
cell.appendChild(chooseButton);
|
||||
}
|
||||
return cell;
|
||||
},
|
||||
|
||||
async _onActionChanged(itemID, selectedActionIndex) {
|
||||
let data = itemData.get(itemID);
|
||||
data.selectedActionIndex = selectedActionIndex;
|
||||
this._updateCanFinish();
|
||||
if (this._getSelectedAction(itemID).type === ACTION_CHOOSE) {
|
||||
await this._chooseItem(itemID);
|
||||
}
|
||||
this._invalidateItemRow(itemID);
|
||||
this._updateCanFinish();
|
||||
itemTree.tree.focus();
|
||||
},
|
||||
|
||||
async _chooseItem(itemID) {
|
||||
if (isChoosingItem) return;
|
||||
isChoosingItem = true;
|
||||
try {
|
||||
let data = itemData.get(itemID);
|
||||
let chooserIO = {
|
||||
dataIn: null,
|
||||
dataOut: null,
|
||||
itemIDs: data.candidateItemIDs.length ? data.candidateItemIDs : undefined,
|
||||
multiSelect: false,
|
||||
onlyRegularItems: true,
|
||||
deferred: Zotero.Promise.defer(),
|
||||
};
|
||||
chooserIO.wrappedJSObject = chooserIO;
|
||||
let chooserWindow = window.openDialog(
|
||||
'chrome://zotero/content/selectItemsDialog.xhtml',
|
||||
'',
|
||||
'chrome,dialog=yes,centerscreen,resizable=yes',
|
||||
chooserIO
|
||||
);
|
||||
Zotero.Utilities.Internal.activate(chooserWindow);
|
||||
await chooserIO.deferred.promise;
|
||||
if (!chooserIO.dataOut?.length) return;
|
||||
|
||||
let chosenItem = await Zotero.Items.getAsync(chooserIO.dataOut[0]);
|
||||
let actionType = chosenItem.libraryID === targetCollection.libraryID
|
||||
? ACTION_RELINK_TO_EXISTING
|
||||
: ACTION_COPY_FROM_OTHER_LIBRARY;
|
||||
|
||||
// Keep only one manually chosen action. Candidate actions can be identified
|
||||
// by candidateItemIDs and remain available when another item is chosen.
|
||||
let extraActionIndex = data.availableActions.findIndex((action) => {
|
||||
return [ACTION_RELINK_TO_EXISTING, ACTION_COPY_FROM_OTHER_LIBRARY].includes(action.type)
|
||||
&& !data.candidateItemIDs.includes(action.item.id);
|
||||
});
|
||||
if (extraActionIndex !== -1) {
|
||||
data.availableActions.splice(extraActionIndex, 1);
|
||||
}
|
||||
|
||||
let selectedActionIndex = data.availableActions.findIndex((action) => {
|
||||
return action.type === actionType && action.item?.id === chosenItem.id;
|
||||
});
|
||||
if (selectedActionIndex === -1) {
|
||||
let chooseActionIndex = data.availableActions.findIndex(
|
||||
action => action.type === ACTION_CHOOSE
|
||||
);
|
||||
selectedActionIndex = chooseActionIndex;
|
||||
let action = {
|
||||
type: actionType,
|
||||
item: chosenItem,
|
||||
};
|
||||
data.availableActions.splice(selectedActionIndex, 0, action);
|
||||
actionDisplay.set(action, await this._getActionDisplay(action));
|
||||
}
|
||||
data.selectedActionIndex = selectedActionIndex;
|
||||
}
|
||||
finally {
|
||||
isChoosingItem = false;
|
||||
}
|
||||
},
|
||||
|
||||
_invalidateItemRow(itemID) {
|
||||
let index = itemTree.getRowIndexByID(itemID);
|
||||
if (index !== false) itemTree.tree.invalidateRow(index);
|
||||
},
|
||||
|
||||
_getSelectedAction(itemID) {
|
||||
let data = itemData.get(itemID);
|
||||
return data.availableActions[data.selectedActionIndex] || null;
|
||||
},
|
||||
|
||||
_updateCanFinish() {
|
||||
let canFinish = [...itemData.entries()]
|
||||
.filter(([, data]) => data.availableActions.length)
|
||||
.every(([itemID]) => {
|
||||
let action = this._getSelectedAction(itemID);
|
||||
return action && action.type !== ACTION_CHOOSE;
|
||||
});
|
||||
wizard.canAdvance = canFinish;
|
||||
wizard.getButton('finish').disabled = !canFinish;
|
||||
},
|
||||
|
||||
async onFinish(event) {
|
||||
event.preventDefault();
|
||||
if (!wizard.canAdvance || isApplying) return;
|
||||
|
||||
isApplying = true;
|
||||
wizard.canAdvance = false;
|
||||
wizard.canRewind = false;
|
||||
wizard.getButton('finish').disabled = true;
|
||||
wizard.getButton('cancel').disabled = true;
|
||||
await itemTree.setItemsPaneMessage(
|
||||
await document.l10n.formatValue('integration-citationExplorerWizard-status-applying'),
|
||||
true
|
||||
);
|
||||
|
||||
try {
|
||||
let replacements = [];
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
for (let [itemID, data] of itemData) {
|
||||
if (!data.availableActions.length) continue;
|
||||
let action = this._getSelectedAction(itemID);
|
||||
if (action.type === ACTION_SKIP) continue;
|
||||
|
||||
let destinationItem = await this._applyAction(data.item, action);
|
||||
if (action.type !== ACTION_ADD_TO_COLLECTION && destinationItem.id != itemID) {
|
||||
replacements.push({ oldItemID: itemID, item: destinationItem });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (replacements.length) io.relinkItems(replacements);
|
||||
io.completed = true;
|
||||
isApplying = false;
|
||||
window.close();
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
isApplying = false;
|
||||
itemTree._locked = false;
|
||||
await itemTree.setItemsPaneMessage(
|
||||
await document.l10n.formatValue('integration-citationExplorerWizard-status-apply-error')
|
||||
);
|
||||
wizard.getButton('cancel').disabled = false;
|
||||
wizard.canRewind = true;
|
||||
this._updateCanFinish();
|
||||
}
|
||||
},
|
||||
|
||||
async _applyAction(documentItem, action) {
|
||||
Zotero.DB.requireTransaction();
|
||||
let destinationItem;
|
||||
switch (action.type) {
|
||||
case ACTION_ADD_TO_COLLECTION:
|
||||
destinationItem = documentItem;
|
||||
break;
|
||||
|
||||
case ACTION_RELINK_TO_EXISTING:
|
||||
destinationItem = action.item;
|
||||
break;
|
||||
|
||||
case ACTION_COPY_FROM_OTHER_LIBRARY:
|
||||
destinationItem = await Zotero.Items.copyToLibrary(
|
||||
action.item,
|
||||
targetCollection.libraryID
|
||||
);
|
||||
break;
|
||||
|
||||
case ACTION_ADD_FROM_DOCUMENT:
|
||||
destinationItem = documentItem.clone(targetCollection.libraryID);
|
||||
await destinationItem.save({ skipSelect: true });
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Cannot apply item action '${action.type}'`);
|
||||
}
|
||||
|
||||
if (!destinationItem) {
|
||||
throw new Error(`Could not create destination item in library ${targetCollection.libraryID}`);
|
||||
}
|
||||
|
||||
// Copy to collection if target is a collection
|
||||
if (targetCollection.objectType === 'collection'
|
||||
&& !destinationItem.inCollection(targetCollection.id)) {
|
||||
destinationItem.addToCollection(targetCollection.id);
|
||||
await destinationItem.save({ skipSelect: true });
|
||||
}
|
||||
return destinationItem;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<?xml version="1.0"?>
|
||||
<!--
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2026 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
-->
|
||||
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://zotero-platform/content/overlay.css" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://zotero-platform/content/zotero.css"?>
|
||||
|
||||
<window
|
||||
id="citation-explorer-wizard-window"
|
||||
width="1000"
|
||||
height="650"
|
||||
persist="screenX screenY width height"
|
||||
data-l10n-id="integration-citationExplorerWizard-window"
|
||||
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
|
||||
xmlns:html="http://www.w3.org/1999/xhtml"
|
||||
onload="ZoteroCitationExplorerWizard.init()"
|
||||
onunload="ZoteroCitationExplorerWizard.unload()"
|
||||
>
|
||||
<linkset>
|
||||
<html:link rel="localization" href="branding/brand.ftl"/>
|
||||
<html:link rel="localization" href="zotero.ftl"/>
|
||||
<html:link rel="localization" href="integration.ftl"/>
|
||||
</linkset>
|
||||
|
||||
<script>
|
||||
Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this);
|
||||
Services.scriptloader.loadSubScript("chrome://zotero/content/customElements.js", this);
|
||||
Services.scriptloader.loadSubScript("chrome://zotero/content/integration/citationExplorerWizard.js", this);
|
||||
</script>
|
||||
|
||||
<!-- Add the View menu before ItemTree initializes so it appears on first open -->
|
||||
<item-tree-menu-bar/>
|
||||
|
||||
<wizard id="citation-explorer-wizard" class="citation-explorer-wizard zotero-dialog-window">
|
||||
<wizardpage pageid="choose-operation" data-header-label-id="integration-citationExplorerWizard-choose-header">
|
||||
<html:p class="page-description" data-l10n-id="integration-citationExplorerWizard-choose-description"/>
|
||||
<radiogroup id="item-operation" align="start">
|
||||
<radio id="item-operation-relink" value="relink" data-l10n-id="integration-citationExplorerWizard-operation-relink"/>
|
||||
<html:p id="item-operation-relink-description" class="option-description" data-l10n-id="integration-citationExplorerWizard-operation-relink-description"/>
|
||||
<radio value="addToTarget" data-l10n-id="integration-citationExplorerWizard-operation-add-to-target"/>
|
||||
<html:p class="option-description" data-l10n-id="integration-citationExplorerWizard-operation-add-to-target-description"/>
|
||||
</radiogroup>
|
||||
|
||||
<vbox class="target-picker">
|
||||
<label control="item-target" data-l10n-id="integration-citationExplorerWizard-target"/>
|
||||
<menulist id="item-target" native="true">
|
||||
<menupopup id="item-target-menu"/>
|
||||
</menulist>
|
||||
</vbox>
|
||||
</wizardpage>
|
||||
|
||||
<wizardpage class="review-page" pageid="review-actions" data-header-label-id="integration-citationExplorerWizard-review-header">
|
||||
<html:p class="page-description" data-l10n-id="integration-citationExplorerWizard-review-description"/>
|
||||
<html:div class="virtualized-table-container flex">
|
||||
<html:div id="zotero-items-tree" class="citation-explorer-item-tree"/>
|
||||
</html:div>
|
||||
</wizardpage>
|
||||
</wizard>
|
||||
</window>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -16,3 +16,7 @@
|
|||
.flex {
|
||||
flex: 1
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
scss/components/_citationExplorerWizard.scss
Normal file
86
scss/components/_citationExplorerWizard.scss
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#citation-explorer-wizard-window {
|
||||
display: flex;
|
||||
min-height: 500px;
|
||||
|
||||
.citation-explorer-wizard {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
wizardpage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
display: block;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
#item-operation {
|
||||
margin: 0;
|
||||
|
||||
radio {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
display: block;
|
||||
margin: 2px 0 14px 24px;
|
||||
color: var(--fill-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.target-picker {
|
||||
margin-top: 12px;
|
||||
max-width: 420px;
|
||||
|
||||
label {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
#item-target {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cell.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-block: 1px;
|
||||
|
||||
.item-action-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
|
||||
&.skip-selected::part(icon) {
|
||||
color: var(--accent-red);
|
||||
fill: var(--accent-red) !important;
|
||||
-moz-context-properties: fill, color;
|
||||
}
|
||||
}
|
||||
|
||||
.choose-item-button {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-action > .menu-icon {
|
||||
color: var(--accent-red);
|
||||
fill: var(--accent-red) !important;
|
||||
-moz-context-properties: fill, color;
|
||||
}
|
||||
|
||||
.row.selected .item-action-select {
|
||||
color: inherit;
|
||||
|
||||
&::part(icon) {
|
||||
color: inherit;
|
||||
fill: currentColor !important;
|
||||
-moz-context-properties: fill, color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ $-icons: (
|
|||
chevron-6: 8,
|
||||
filter: 16,
|
||||
note: 16,
|
||||
link: 16,
|
||||
x-8: 16,
|
||||
play: 16,
|
||||
pause: 16,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
search-textbox:not([no-native]) {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
textarea:not([no-native]) {
|
||||
height: unset;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,4 +44,5 @@
|
|||
|
||||
html|*.wizard-page-box:not(.specificity) {
|
||||
padding: 16px 0;
|
||||
}
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,5 @@
|
|||
@import "mac/components/input";
|
||||
@import "mac/components/menupopup";
|
||||
@import "mac/components/menulist";
|
||||
@import "mac/citationExplorer";
|
||||
|
||||
// Elements
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
@import "win/createParent";
|
||||
@import "win/tabBar";
|
||||
@import "win/titleBar";
|
||||
@import "win/citationExplorer";
|
||||
|
||||
// Elements
|
||||
|
||||
|
|
|
|||
367
test/tests/citationExplorerWizardTest.js
Normal file
367
test/tests/citationExplorerWizardTest.js
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
"use strict";
|
||||
|
||||
describe('Citation Explorer Wizard', function () {
|
||||
var dialog;
|
||||
var group;
|
||||
var targetCollection;
|
||||
var io;
|
||||
var savedItems;
|
||||
|
||||
async function openCitationExplorerWizard(items, uncitedItems, citations) {
|
||||
io = {
|
||||
items,
|
||||
uncitedItems,
|
||||
citations,
|
||||
relinkItems: sinon.spy(),
|
||||
completed: false,
|
||||
deferred: Zotero.Promise.defer(),
|
||||
};
|
||||
io.wrappedJSObject = io;
|
||||
dialog = await loadWindow(
|
||||
'chrome://zotero/content/integration/citationExplorerWizard.xhtml',
|
||||
io
|
||||
);
|
||||
await waitForCallback(() => {
|
||||
return dialog.document.getElementById('item-target').getAttribute('label');
|
||||
});
|
||||
return dialog.document.getElementById('citation-explorer-wizard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the review page for cited items with the specified destination and operation,
|
||||
* and wait for either an action select or the no-changes message.
|
||||
*/
|
||||
async function openReview({ items, target, operation, noChanges = false }) {
|
||||
let citations = {};
|
||||
for (let [index, item] of items.entries()) {
|
||||
citations[index] = {
|
||||
citationItems: [{ id: item.cslItemID ?? item.id }],
|
||||
};
|
||||
}
|
||||
let wizard = await openCitationExplorerWizard(items, [], citations);
|
||||
dialog.document.getElementById('item-operation').value = operation;
|
||||
let targetMenuitem = dialog.document.querySelector(
|
||||
`#item-target-menu menuitem[value="${target.treeViewID}"]`
|
||||
);
|
||||
if (!targetMenuitem) {
|
||||
throw new Error(`Target ${target.treeViewID} not found`);
|
||||
}
|
||||
targetMenuitem.doCommand();
|
||||
wizard.getButton('next').click();
|
||||
await waitForCallback(() => {
|
||||
if (wizard.currentPage?.pageid !== 'review-actions') return false;
|
||||
if (noChanges) {
|
||||
return dialog.document.querySelector('.items-tree-message')?.textContent
|
||||
.includes('No changes are needed.');
|
||||
}
|
||||
let options = dialog.document.querySelectorAll('.item-action-select menuitem');
|
||||
return options.length && [...options].every(option => option.getAttribute('label'));
|
||||
});
|
||||
return wizard;
|
||||
}
|
||||
|
||||
function getActionState() {
|
||||
let select = dialog.document.querySelector('.item-action-select');
|
||||
let options = [...select.menupopup.children].map(option => option.getAttribute('label'));
|
||||
return {
|
||||
selected: options[Number(select.value)],
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function createUnlinkedItem(itemData, cslItemID) {
|
||||
let item = new Zotero.Item(itemData.itemType);
|
||||
item.setField('title', itemData.title);
|
||||
item.setCreators(itemData.creators || []);
|
||||
item.cslItemID = cslItemID;
|
||||
return item;
|
||||
}
|
||||
|
||||
before(async function () {
|
||||
group = await createGroup({ name: 'Wizard Target Group' });
|
||||
targetCollection = await createDataObject('collection', {
|
||||
libraryID: group.libraryID,
|
||||
name: 'Wizard Target Collection',
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
dialog = null;
|
||||
io = null;
|
||||
savedItems = [];
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
if (dialog && !dialog.closed) {
|
||||
dialog.close();
|
||||
await io.deferred.promise;
|
||||
}
|
||||
for (let item of savedItems) {
|
||||
if (Zotero.Items.get(item.id)) {
|
||||
await item.eraseTx();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await group.eraseTx();
|
||||
});
|
||||
|
||||
describe('Destination selection', function () {
|
||||
it('should default to the library with the most citation occurrences', async function () {
|
||||
let groupItem = await createDataObject('item', {
|
||||
libraryID: group.libraryID,
|
||||
title: 'Repeated Group Citation',
|
||||
});
|
||||
let userItem = await createDataObject('item', { title: 'Repeated User Citation' });
|
||||
let uncitedGroupItems = await Promise.all([1, 2].map(index => createDataObject('item', {
|
||||
libraryID: group.libraryID,
|
||||
title: `Uncited Group Item ${index}`,
|
||||
})));
|
||||
savedItems.push(groupItem, userItem, ...uncitedGroupItems);
|
||||
|
||||
// Uncited linked items each add one to the destination score.
|
||||
await openCitationExplorerWizard(
|
||||
[groupItem, userItem],
|
||||
uncitedGroupItems,
|
||||
{
|
||||
0: { citationItems: [{ id: groupItem.id }] },
|
||||
1: { citationItems: [{ id: groupItem.id }] },
|
||||
2: { citationItems: [{ id: userItem.id }] },
|
||||
3: { citationItems: [{ id: userItem.id }] },
|
||||
4: { citationItems: [{ id: userItem.id }] },
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
dialog.document.getElementById('item-target').getAttribute('label'),
|
||||
group.name,
|
||||
'Uncited items should make the group library the highest-scoring destination'
|
||||
);
|
||||
assert.isTrue(
|
||||
dialog.document.getElementById('item-operation-relink').hidden,
|
||||
'The relink option should be hidden when all items are linked'
|
||||
);
|
||||
assert.isTrue(
|
||||
dialog.document.getElementById('item-operation-relink-description').hidden,
|
||||
'The relink description should be hidden with its option'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Review actions', function () {
|
||||
describe('Unlinked items', function () {
|
||||
it('should offer and select a destination-library match', async function () {
|
||||
let itemData = {
|
||||
itemType: 'book',
|
||||
title: `Target Match ${Zotero.Utilities.randomString()}`,
|
||||
};
|
||||
let match = await createDataObject('item', itemData);
|
||||
savedItems.push(match);
|
||||
let item = createUnlinkedItem(itemData, 'session/target-match');
|
||||
|
||||
await openReview({
|
||||
items: [item],
|
||||
target: Zotero.Libraries.userLibrary,
|
||||
operation: 'relink',
|
||||
});
|
||||
|
||||
assert.deepEqual(getActionState(), {
|
||||
selected: `Relink to existing item “${match.getDisplayTitle()}”`,
|
||||
options: [
|
||||
`Relink to existing item “${match.getDisplayTitle()}”`,
|
||||
'Choose Item…',
|
||||
'Do nothing',
|
||||
],
|
||||
}, 'A destination match should be selected for relinking');
|
||||
});
|
||||
|
||||
it('should offer and select a non-destination-library match for copying', async function () {
|
||||
let itemData = {
|
||||
itemType: 'book',
|
||||
title: `Other Library Match ${Zotero.Utilities.randomString()}`,
|
||||
};
|
||||
let match = await createDataObject('item', {
|
||||
...itemData,
|
||||
libraryID: group.libraryID,
|
||||
});
|
||||
savedItems.push(match);
|
||||
let item = createUnlinkedItem(itemData, 'session/other-library-match');
|
||||
|
||||
await openReview({
|
||||
items: [item],
|
||||
target: Zotero.Libraries.userLibrary,
|
||||
operation: 'relink',
|
||||
});
|
||||
|
||||
assert.deepEqual(getActionState(), {
|
||||
selected: `Copy an existing item from ${group.name}`,
|
||||
options: [
|
||||
`Copy an existing item from ${group.name}`,
|
||||
`Add to ${Zotero.Libraries.userLibrary.name}`,
|
||||
'Choose Item…',
|
||||
'Do nothing',
|
||||
],
|
||||
}, 'A non-destination match should be selected for copying and linking');
|
||||
});
|
||||
|
||||
it('should select adding the item from the document', async function () {
|
||||
let item = createUnlinkedItem({
|
||||
itemType: 'book',
|
||||
title: `Unmatched Document Item ${Zotero.Utilities.randomString()}`,
|
||||
}, 'session/unmatched');
|
||||
|
||||
await openReview({
|
||||
items: [item],
|
||||
target: Zotero.Libraries.userLibrary,
|
||||
operation: 'relink',
|
||||
});
|
||||
|
||||
assert.deepEqual(getActionState(), {
|
||||
selected: `Add to ${Zotero.Libraries.userLibrary.name}`,
|
||||
options: [
|
||||
`Add to ${Zotero.Libraries.userLibrary.name}`,
|
||||
'Choose Item…',
|
||||
'Do nothing',
|
||||
],
|
||||
}, 'An unmatched unlinked item should default to its document data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Linked items', function () {
|
||||
it('should omit an item already in the destination library', async function () {
|
||||
let destinationItem = await createDataObject('item', {
|
||||
title: `Already in Destination ${Zotero.Utilities.randomString()}`,
|
||||
});
|
||||
let externalItem = await createDataObject('item', {
|
||||
libraryID: group.libraryID,
|
||||
title: `External Item ${Zotero.Utilities.randomString()}`,
|
||||
});
|
||||
savedItems.push(destinationItem, externalItem);
|
||||
|
||||
await openReview({
|
||||
items: [destinationItem, externalItem],
|
||||
target: Zotero.Libraries.userLibrary,
|
||||
operation: 'addToTarget',
|
||||
});
|
||||
|
||||
assert.lengthOf(
|
||||
dialog.document.querySelectorAll('.item-action-select'),
|
||||
1,
|
||||
'Only the item outside the destination library should have actions'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show no changes when all items are already in the destination library', async function () {
|
||||
let items = await Promise.all([1, 2].map(index => createDataObject('item', {
|
||||
title: `Already in Destination ${index} ${Zotero.Utilities.randomString()}`,
|
||||
})));
|
||||
savedItems.push(...items);
|
||||
|
||||
await openReview({
|
||||
items,
|
||||
target: Zotero.Libraries.userLibrary,
|
||||
operation: 'addToTarget',
|
||||
noChanges: true,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
dialog.document.querySelector('.items-tree-message').textContent,
|
||||
'No changes are needed.',
|
||||
'Items already in the destination should produce the no-changes state'
|
||||
);
|
||||
});
|
||||
|
||||
it('should offer and select copying an item from another library', async function () {
|
||||
let item = await createDataObject('item', {
|
||||
libraryID: group.libraryID,
|
||||
title: `Linked Copy ${Zotero.Utilities.randomString()}`,
|
||||
});
|
||||
savedItems.push(item);
|
||||
|
||||
await openReview({
|
||||
items: [item],
|
||||
target: Zotero.Libraries.userLibrary,
|
||||
operation: 'addToTarget',
|
||||
});
|
||||
|
||||
assert.deepEqual(getActionState(), {
|
||||
selected: `Copy an existing item from ${group.name}`,
|
||||
options: [
|
||||
`Copy an existing item from ${group.name}`,
|
||||
'Choose Item…',
|
||||
'Do nothing',
|
||||
],
|
||||
}, 'An external linked item should be selected for copying');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collection destinations', function () {
|
||||
it('should offer and select adding a library item to the collection', async function () {
|
||||
let item = await createDataObject('item', {
|
||||
libraryID: group.libraryID,
|
||||
title: `Add to Collection ${Zotero.Utilities.randomString()}`,
|
||||
});
|
||||
savedItems.push(item);
|
||||
|
||||
await openReview({
|
||||
items: [item],
|
||||
target: targetCollection,
|
||||
operation: 'addToTarget',
|
||||
});
|
||||
|
||||
assert.deepEqual(getActionState(), {
|
||||
selected: `Add to ${targetCollection.name}`,
|
||||
options: [
|
||||
`Add to ${targetCollection.name}`,
|
||||
'Choose Item…',
|
||||
'Do nothing',
|
||||
],
|
||||
}, 'An item in the target library should be selected for collection addition');
|
||||
});
|
||||
|
||||
it('should offer and select copying an item from another library', async function () {
|
||||
let item = await createDataObject('item', {
|
||||
title: `Copy to Collection ${Zotero.Utilities.randomString()}`,
|
||||
});
|
||||
savedItems.push(item);
|
||||
|
||||
await openReview({
|
||||
items: [item],
|
||||
target: targetCollection,
|
||||
operation: 'addToTarget',
|
||||
});
|
||||
|
||||
assert.deepEqual(getActionState(), {
|
||||
selected: `Copy an existing item from ${Zotero.Libraries.userLibrary.name}`,
|
||||
options: [
|
||||
`Copy an existing item from ${Zotero.Libraries.userLibrary.name}`,
|
||||
'Choose Item…',
|
||||
'Do nothing',
|
||||
],
|
||||
}, 'An external item should be selected for copying to the collection library');
|
||||
});
|
||||
|
||||
it('should omit an item already in the collection', async function () {
|
||||
let item = await createDataObject('item', {
|
||||
libraryID: group.libraryID,
|
||||
collections: [targetCollection.id],
|
||||
title: `Already in Collection ${Zotero.Utilities.randomString()}`,
|
||||
});
|
||||
savedItems.push(item);
|
||||
|
||||
await openReview({
|
||||
items: [item],
|
||||
target: targetCollection,
|
||||
operation: 'addToTarget',
|
||||
noChanges: true,
|
||||
});
|
||||
|
||||
assert.notExists(
|
||||
dialog.document.querySelector('.item-action-select'),
|
||||
'An item already in the target collection should not have actions'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
152
test/tests/selectItemsDialogTest.js
Normal file
152
test/tests/selectItemsDialogTest.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue