Citation Explorer: show suggested items when relinking an unlinked item

This commit is contained in:
Adomas Venčkauskas 2026-08-10 16:06:55 +03:00
parent 06eb70abda
commit af5ab8427b
7 changed files with 336 additions and 20 deletions

View file

@ -47,6 +47,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
static defaultProps = {
customRows: [],
dragAndDrop: false,
filterLibraryIDs: false,
hideSources: [],
@ -57,6 +58,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,
@ -598,6 +600,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
@ -1499,6 +1521,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;

View file

@ -729,7 +729,18 @@ window.ZoteroCitationExplorer = {
let treeRow = itemList.getRow(itemList.selection.focused);
let oldItemID = treeRow.id;
let io = { dataIn: null, dataOut: null, multiSelect: false, deferred: Zotero.Promise.defer() };
let libraryIDs = Zotero.Libraries.getAll()
.filter(library => library.libraryType != 'feed')
.map(library => library.libraryID);
let itemIDs = await new Zotero.Duplicates(libraryIDs).findDuplicatesOf(treeRow.ref);
let io = {
dataIn: null,
dataOut: null,
itemIDs: itemIDs.length ? itemIDs : undefined,
multiSelect: false,
onlyRegularItems: true,
deferred: Zotero.Promise.defer()
};
window.openDialog('chrome://zotero/content/selectItemsDialog.xhtml', '',
'chrome,dialog=no,centerscreen,resizable=yes', io);
@ -748,6 +759,7 @@ window.ZoteroCitationExplorer = {
await this._initMappings();
await this.refreshCitationList();
await this.refreshItemList();
await itemList.selectItem(items[0].id);
},
async addToLibraryAndLink() {

View file

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

View file

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

View file

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

View file

@ -406,10 +406,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 +477,7 @@ $-coloredIcons: (
}
}
@each $icon in $-trashableObjectIcons {
@each $icon in $-nonItemIcons {
.icon-css.icon-#{$icon} {
@include svgicon($icon, $color, "16", "collection-tree");
}

View file

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