diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index 58067f2449..0708de1cf2 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -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; diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 33d21474d3..d37b7a7be0 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -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() { diff --git a/chrome/content/zotero/selectItemsDialog.js b/chrome/content/zotero/selectItemsDialog.js index ef93302d0d..9517c1a370 100644 --- a/chrome/content/zotero/selectItemsDialog.js +++ b/chrome/content/zotero/selectItemsDialog.js @@ -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() diff --git a/chrome/content/zotero/selectItemsDialog.xhtml b/chrome/content/zotero/selectItemsDialog.xhtml index 05c61c937c..ffd46dca44 100644 --- a/chrome/content/zotero/selectItemsDialog.xhtml +++ b/chrome/content/zotero/selectItemsDialog.xhtml @@ -46,7 +46,7 @@ diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl index b91303ff49..c43c927ee4 100644 --- a/chrome/locale/en-US/zotero/zotero.ftl +++ b/chrome/locale/en-US/zotero/zotero.ftl @@ -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 = diff --git a/scss/components/_item-tree.scss b/scss/components/_item-tree.scss index b6e5b28601..368eed215c 100644 --- a/scss/components/_item-tree.scss +++ b/scss/components/_item-tree.scss @@ -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"); } diff --git a/test/tests/selectItemsDialogTest.js b/test/tests/selectItemsDialogTest.js new file mode 100644 index 0000000000..a2c4a19157 --- /dev/null +++ b/test/tests/selectItemsDialogTest.js @@ -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); + }); + }); +});