From e45659f62c92661f71539d29a8db47ab70469cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Thu, 12 Jun 2025 10:51:37 +0300 Subject: [PATCH 01/39] Add a hidden pref to enable citation explorer on google docs --- chrome/content/zotero/xpcom/server/server_connector.js | 2 +- defaults/preferences/zotero.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/chrome/content/zotero/xpcom/server/server_connector.js b/chrome/content/zotero/xpcom/server/server_connector.js index 301be412a3..a3e7038168 100644 --- a/chrome/content/zotero/xpcom/server/server_connector.js +++ b/chrome/content/zotero/xpcom/server/server_connector.js @@ -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 } diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index c51de7cda2..24ff31f76f 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -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); From 623218028ca224fd82c4517112fd44b771f6b65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Thu, 12 Jun 2025 14:29:13 +0300 Subject: [PATCH 02/39] fx128: Remove Service.jsm load in Citation Explorer --- chrome/content/zotero/integration/citationExplorer.xhtml | 2 -- 1 file changed, 2 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index 62ddb9c227..e7e03fd568 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -45,8 +45,6 @@ buttons="accept" buttonpack="end"> -
-
-
-
Citations
-
- -
-
-
-
- - -
-
-
-
-
- - - - - - - - -
- -
-
-
-
- - -
-
-
+
+ +
Citations
+ +
+ + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/scss/abstracts/_layout.scss b/scss/abstracts/_layout.scss index 6b660fa239..8ac12fb9de 100644 --- a/scss/abstracts/_layout.scss +++ b/scss/abstracts/_layout.scss @@ -16,3 +16,7 @@ .flex { flex: 1 } + +.spacer { + flex: 1; +} \ No newline at end of file diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index 7615565799..5427e6d450 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -1,29 +1,72 @@ #zotero-citation-explorer-dialog { min-height: 500px; - + dialog { max-height: 100vh; + padding: 0 0 .3em; + background: var(--material-sidepane); + } + + search-textbox, input, button { + margin: 0; } - .virtualized-table-container { + .citation-explorer-grid { + display: grid; + grid-template-columns: 4fr 6fr; + grid-template-rows: auto auto minmax(0, 1fr) auto; height: 100%; - flex: 1; - } - - .xul-border-div { - -moz-box-flex: 1; + + .citation-explorer-grid-left { + border-right: solid 1px ThreeDShadow; + } + + .grid-padded-section { + padding: 8px 16px; + } } #citations-label { - margin: 5px 0 4px; + align-content: center; } - .panel { - border: solid 1px ThreeDShadow; - padding: 5px; + .tabbox-container { + display: flex; } - .search-box { - margin: 2px -1px; + .searchbox-container { + border-bottom: 1px solid ThreeDShadow; + } + + .virtualized-table-container { + height: 100%; + } + + .virtualized-table:focus-within .row.highlighted:not(.selected) { + background: initial !important; + } + + .button-container { + border-block: 1px solid ThreeDShadow; + display: flex; + } + + @media (-moz-platform: macos) { + tab[visuallyselected="true"]:not(:-moz-window-inactive) { + color: initial !important; + } + #citations-label { + font-size: 1.2em; + } + } + + @media (-moz-platform: windows) { + tab:focus-visible > .tab-middle { + outline: none; + } + + input[type="button"], button { + margin-top: 7px; + } } } diff --git a/scss/components/_icons.scss b/scss/components/_icons.scss index 69e7369af2..9da7a0d4c4 100644 --- a/scss/components/_icons.scss +++ b/scss/components/_icons.scss @@ -41,6 +41,7 @@ $-icons: ( chevron-6: 8, filter: 16, note: 16, + link: 16, x-8: 16, play: 16, pause: 16, diff --git a/scss/mac/_citationExplorer.scss b/scss/mac/_citationExplorer.scss deleted file mode 100644 index 5b499344ce..0000000000 --- a/scss/mac/_citationExplorer.scss +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/scss/win/_citationExplorer.scss b/scss/win/_citationExplorer.scss deleted file mode 100644 index d3ed61a654..0000000000 --- a/scss/win/_citationExplorer.scss +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/scss/zotero-mac.scss b/scss/zotero-mac.scss index 48f9235271..5b5e3b4719 100644 --- a/scss/zotero-mac.scss +++ b/scss/zotero-mac.scss @@ -13,6 +13,5 @@ @import "mac/components/input"; @import "mac/components/menupopup"; @import "mac/components/menulist"; -@import "mac/citationExplorer"; // Elements diff --git a/scss/zotero-win.scss b/scss/zotero-win.scss index 17448c86bf..43b62ff20e 100644 --- a/scss/zotero-win.scss +++ b/scss/zotero-win.scss @@ -9,7 +9,6 @@ @import "win/createParent"; @import "win/tabBar"; @import "win/titleBar"; -@import "win/citationExplorer"; // Elements From 2c18b3973a36fb62d133f45622eefb09dc9bc712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Mon, 22 Sep 2025 15:28:19 +0300 Subject: [PATCH 09/39] Citation explorer: macOS styling - Refactor alternating row colors from item tree into virtualized table - Adjust the way twisty and spacer are aligned for regularOnly item trees --- chrome/content/zotero/collectionTree.jsx | 1 - chrome/content/zotero/itemTree.jsx | 3 ++ scss/components/_citationExplorer.scss | 55 +++++++++++++++++++----- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index 21de5676ad..58067f2449 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -487,7 +487,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, diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index 748372ace1..c212adf248 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -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; @@ -1446,8 +1447,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, diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index 5427e6d450..6912665eb2 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -3,7 +3,6 @@ dialog { max-height: 100vh; - padding: 0 0 .3em; background: var(--material-sidepane); } @@ -16,6 +15,7 @@ grid-template-columns: 4fr 6fr; grid-template-rows: auto auto minmax(0, 1fr) auto; height: 100%; + margin: -16px; .citation-explorer-grid-left { border-right: solid 1px ThreeDShadow; @@ -34,12 +34,19 @@ display: flex; } - .searchbox-container { - border-bottom: 1px solid ThreeDShadow; - } - .virtualized-table-container { height: 100%; + border-block: 1px solid ThreeDShadow; + } + + .virtualized-table .row { + &.odd:not(.selected) { + background-color: var(--material-stripe); + } + + &.even:not(.selected) { + background-color: var(--material-background); + } } .virtualized-table:focus-within .row.highlighted:not(.selected) { @@ -47,16 +54,44 @@ } .button-container { - border-block: 1px solid ThreeDShadow; + border-bottom: 1px solid ThreeDShadow; display: flex; } @media (-moz-platform: macos) { - tab[visuallyselected="true"]:not(:-moz-window-inactive) { - color: initial !important; + .tabbox-container, #citations-label { + padding-top: 16px !important; } - #citations-label { - font-size: 1.2em; + + .button-container { + padding-bottom: 0px; + margin-bottom: -16px; + } + + tabbox { + flex: 1; + flex-direction: row; + + tabs { + flex: 1; + margin-bottom: 0; + + tab { + flex: 1; + + &[visuallyselected="true"]:not(:-moz-window-inactive) { + color: initial !important; + } + label.tab-text { + flex-direction: column; + align-items: center; + } + } + + spacer.tabs-right { + flex: 0; + } + } } } From e05696acb29d8c29c3a0bee441f66c8bba58a017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Tue, 23 Sep 2025 13:47:47 +0300 Subject: [PATCH 10/39] Citation explorer: Windows styling --- .../zotero/integration/citationExplorer.xhtml | 2 +- scss/components/_citationExplorer.scss | 22 +++++++++++-------- scss/elements/_splitMenuButton.scss | 4 ++++ scss/win/components/_input.scss | 4 ++++ 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index bc011b4ede..19c51850b7 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -25,11 +25,11 @@ ***** END LICENSE BLOCK ***** --> + - .tab-middle { - outline: none; - } - - input[type="button"], button { - margin-top: 7px; + tabbox tabs { + border-bottom: none; + tab { + margin-bottom: 0; + } } } } diff --git a/scss/elements/_splitMenuButton.scss b/scss/elements/_splitMenuButton.scss index 60967a1414..204604a37e 100644 --- a/scss/elements/_splitMenuButton.scss +++ b/scss/elements/_splitMenuButton.scss @@ -48,4 +48,8 @@ fill: currentColor; -moz-context-properties: fill,fill-opacity; } + + @media (-moz-platform: windows) { + padding-right: 6px; + } } diff --git a/scss/win/components/_input.scss b/scss/win/components/_input.scss index 5646034100..1c0e6e7fca 100644 --- a/scss/win/components/_input.scss +++ b/scss/win/components/_input.scss @@ -127,6 +127,10 @@ } } +search-textbox:not([no-native]) { + padding-inline-start: 0; +} + textarea:not([no-native]) { height: unset; } From 1ab1d34a88ea73bfddb6d5e8a95ab7c6c3c0d169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 24 Sep 2025 11:14:28 +0300 Subject: [PATCH 11/39] Fix switching to different citation explorer view not resetting selection --- .../content/zotero/integration/citationExplorer.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 83c2007987..14ae0dd62b 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -166,10 +166,8 @@ window.ZoteroDocumentCitations = { onSelectionChange={this.onCitationSelectionChange.bind(this)} getRowString={index => this._renderedCitationRows[index].title} />); - domElem.addEventListener("focusout", (event) => { - if (event.relatedTarget && !event.relatedTarget.closest("#citation-list-container")) { - citationList?.selection.clearSelection() - } + domElem.addEventListener("focusin", (event) => { + itemList?.selection.clearSelection() }); }); } @@ -196,10 +194,8 @@ window.ZoteroDocumentCitations = { firstColumnExtraWidth: 28-16, }); await itemList.waitForLoad(); - domElem.addEventListener("focusout", (event) => { - if (event.relatedTarget && !event.relatedTarget.closest("#item-list-container")) { - itemList?.selection.clearSelection() - } + domElem.addEventListener("focusin", (event) => { + citationList?.selection.clearSelection() }); } await itemList.changeCollectionTreeRow({ From d300dd497bc5ddb0558f65a0dc2f1f6d9aad5f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 24 Sep 2025 11:34:46 +0300 Subject: [PATCH 12/39] Citation dialog: Fix tab order --- .../zotero/integration/citationExplorer.xhtml | 30 ++++++++++--------- scss/components/_citationExplorer.scss | 1 + 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index 19c51850b7..121534ba9b 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -54,8 +54,24 @@
+
Citations
+
+ +
+ +
+
+
+ +
+ +
+ +
+ +
@@ -67,28 +83,14 @@
-
- -
-
-
-
-
-
-
- -
- -
-
diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index a196f24298..6ef45ea165 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -15,6 +15,7 @@ display: grid; grid-template-columns: 4fr 6fr; grid-template-rows: auto auto minmax(0, 1fr) auto; + grid-auto-flow: column; height: 100%; margin: -8px; From 3575dabd505e58d2efb627c2ab13e5a2c6f6d7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 24 Sep 2025 11:35:08 +0300 Subject: [PATCH 13/39] Fix splitMenuButton appearance on Linux --- scss/elements/_splitMenuButton.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scss/elements/_splitMenuButton.scss b/scss/elements/_splitMenuButton.scss index 204604a37e..12f97cfe9d 100644 --- a/scss/elements/_splitMenuButton.scss +++ b/scss/elements/_splitMenuButton.scss @@ -1,7 +1,7 @@ .split-menu-button { display: flex; align-items: center; - padding-right: 0; + padding-right: 6px; gap: 6px; flex-grow: 1; @@ -49,7 +49,7 @@ -moz-context-properties: fill,fill-opacity; } - @media (-moz-platform: windows) { - padding-right: 6px; + @media (-moz-platform: mac) { + padding-right: 0; } } From b7c5caa8ee56c7665795c43c8523905a5569c5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 24 Sep 2025 11:35:24 +0300 Subject: [PATCH 14/39] Citation Dialog: log failures to select citation in document --- chrome/content/zotero/integration/citationExplorer.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 14ae0dd62b..6701c1caf2 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -321,7 +321,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. From 2fb4c61064b6f02a65a4c28ce39bbbd92d97daa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 24 Sep 2025 12:32:34 +0300 Subject: [PATCH 15/39] Citation Explorer: Fix item tree menu view --- .../zotero/integration/citationExplorer.js | 5 ++++ .../zotero/integration/citationExplorer.xhtml | 12 ++++++++ chrome/content/zotero/itemTree.jsx | 28 ++++++++++--------- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 6701c1caf2..2be769f398 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -166,6 +166,8 @@ 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() }); @@ -194,9 +196,12 @@ window.ZoteroDocumentCitations = { firstColumnExtraWidth: 28-16, }); await itemList.waitForLoad(); + // Remove focus from citationList if focus is on itemList + // to prevent highlighting in both lists domElem.addEventListener("focusin", (event) => { citationList?.selection.clearSelection() }); + document.querySelector("item-tree-menu-bar").init(itemList); } await itemList.changeCollectionTreeRow({ getItems: async () => filteredItems, diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index 121534ba9b..be249d60a3 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -42,9 +42,20 @@ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" persist="screenX screenY width height" resizable="true"> + + + + + + + + + + + +
diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index c212adf248..19f305bfe7 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -2039,20 +2039,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); From 8c91198eb4eff49e365d0d2380ac3b644df5f794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 24 Sep 2025 12:50:44 +0300 Subject: [PATCH 16/39] Citation Explorer: Change Date column out for Year --- chrome/content/zotero/integration/citationExplorer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 2be769f398..7aef089118 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -69,7 +69,7 @@ const citationColumns = [ }, ]; -let itemColumns = getColumnDefinitionsByDataKey(['title', 'firstCreator', 'date']); +let itemColumns = getColumnDefinitionsByDataKey(['title', 'firstCreator', 'year']); itemColumns.push({ dataKey: 'isLinked', label: 'Is Linked', From 41e0c15f045338368bdcd3161c30725c41b2b635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 24 Sep 2025 12:51:03 +0300 Subject: [PATCH 17/39] Citation Explorer: Various styling adjustments --- .../zotero/integration/citationExplorer.js | 10 +++++----- .../zotero/integration/citationExplorer.xhtml | 2 +- scss/components/_citationExplorer.scss | 19 ++++++++++++++++--- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 7aef089118..2367f510aa 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -151,7 +151,7 @@ window.ZoteroDocumentCitations = { await new Promise((resolve) => { const domElem = document.querySelector('#citation-list-container'); ReactDOM.createRoot(domElem).render( { citationList = ref; resolve(); @@ -184,9 +184,9 @@ window.ZoteroDocumentCitations = { let filteredItems = rows.filter(item => !this._filteredItems.has(item.id)); if (!itemList) { - let domElem = document.querySelector('#item-list-container'); + let domElem = document.querySelector('#zotero-items-tree'); itemList = await ItemTree.init(domElem, { - id: "document-collections", + id: "citation-explorer-items", regularOnly: true, columns: itemColumns, shouldListenForNotifications: false, @@ -296,8 +296,8 @@ window.ZoteroDocumentCitations = { let d = diff(citation.properties.plainCitation, title); title = d.map(([type, text]) => { if (type == 0) return text; - if (type == -1) return `${text}`; - if (type == 1) return `${text}`; + if (type == -1) return `${text}`; + if (type == 1) return `${text}`; }).join(''); } return { diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index be249d60a3..d49f54810c 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -100,7 +100,7 @@
-
+
diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index 6ef45ea165..2b2bb89655 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -1,5 +1,6 @@ #zotero-citation-explorer-dialog { min-height: 500px; + --split-button-icon-color: var(--accent-blue); dialog { max-height: 100vh; @@ -10,6 +11,9 @@ margin: 0; } + input + button { + margin-left: 8px; + } .citation-explorer-grid { display: grid; @@ -20,7 +24,7 @@ margin: -8px; .citation-explorer-grid-left { - border-right: solid 1px ThreeDShadow; + border-right: var(--material-border-quarternary); } .grid-padded-section { @@ -42,7 +46,7 @@ .virtualized-table-container { height: 100%; - border-block: 1px solid ThreeDShadow; + border-block: var(--material-border-quarternary); } .virtualized-table .row { @@ -60,9 +64,18 @@ } .button-container { - border-bottom: 1px solid ThreeDShadow; + border-bottom: var(--material-border-quarternary); display: flex; } + + .diff-deleted { + color: var(--accent-red); + text-decoration: line-through; + } + + .diff-added { + color: green; + } @media (-moz-platform: macos) { .citation-explorer-grid { From a0735fdb2c67dc8fefab28a923d2d4c0997cf9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Thu, 25 Sep 2025 10:49:42 +0300 Subject: [PATCH 18/39] Citation Explorer: Fix highlighting inconsistencies --- .../zotero/integration/citationExplorer.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 2367f510aa..2e564a54b1 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -250,9 +250,10 @@ window.ZoteroDocumentCitations = { }, _initMappings: async function () { + const itemMap = {}; itemRows = items.map((item) => { let citedIn = []; - return new Proxy(item, { + let proxyItem = new Proxy(item, { get(target, prop) { if (prop == 'id' && !target.id) { return target.cslItemID; @@ -263,6 +264,8 @@ window.ZoteroDocumentCitations = { return Reflect.get(...arguments); } }); + itemMap[item.id || item.cslItemID] = proxyItem; + return proxyItem; }); uncitedItemRows = uncitedItems.map((item) => { return new Proxy(item, { @@ -280,16 +283,8 @@ window.ZoteroDocumentCitations = { 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; - } + itemMap[citationItem.id].citedIn.push(citationIndex); + isLinked = typeof citationItem.id == 'number'; } let title = await citation.field.getText(); if (citation.properties.plainCitation != title) { From 87f9173c2dd39f879107b1ff7b855cc46bbb3afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Mon, 29 Sep 2025 10:30:36 +0300 Subject: [PATCH 19/39] Citation Explorer: Uncited item display change --- .../zotero/integration/citationExplorer.js | 28 ++++----- .../zotero/integration/citationExplorer.xhtml | 11 +--- scss/components/_citationExplorer.scss | 61 ++++++------------- 3 files changed, 32 insertions(+), 68 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 2e564a54b1..8d33861f0b 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -194,6 +194,7 @@ window.ZoteroDocumentCitations = { onActivate: this.onItemActivate.bind(this), emptyMessage: Zotero.getString('pane.items.loading'), firstColumnExtraWidth: 28-16, + firstColumnPrependRenderer: this._itemFirstColumnPrependRenderer.bind(this), }); await itemList.waitForLoad(); // Remove focus from citationList if focus is on itemList @@ -236,19 +237,6 @@ 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) => { @@ -267,7 +255,7 @@ window.ZoteroDocumentCitations = { itemMap[item.id || item.cslItemID] = proxyItem; return proxyItem; }); - uncitedItemRows = uncitedItems.map((item) => { + itemRows.push(...uncitedItems.map((item) => { return new Proxy(item, { get(target, prop) { if (prop == 'citedIn') { @@ -276,7 +264,7 @@ window.ZoteroDocumentCitations = { return Reflect.get(...arguments); } }); - }); + })); citationRows = await Promise.all(citations .map(async (citation, citationIndex) => { let isLinked = true; @@ -420,6 +408,16 @@ window.ZoteroDocumentCitations = { await this.refreshCitationList(); await this.refreshItemList(); }, + + _itemFirstColumnPrependRenderer: function (index, data, firstColumnPrepend) { + if (itemRows[index].citedIn.length === 0) { + let span = document.createElement('span'); + span.classList.add('badge', 'badge-uncited'); + span.textContent = 'Uncited'; + firstColumnPrepend.push(span); + } + return firstColumnPrepend; + }, async addToLibraryAndLink() { var collectionID = _addToTarget.objectType == 'collection' ? _addToTarget.id : undefined; diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index d49f54810c..5f9d7d8db0 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -84,16 +84,7 @@
-
- - - - - - - - -
+
Items in Bibliography
diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index 2b2bb89655..8bc2f61ae0 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -32,16 +32,11 @@ } } - .tabbox-container, #citations-label { + #citations-label, #items-in-bibliography-label { padding-top: 16px !important; - } - - #citations-label { align-content: center; - } - - .tabbox-container { - display: flex; + font-size: 15px; + font-weight: 600; } .virtualized-table-container { @@ -63,6 +58,21 @@ background: initial !important; } + .virtualized-table .row { + .cell .badge { + font-size: 11px; + padding: 1px 4px; + border-radius: 5px; + display: inline-block; + background: #59ADC44D; + } + + &.selected .cell .badge { + background: rgba(255, 255, 255, 0.2); + color: --var(accent-white); + } + } + .button-container { border-bottom: var(--material-border-quarternary); display: flex; @@ -86,40 +96,5 @@ padding-bottom: 0px; margin-bottom: -16px; } - - tabbox { - flex: 1; - flex-direction: row; - - tabs { - flex: 1; - margin-bottom: 0; - - tab { - flex: 1; - - &[visuallyselected="true"]:not(:-moz-window-inactive) { - color: initial !important; - } - label.tab-text { - flex-direction: column; - align-items: center; - } - } - - spacer.tabs-right { - flex: 0; - } - } - } - } - - @media (-moz-platform: windows) { - tabbox tabs { - border-bottom: none; - tab { - margin-bottom: 0; - } - } } } From 4fd753f82201bd579e25ea99a29db44435d7d7a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Mon, 29 Sep 2025 11:31:55 +0300 Subject: [PATCH 20/39] Citation Explorer: minor styling changes --- scss/components/_citationExplorer.scss | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index 8bc2f61ae0..f741366c6a 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -26,18 +26,18 @@ .citation-explorer-grid-left { border-right: var(--material-border-quarternary); } - - .grid-padded-section { - padding: 8px 16px; - } } #citations-label, #items-in-bibliography-label { - padding-top: 16px !important; + padding: 16px 16px 8px; align-content: center; font-size: 15px; font-weight: 600; } + + .searchbox-container { + padding: 0 16px 8px; + } .virtualized-table-container { height: 100%; @@ -65,6 +65,7 @@ border-radius: 5px; display: inline-block; background: #59ADC44D; + margin-left: 4px; } &.selected .cell .badge { @@ -76,6 +77,7 @@ .button-container { border-bottom: var(--material-border-quarternary); display: flex; + padding: 8px 16px; } .diff-deleted { @@ -89,7 +91,7 @@ @media (-moz-platform: macos) { .citation-explorer-grid { - margin: -16px; + margin: -14px; } .button-container { From 68b9499bc0b801dd8ad902e73bd646ca894c0fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Mon, 29 Sep 2025 16:11:11 +0300 Subject: [PATCH 21/39] Citation Explorer: Disable buttons when no selection --- .../zotero/integration/citationExplorer.js | 18 +++++++++++------- .../zotero/integration/citationExplorer.xhtml | 12 +++++------- chrome/content/zotero/itemTree.jsx | 5 ++++- .../zotero/xpcom/utilities_internal.js | 19 +++++++++++-------- scss/abstracts/_layout.scss | 4 ++-- scss/components/_citationExplorer.scss | 17 +++++++++++++---- scss/elements/_splitMenuButton.scss | 1 + 7 files changed, 47 insertions(+), 29 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 8d33861f0b..0ae8d50ec2 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -190,6 +190,7 @@ window.ZoteroDocumentCitations = { regularOnly: true, columns: itemColumns, shouldListenForNotifications: false, + autoSelect: false, onSelectionChange: this.onItemSelectionChange.bind(this), onActivate: this.onItemActivate.bind(this), emptyMessage: Zotero.getString('pane.items.loading'), @@ -272,7 +273,7 @@ window.ZoteroDocumentCitations = { // check if all citation items are linked for (let citationItem of citation.citationItems) { itemMap[citationItem.id].citedIn.push(citationIndex); - isLinked = typeof citationItem.id == 'number'; + isLinked = isLinked && typeof citationItem.id == 'number'; } let title = await citation.field.getText(); if (citation.properties.plainCitation != title) { @@ -343,6 +344,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 () { @@ -366,22 +370,22 @@ window.ZoteroDocumentCitations = { } 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'; + const noneSelected = itemList.selection.selected.size === 0; + document.querySelector('#button-show-in-zotero').disabled = noneSelected || isUnlinked; + document.querySelector('#button-relink-item').disabled = noneSelected || !isUnlinked; + document.querySelector('#button-addTo-library').disabled = noneSelected || !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(); } else { - await Zotero.Utilities.Internal.showInLibrary(item); + await Zotero.Utilities.Internal.showInLibrary( + Array.from(itemList.selection.selected).map(index => itemList.getRow(index).ref)); } }, diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index 5f9d7d8db0..5ac5a06816 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -78,9 +78,8 @@
- -
- + +
@@ -95,12 +94,11 @@
- -
- + +
diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index de5370f78b..defedbc315 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -917,6 +917,8 @@ 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 styles.install.title = Install Style styles.install.unexpectedError = An unexpected error occurred while installing "%1$S" From ab849085d705348244fa2dc889acbdf1fdea2080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 18 Mar 2026 14:22:17 +0200 Subject: [PATCH 26/39] Citation Explorer: Restrict multi-select to item rows within one group --- .../zotero/integration/citationExplorer.js | 77 ++++++++++++++++--- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index bf54f7dd78..14cdac81f4 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -92,7 +92,7 @@ class LibraryItemTreeRow extends ItemTreeRow { renderRow(div, index, columns, rowData, renderCtx) { let titleColumn = columns.find(c => c.dataKey === 'title') || columns[0]; - let cell = renderCtx.renderCell(index, this.ref.name, titleColumn, true); + let cell = renderCtx.renderCell(index, this.getDisplayTitle(), titleColumn, true); div.appendChild(cell); } } @@ -191,13 +191,15 @@ class CitationExplorerRowProvider extends ItemTreeRowProvider { return new UnlinkedItemsTreeRow(this._unlinkedItems, isOpen); } if (ref instanceof Zotero.Library) { - let items = this._sourceItems.filter( - item => (item.libraryID ?? Zotero.Libraries.userLibraryID) === ref.libraryID - ); + 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; + let key = ref.treeViewID ?? ref.cslItemID; return new CitationExplorerItemTreeRow(ref, level, isOpen, { citedIn: this._citedInByID.get(key) || [], cslItemID: this._cslItemIDByID.get(key) || null, @@ -237,7 +239,8 @@ class CitationExplorerRowProvider extends ItemTreeRowProvider { this._unlinkedItems = []; for (const item of this._sourceItems) { - if (this._cslItemIDByID.has(item.treeViewID)) { + let key = item.treeViewID ?? item.cslItemID; + if (this._cslItemIDByID.has(key)) { this._unlinkedItems.push(item); continue; } @@ -618,7 +621,62 @@ window.ZoteroCitationExplorer = { * @private */ onItemSelectionChange: async function () { - let selectedRows = [...itemList.selection.selected] + 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); @@ -633,10 +691,11 @@ window.ZoteroCitationExplorer = { let isItemRow = focusedRow instanceof CitationExplorerItemTreeRow; let isUnlinked = isItemRow && !focusedRow.isLinked; let noneSelected = selectedRows.length === 0; + let anyUnlinkedSelected = selectedRows.some(row => !row.isLinked); document.querySelector('#button-show-in-zotero').disabled = noneSelected || !isItemRow || isUnlinked; - document.querySelector('#button-relink-item').disabled = noneSelected || !isUnlinked; - document.querySelector('#button-addTo-library').disabled = noneSelected || !isUnlinked; + document.querySelector('#button-relink-item').disabled = noneSelected || !anyUnlinkedSelected; + document.querySelector('#button-addTo-library').disabled = noneSelected || !anyUnlinkedSelected; await this.refreshCitationList(); }, From cf058891c1caf7d8da570f3fbc507dc051f0c34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Tue, 31 Mar 2026 12:23:39 +0300 Subject: [PATCH 27/39] Citation Explorer: misc renaming/cleanup --- chrome/content/zotero/integration/citationExplorer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 14cdac81f4..b6c5a20ed3 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -366,7 +366,7 @@ window.ZoteroCitationExplorer = { 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'); + let lastTranslationTarget = Zotero.Prefs.get('citationExplorer.lastAddToTarget'); if (lastTranslationTarget) { let id = parseInt(lastTranslationTarget.substr(1)); if (lastTranslationTarget[0] == "L") { @@ -797,7 +797,7 @@ window.ZoteroCitationExplorer = { menu.removeChild(menu.firstChild); } - let target = Zotero.Prefs.get('documentCitations.lastAddToTarget'); + let target = Zotero.Prefs.get('citationExplorer.lastAddToTarget'); if (!target) { target = "L" + Zotero.Libraries.userLibraryID; } @@ -841,7 +841,7 @@ window.ZoteroCitationExplorer = { setAddToTarget(translationTarget) { _addToTarget = translationTarget; - Zotero.Prefs.set('documentCitations.lastAddToTarget', translationTarget.treeViewID); + Zotero.Prefs.set('citationExplorer.lastAddToTarget', translationTarget.treeViewID); this.setAddToButton(); }, From 14495b59825513d3219ccc78ed8c535fd98ff09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Tue, 31 Mar 2026 12:27:58 +0300 Subject: [PATCH 28/39] Citation Explorer: enable add to button on any row --- chrome/content/zotero/integration/citationExplorer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index b6c5a20ed3..b5917adb76 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -690,12 +690,12 @@ window.ZoteroCitationExplorer = { let focusedRow = itemList.getRow(itemList.selection.focused); let isItemRow = focusedRow instanceof CitationExplorerItemTreeRow; let isUnlinked = isItemRow && !focusedRow.isLinked; - let noneSelected = selectedRows.length === 0; + let noneItemsSelected = selectedRows.length === 0; let anyUnlinkedSelected = selectedRows.some(row => !row.isLinked); - document.querySelector('#button-show-in-zotero').disabled = noneSelected || !isItemRow || isUnlinked; - document.querySelector('#button-relink-item').disabled = noneSelected || !anyUnlinkedSelected; - document.querySelector('#button-addTo-library').disabled = noneSelected || !anyUnlinkedSelected; + document.querySelector('#button-show-in-zotero').disabled = noneItemsSelected || !isItemRow || isUnlinked; + document.querySelector('#button-relink-item').disabled = noneItemsSelected || !anyUnlinkedSelected; + document.querySelector('#button-addTo-library').disabled = noneItemsSelected; await this.refreshCitationList(); }, From 5977f1c91794ac3e35d9464aed3d5665c547f59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Tue, 31 Mar 2026 15:02:46 +0300 Subject: [PATCH 29/39] Duplicates: allow to run duplicate queries on multiple libraries --- chrome/content/zotero/xpcom/duplicates.js | 75 ++++++++++++++++------- 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/chrome/content/zotero/xpcom/duplicates.js b/chrome/content/zotero/xpcom/duplicates.js index de8eaca05c..f5b7234d23 100644 --- a/chrome/content/zotero/xpcom/duplicates.js +++ b/chrome/content/zotero/xpcom/duplicates.js @@ -23,21 +23,49 @@ ***** 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() + }; +}; /** * Get duplicates, populate a temporary table, and return a search based @@ -74,7 +102,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; }; @@ -191,15 +221,17 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { } } + let libraryCondition = this._getLibraryCondition(); + // 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') ] @@ -226,12 +258,12 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { // DOI var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " + "JOIN itemDataValues USING (valueID) " - + "WHERE libraryID=? AND fieldID=? AND value LIKE ? " + + `WHERE ${libraryCondition.sql} AND fieldID=? AND value LIKE ? ` + "AND itemID NOT IN (SELECT itemID FROM deletedItems)"; var rows = await Zotero.DB.queryAsync( sql, [ - this._libraryID, + ...libraryCondition.params, Zotero.ItemFields.getID('DOI'), '10.%' ] @@ -254,18 +286,19 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { } // Get years - var dateFields = [Zotero.ItemFields.getID('date')].concat( - Zotero.ItemFields.getTypeFieldsFromBase('date') - ); + var dateFields = [ + Zotero.ItemFields.getID('date'), + ...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 (" + + `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"; - var rows = await Zotero.DB.queryAsync(sql, [this._libraryID].concat(dateFields)); + var rows = await Zotero.DB.queryAsync(sql, [...libraryCondition.params, ...dateFields]); var yearCache = {}; for (let i = 0; i < rows.length; i++) { let row = rows[i]; @@ -280,11 +313,11 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { 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 " + + `WHERE ${libraryCondition.sql} 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]); + var rows = await Zotero.DB.queryAsync(sql, libraryCondition.params); if (rows.length) { //normalize all values ahead of time rows = rows.map(function (row) { @@ -304,10 +337,10 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { 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 ` + + `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, this._libraryID); + let creatorRows = await Zotero.DB.queryAsync(sql, libraryCondition.params); let lastItemID; let itemCreators = []; for (let i = 0; i < creatorRows.length; i++) { From 80b82fc953ec294c4fe1f30e5142ad74673f543f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Fri, 10 Apr 2026 09:17:14 +0300 Subject: [PATCH 30/39] Duplicates: allow to search for duplicates by specific item --- chrome/content/zotero/xpcom/duplicates.js | 643 +++++++++++++--------- test/tests/duplicatesTest.js | 152 +++++ 2 files changed, 531 insertions(+), 264 deletions(-) diff --git a/chrome/content/zotero/xpcom/duplicates.js b/chrome/content/zotero/xpcom/duplicates.js index f5b7234d23..bb3bc69e85 100644 --- a/chrome/content/zotero/xpcom/duplicates.js +++ b/chrome/content/zotero/xpcom/duplicates.js @@ -67,6 +67,34 @@ Zotero.Duplicates.prototype._getLibraryCondition = function (field = 'libraryID' }; }; +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 * on that table @@ -128,101 +156,144 @@ 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) " @@ -236,31 +307,23 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { 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 ${libraryCondition.sql} 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, [ ...libraryCondition.params, @@ -268,190 +331,242 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { '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'), ...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 ${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"; - var rows = await Zotero.DB.queryAsync(sql, [...libraryCondition.params, ...dateFields]); - var yearCache = {}; - for (let i = 0; i < rows.length; i++) { - let row = rows[i]; - yearCache[row.itemID] = row.year; + 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 ${libraryCondition.sql} 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, libraryCondition.params); - 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 ${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); - 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} - 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 diff --git a/test/tests/duplicatesTest.js b/test/tests/duplicatesTest.js index bb63295d91..8d256f2734 100644 --- a/test/tests/duplicatesTest.js +++ b/test/tests/duplicatesTest.js @@ -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 }); From ad90d4b76f1e369cfa3aae6cd1ec17aae54d7473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Fri, 7 Aug 2026 07:23:01 +0000 Subject: [PATCH 31/39] Citation Explorer: snapshot rows before async actions --- chrome/content/zotero/integration/citationExplorer.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index b5917adb76..361c201f91 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -717,6 +717,10 @@ window.ZoteroCitationExplorer = { }, onItemRelink: async function () { + let treeRow = itemList.getRow(itemList.selection.focused); + if (!(treeRow instanceof CitationExplorerItemTreeRow) || treeRow.isLinked) return; + let oldItemID = treeRow.id; + 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); @@ -730,9 +734,6 @@ window.ZoteroCitationExplorer = { if (!items.length) { return; } - let treeRow = itemList.getRow(itemList.selection.focused); - if (treeRow instanceof LibraryItemTreeRow) return; - const oldItemID = treeRow.id; const itemIdx = itemRows.findIndex(row => row.id === oldItemID); this._linkItem(items[0], oldItemID, itemIdx); From 580b9cf5bba3ee39fa17e822a38866d93b6048c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Fri, 7 Aug 2026 07:24:23 +0000 Subject: [PATCH 32/39] Citation Explorer: restrict relinking to one item --- chrome/content/zotero/integration/citationExplorer.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 361c201f91..dd36cee3fb 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -691,10 +691,10 @@ window.ZoteroCitationExplorer = { let isItemRow = focusedRow instanceof CitationExplorerItemTreeRow; let isUnlinked = isItemRow && !focusedRow.isLinked; let noneItemsSelected = selectedRows.length === 0; - let anyUnlinkedSelected = selectedRows.some(row => !row.isLinked); + let canRelink = selectedRows.length === 1 && selectedRows[0] === focusedRow && isUnlinked; document.querySelector('#button-show-in-zotero').disabled = noneItemsSelected || !isItemRow || isUnlinked; - document.querySelector('#button-relink-item').disabled = noneItemsSelected || !anyUnlinkedSelected; + document.querySelector('#button-relink-item').disabled = !canRelink; document.querySelector('#button-addTo-library').disabled = noneItemsSelected; await this.refreshCitationList(); @@ -704,7 +704,7 @@ window.ZoteroCitationExplorer = { let focusedRow = itemList.getRow(itemList.selection.focused); if (focusedRow instanceof LibraryItemTreeRow) return; - if (!focusedRow.isLinked) { + if (!focusedRow.isLinked && itemList.selection.count === 1) { this.onItemRelink(); } else { @@ -718,7 +718,6 @@ window.ZoteroCitationExplorer = { onItemRelink: async function () { let treeRow = itemList.getRow(itemList.selection.focused); - if (!(treeRow instanceof CitationExplorerItemTreeRow) || treeRow.isLinked) return; let oldItemID = treeRow.id; let io = { dataIn: null, dataOut: null, multiSelect: false, deferred: Zotero.Promise.defer() }; From 06eb70abdaf0b8ae1bb3645e77d0044e727f4bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Fri, 7 Aug 2026 07:24:52 +0000 Subject: [PATCH 33/39] Citation Explorer: preserve group open state --- .../content/zotero/integration/citationExplorer.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index dd36cee3fb..33d21474d3 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -179,6 +179,7 @@ class CitationExplorerRowProvider extends ItemTreeRowProvider { this._unlinkedItems = []; this._citedInByID = new Map(); this._cslItemIDByID = new Map(); + this._containerOpenState = new Map(); } /** @@ -235,6 +236,12 @@ class CitationExplorerRowProvider extends ItemTreeRowProvider { * 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 = []; @@ -255,11 +262,13 @@ class CitationExplorerRowProvider extends ItemTreeRowProvider { this._rows = []; if (showContainers) { if (this._unlinkedItems.length) { - this._rows.push(this.createRow({ treeViewID: UNLINKED_ITEMS_ID }, 0, true)); + 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); - this._rows.push(this.createRow(library, 0, true)); + 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 } From af5ab8427b280f42253de40fb33f6dca72c93cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Mon, 10 Aug 2026 16:06:55 +0300 Subject: [PATCH 34/39] Citation Explorer: show suggested items when relinking an unlinked item --- chrome/content/zotero/collectionTree.jsx | 26 +++ .../zotero/integration/citationExplorer.js | 14 +- chrome/content/zotero/selectItemsDialog.js | 148 +++++++++++++++-- chrome/content/zotero/selectItemsDialog.xhtml | 2 +- chrome/locale/en-US/zotero/zotero.ftl | 4 + scss/components/_item-tree.scss | 10 +- test/tests/selectItemsDialogTest.js | 152 ++++++++++++++++++ 7 files changed, 336 insertions(+), 20 deletions(-) create mode 100644 test/tests/selectItemsDialogTest.js 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); + }); + }); +}); From a7df23cdb8206570f871c6b668f7dad611ad8936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Wed, 12 Aug 2026 07:17:52 +0000 Subject: [PATCH 35/39] Move cross-library item copying to Zotero.Items --- chrome/content/zotero/collectionTree.jsx | 176 ++-------------------- chrome/content/zotero/xpcom/data/items.js | 122 +++++++++++++++ test/tests/itemsTest.js | 67 ++++++++ 3 files changed, 199 insertions(+), 166 deletions(-) diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index 0708de1cf2..da1753955c 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -2045,159 +2045,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 { var collections = [{ id: collection.id, @@ -2291,7 +2139,6 @@ var CollectionTree = class CollectionTree extends LibraryTree { parentID: targetCollectionID, addItems, targetLibraryID, - targetTreeRow, copyOptions }); for (let [collectionID, items] of addItems.entries()) { @@ -2388,7 +2235,6 @@ var CollectionTree = class CollectionTree extends LibraryTree { collection: droppedCollection, targetCollectionID, targetLibraryID, - targetTreeRow, copyOptions }); } @@ -2507,12 +2353,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; diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js index dd35b89a53..6cde975a68 100644 --- a/chrome/content/zotero/xpcom/data/items.js +++ b/chrome/content/zotero/xpcom/data/items.js @@ -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} 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) * diff --git a/test/tests/itemsTest.js b/test/tests/itemsTest.js index 9842f2bd60..bedfe4a9df 100644 --- a/test/tests/itemsTest.js +++ b/test/tests/itemsTest.js @@ -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; From de6ed1a8f2c7c2e6581c3014bdc68a9c3dac1bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Thu, 13 Aug 2026 10:34:55 +0300 Subject: [PATCH 36/39] Citation Explorer: extract item tree into its own file Move the shared row classes, row provider, and ItemTree subclass out of citationExplorer.js without changing their behavior. --- .../zotero/integration/citationExplorer.js | 291 +--------------- .../integration/citationExplorerItemTree.js | 319 ++++++++++++++++++ 2 files changed, 326 insertions(+), 284 deletions(-) create mode 100644 chrome/content/zotero/integration/citationExplorerItemTree.js diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index d37b7a7be0..8126912e2d 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -28,292 +28,15 @@ const ReactDOM = require('react-dom'); const diff = require('diff'); const VirtualizedTable = require('components/virtualized-table'); const { getCSSIcon, CSSIcon } = require('components/icons'); -const ItemTree = require('zotero/itemTree'); -const { ItemTreeRowProvider } = ItemTree; -const { ItemTreeRow, ZoteroItemTreeRow } = require('zotero/itemTreeRow'); const { COLUMNS } = require('zotero/itemTreeColumns'); const { makeRowRenderer } = VirtualizedTable; - -// //////////////////////////////////////////////////////////////////////////// -// -// Citation Explorer Row Classes -// -// //////////////////////////////////////////////////////////////////////////// - -const UNLINKED_ITEMS_ID = 'UNLINKED_ITEMS'; - -/** - * Container row wrapping a Zotero.Library. - */ -class LibraryItemTreeRow extends ItemTreeRow { - constructor(library, items, isOpen = true) { - super(library, 0, isOpen); // library has treeViewID = "L" - 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.ref.name; - 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'); - } - - getField(field) { - if (field === 'title') return this.getDisplayTitle(); - return ''; - } - - 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; - } - - 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 === 0) { - let badge = document.createElement('span'); - badge.classList.add('badge', 'badge-uncited'); - badge.textContent = '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 { - 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); - } - - _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; - } -} +const { + CitationExplorerItemTree, + CitationExplorerItemTreeRow, + LibraryItemTreeRow, + UnlinkedItemsTreeRow, + UNLINKED_ITEMS_ID, +} = require('zotero/integration/citationExplorerItemTree'); // //////////////////////////////////////////////////////////////////////////// // diff --git a/chrome/content/zotero/integration/citationExplorerItemTree.js b/chrome/content/zotero/integration/citationExplorerItemTree.js new file mode 100644 index 0000000000..81f4109100 --- /dev/null +++ b/chrome/content/zotero/integration/citationExplorerItemTree.js @@ -0,0 +1,319 @@ +/* + ***** 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 . + + ***** 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'; + +/** + * Container row wrapping a Zotero.Library. + */ +class LibraryItemTreeRow extends ItemTreeRow { + constructor(library, items, isOpen = true) { + super(library, 0, isOpen); // library has treeViewID = "L" + 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.ref.name; + 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'); + } + + getField(field) { + if (field === 'title') return this.getDisplayTitle(); + return ''; + } + + 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; + } + + 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 === 0) { + let badge = document.createElement('span'); + badge.classList.add('badge', 'badge-uncited'); + badge.textContent = '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 { + 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); + } + + _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, +}; + From f37d75f178d72bee7b23b6e8a3db66ca17d75c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Thu, 13 Aug 2026 10:35:46 +0300 Subject: [PATCH 37/39] Add wizard for managing document items --- .../zotero/integration/citationExplorer.js | 197 +---- .../zotero/integration/citationExplorer.xhtml | 10 +- .../integration/citationExplorerItemTree.js | 46 +- .../integration/citationExplorerWizard.js | 694 ++++++++++++++++++ .../integration/citationExplorerWizard.xhtml | 83 +++ chrome/content/zotero/xpcom/integration.js | 81 +- chrome/locale/en-US/zotero/integration.ftl | 28 +- chrome/locale/en-US/zotero/zotero.properties | 1 + scss/_zotero.scss | 3 +- scss/components/_citationExplorer.scss | 32 +- scss/components/_citationExplorerWizard.scss | 86 +++ scss/xulElementPatches/wizard.scss | 3 +- test/tests/citationExplorerWizardTest.js | 367 +++++++++ test/tests/integrationTest.js | 97 ++- 14 files changed, 1511 insertions(+), 217 deletions(-) create mode 100644 chrome/content/zotero/integration/citationExplorerWizard.js create mode 100644 chrome/content/zotero/integration/citationExplorerWizard.xhtml create mode 100644 scss/components/_citationExplorerWizard.scss create mode 100644 test/tests/citationExplorerWizardTest.js diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index 8126912e2d..dfafe9004c 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -47,7 +47,6 @@ const { let io, citations, items, uncitedItems, citationList, itemList; let citationRows = []; let itemRows = []; -let _addToTarget; let disableCitationActivate; const citationColumns = [ @@ -96,22 +95,7 @@ window.ZoteroCitationExplorer = { document.querySelector('#button-edit-citation').addEventListener('click', this.onCitationEdit.bind(this)); document.querySelector('#button-show-in-zotero').addEventListener('click', this.onItemActivate.bind(this)); - document.querySelector('#button-relink-item').addEventListener('click', this.onItemRelink.bind(this)); - - let lastTranslationTarget = Zotero.Prefs.get('citationExplorer.lastAddToTarget'); - if (lastTranslationTarget) { - let id = parseInt(lastTranslationTarget.substr(1)); - if (lastTranslationTarget[0] == "L") { - _addToTarget = Zotero.Libraries.get(id); - } - else if (lastTranslationTarget[0] == "C") { - _addToTarget = Zotero.Collections.get(id); - } - } - if (!_addToTarget) { - _addToTarget = Zotero.Libraries.userLibrary; - } - this.setAddToButton(); + document.querySelector('#button-manage-items').addEventListener('click', this.onManageItems.bind(this)); io = window.arguments[0].wrappedJSObject; citations = Object.values(io.citations); @@ -188,19 +172,6 @@ window.ZoteroCitationExplorer = { onSelectionChange: this.onItemSelectionChange.bind(this), onActivate: this.onItemActivate.bind(this), emptyMessage: Zotero.getString('pane.items.loading'), - compareItems: (a, b) => { - let getGroupOrder = (row) => { - if (row.ref?.treeViewID === UNLINKED_ITEMS_ID - || (row.ref?.cslItemID && !row.ref?.id)) { - return -1; - } - if (row.ref instanceof Zotero.Library) { - return row.ref.libraryID ?? Zotero.Libraries.userLibraryID; - } - return row.ref?.libraryID ?? Zotero.Libraries.userLibraryID; - }; - return getGroupOrder(a) - getGroupOrder(b); - }, }); await itemList.waitForLoad(); // Remove focus from citationList if focus is on itemList @@ -423,11 +394,8 @@ window.ZoteroCitationExplorer = { let isItemRow = focusedRow instanceof CitationExplorerItemTreeRow; let isUnlinked = isItemRow && !focusedRow.isLinked; let noneItemsSelected = selectedRows.length === 0; - let canRelink = selectedRows.length === 1 && selectedRows[0] === focusedRow && isUnlinked; document.querySelector('#button-show-in-zotero').disabled = noneItemsSelected || !isItemRow || isUnlinked; - document.querySelector('#button-relink-item').disabled = !canRelink; - document.querySelector('#button-addTo-library').disabled = noneItemsSelected; await this.refreshCitationList(); }, @@ -437,7 +405,7 @@ window.ZoteroCitationExplorer = { if (focusedRow instanceof LibraryItemTreeRow) return; if (!focusedRow.isLinked && itemList.selection.count === 1) { - this.onItemRelink(); + this.onManageItems(); } else { let selectedItems = [...itemList.selection.selected] @@ -448,153 +416,22 @@ window.ZoteroCitationExplorer = { } }, - onItemRelink: async function () { - let treeRow = itemList.getRow(itemList.selection.focused); - let oldItemID = treeRow.id; - - let libraryIDs = Zotero.Libraries.getAll() - .filter(library => library.libraryType != 'feed') - .map(library => library.libraryID); - let itemIDs = await new Zotero.Duplicates(libraryIDs).findDuplicatesOf(treeRow.ref); - let io = { - dataIn: null, - dataOut: null, - itemIDs: itemIDs.length ? itemIDs : undefined, - multiSelect: false, - onlyRegularItems: true, - deferred: Zotero.Promise.defer() - }; - window.openDialog('chrome://zotero/content/selectItemsDialog.xhtml', '', - 'chrome,dialog=no,centerscreen,resizable=yes', io); - - await io.deferred.promise; - if (!io.dataOut || !io.dataOut.length) { - return; + onManageItems: async function () { + let wizardIO = Object.assign({}, io, { + completed: false, + deferred: Zotero.Promise.defer(), + }); + wizardIO.wrappedJSObject = wizardIO; + window.openDialog( + 'chrome://zotero/content/integration/citationExplorerWizard.xhtml', + '', + 'chrome,dialog=no,centerscreen,resizable=yes', + wizardIO + ); + await wizardIO.deferred.promise; + if (wizardIO.completed) { + window.close(); } - - let items = await Zotero.Items.getAsync(io.dataOut); - if (!items.length) { - return; - } - const itemIdx = itemRows.findIndex(row => row.id === oldItemID); - this._linkItem(items[0], oldItemID, itemIdx); - - await this._initMappings(); - await this.refreshCitationList(); - await this.refreshItemList(); - await itemList.selectItem(items[0].id); - }, - - async addToLibraryAndLink() { - var collectionID = _addToTarget.objectType == 'collection' ? _addToTarget.id : undefined; - - // Load library data - let targetLibraryID = _addToTarget.libraryID || _addToTarget.library.libraryID; - let library = Zotero.Libraries.get(targetLibraryID); - if (!library.getDataLoaded('item')) { - Zotero.debug("Waiting for items to load for library " + library.libraryID); - await library.waitForDataLoad('item'); - } - - for (let index of itemList.selection.selected) { - let treeRow = itemList.getRow(index); - if (treeRow instanceof LibraryItemTreeRow) continue; - const oldItemID = treeRow.id; - const itemIdx = itemRows.findIndex(row => row.id === oldItemID); - - // Save item - let item = treeRow.ref.clone(_addToTarget.libraryID); - if (collectionID) { - item.addToCollection(collectionID); - } - await item.saveTx(); - this._linkItem(item, oldItemID, itemIdx); - } - await this._initMappings(); - await this.refreshCitationList(); - await this.refreshItemList(); - }, - - _linkItem(item, oldItemID, itemIdx) { - // For all citations where the item is cited - for (let citationIndex of itemRows[itemIdx].citedIn) { - let citation = citations[citationIndex]; - let citationItemIdx = citation.citationItems.findIndex(i => i.id == oldItemID); - let citationItem = citation.citationItems[citationItemIdx]; - // Update the citation with the new item - citationItem.id = item.id; - citationItem.uris = Zotero.Integration.currentSession.uriMap.getURIsForItemID(citationItem.id); - // Mark citation for an update with citeproc and write changes to doc - io.updateIndex(citationIndex); - } - items[itemIdx] = item; - }, - - buildAddToLibraryContextMenu(event) { - var menu = document.querySelector('#item-addTo-menu'); - // Don't trigger rebuilding on nested popupmenu open/close - if (event.target != menu) { - return; - } - // Clear previous items - while (menu.firstChild) { - menu.removeChild(menu.firstChild); - } - - let target = Zotero.Prefs.get('citationExplorer.lastAddToTarget'); - if (!target) { - target = "L" + Zotero.Libraries.userLibraryID; - } - - var libraries = Zotero.Libraries.getAll(); - for (let library of libraries) { - if (!library.editable || library.libraryType == 'publications') { - continue; - } - Zotero.Utilities.Internal.createMenuForTarget( - library, - menu, - target, - function(event, libraryOrCollection) { - if (event.target.tagName == 'menu') { - Zotero.Promise.coroutine(function* () { - // Simulate menuitem flash on OS X - if (Zotero.isMac) { - event.target.setAttribute('_moz-menuactive', false); - yield Zotero.Promise.delay(50); - event.target.setAttribute('_moz-menuactive', true); - yield Zotero.Promise.delay(50); - event.target.setAttribute('_moz-menuactive', false); - yield Zotero.Promise.delay(50); - event.target.setAttribute('_moz-menuactive', true); - } - menu.hidePopup(); - - ZoteroCitationExplorer.setAddToTarget(libraryOrCollection); - event.stopPropagation(); - })(); - } - else { - ZoteroCitationExplorer.setAddToTarget(libraryOrCollection); - event.stopPropagation(); - } - } - ); - } - }, - - setAddToTarget(translationTarget) { - _addToTarget = translationTarget; - Zotero.Prefs.set('citationExplorer.lastAddToTarget', translationTarget.treeViewID); - this.setAddToButton(); - }, - - setAddToButton() { - var label = Zotero.getString('pane.item.addTo', _addToTarget.name); - var elem = document.querySelector('#button-addTo-library'); - elem.label = label; - elem.title = label; - elem.image = _addToTarget.treeViewImage; }, diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index c1de69fac7..79b6da489d 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -90,18 +90,12 @@
-
+
- -
diff --git a/chrome/content/zotero/integration/citationExplorerItemTree.js b/chrome/content/zotero/integration/citationExplorerItemTree.js index 81f4109100..a003e64528 100644 --- a/chrome/content/zotero/integration/citationExplorerItemTree.js +++ b/chrome/content/zotero/integration/citationExplorerItemTree.js @@ -36,6 +36,21 @@ const { ItemTreeRow, ZoteroItemTreeRow } = require('zotero/itemTreeRow'); const UNLINKED_ITEMS_ID = 'UNLINKED_ITEMS'; +/** + * Sort unlinked items first, then libraries by libraryID. + * Used as CitationExplorerItemTree's default compareItems(). + */ +function getLibraryGroupOrder(row) { + if (row.ref?.treeViewID === UNLINKED_ITEMS_ID + || (row.ref?.cslItemID && !row.ref?.id)) { + return -1; + } + if (row.ref instanceof Zotero.Library) { + return row.ref.libraryID ?? Zotero.Libraries.userLibraryID; + } + return row.ref?.libraryID ?? Zotero.Libraries.userLibraryID; +} + /** * Container row wrapping a Zotero.Library. */ @@ -66,7 +81,7 @@ class LibraryItemTreeRow extends ItemTreeRow { } getField(field) { - if (field === 'title') return this.ref.name; + if (field === 'title') return this.getDisplayTitle(); return ''; } @@ -107,11 +122,6 @@ class UnlinkedItemsTreeRow extends LibraryItemTreeRow { return Zotero.getString('integration.citationExplorer.unlinkedItems'); } - getField(field) { - if (field === 'title') return this.getDisplayTitle(); - return ''; - } - getIcon() { let icon = getCSSIcon('cross'); icon.classList.add('icon-item-type'); @@ -130,6 +140,11 @@ class CitationExplorerItemTreeRow extends ZoteroItemTreeRow { this.cslItemID = cslItemID; } + getField(field) { + if (field === 'action') return ''; + return super.getField(field); + } + get isLinked() { return !this.cslItemID; } @@ -144,10 +159,10 @@ class CitationExplorerItemTreeRow extends ZoteroItemTreeRow { renderPrimaryCell(index, data, column) { let cell = super.renderPrimaryCell(index, data, column); - if (this.citedIn.length === 0) { + if (!this.citedIn.length) { let badge = document.createElement('span'); badge.classList.add('badge', 'badge-uncited'); - badge.textContent = 'Uncited'; + badge.textContent = Zotero.getString('integration.citationExplorer.uncited'); let textSpan = cell.querySelector('.cell-text'); if (textSpan) textSpan.after(badge); else cell.appendChild(badge); @@ -286,6 +301,11 @@ class CitationExplorerRowProvider extends ItemTreeRowProvider { * ItemTree subclass that uses CitationExplorerRowProvider. */ class CitationExplorerItemTree extends ItemTree { + static defaultProps = { + ...ItemTree.defaultProps, + compareItems: (a, b) => getLibraryGroupOrder(a) - getLibraryGroupOrder(b), + }; + constructor(props) { super(props); this.rowProvider = new CitationExplorerRowProvider(this); @@ -301,6 +321,15 @@ class CitationExplorerItemTree extends ItemTree { return !!this.getRow(index); } + handleKeyDown(event) { + if (!super.handleKeyDown(event)) return false; + if (event.key === ' ' + && this.props.onActionMenuOpen?.()) { + return false; + } + return true; + } + _renderItem(index, selection, oldDiv = null, columns = []) { let div = super._renderItem(index, selection, oldDiv, columns); let row = this.getRow(index); @@ -315,5 +344,6 @@ module.exports = { LibraryItemTreeRow, UnlinkedItemsTreeRow, UNLINKED_ITEMS_ID, + getLibraryGroupOrder, }; diff --git a/chrome/content/zotero/integration/citationExplorerWizard.js b/chrome/content/zotero/integration/citationExplorerWizard.js new file mode 100644 index 0000000000..f6797650c1 --- /dev/null +++ b/chrome/content/zotero/integration/citationExplorerWizard.js @@ -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 . + + ***** 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} */ +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; + }, +}; diff --git a/chrome/content/zotero/integration/citationExplorerWizard.xhtml b/chrome/content/zotero/integration/citationExplorerWizard.xhtml new file mode 100644 index 0000000000..56e1579326 --- /dev/null +++ b/chrome/content/zotero/integration/citationExplorerWizard.xhtml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/chrome/content/zotero/xpcom/integration.js b/chrome/content/zotero/xpcom/integration.js index 92757aca74..1c8af5ec5e 100644 --- a/chrome/content/zotero/xpcom/integration.js +++ b/chrome/content/zotero/xpcom/integration.js @@ -2454,6 +2454,85 @@ Zotero.Integration.Session.prototype.promptForRetraction = function (citedItem, return checkbox.value; } + +/** + * Relink document and bibliography entries to replacement library items + * + * @param {{oldItemID: Number|String, item: Zotero.Item}[]} replacements + */ +Zotero.Integration.Session.prototype._relinkItems = function (replacements) { + let replacementsByID = new Map( + replacements.map(({ oldItemID, item }) => [String(oldItemID), item]) + ); + let changed = false; + + // Relink every citation occurrence and mark affected fields for update + for (let [index, citation] of Object.entries(this.citationsByIndex)) { + let citationChanged = false; + for (let citationItem of citation.citationItems) { + let oldItemID = String(citationItem.cslItemID ?? citationItem.id); + let item = replacementsByID.get(oldItemID); + if (!item) continue; + + citationItem.id = item.id; + delete citationItem.cslItemID; + citationItem.uris = this.uriMap.getURIsForItemID(item.id); + citationChanged = true; + } + if (citationChanged) { + this.updateIndices[index] = true; + changed = true; + } + } + + // Keep the session index in sync with the mutated citations + this.citationsByItemID = {}; + for (let citation of Object.values(this.citationsByIndex)) { + for (let citationItem of citation.citationItems) { + let itemID = citationItem.cslItemID ?? citationItem.id; + if (!this.citationsByItemID[itemID]) { + this.citationsByItemID[itemID] = []; + } + this.citationsByItemID[itemID].push(citation); + } + } + + // Relink uncited, omitted, and customized bibliography entries + if (this.bibliography) { + let bibliographyDataChanged = false; + let replaceInSet = (set, skipCited = false) => { + for (let [oldItemID, item] of replacementsByID) { + if (!set.delete(oldItemID)) continue; + if (!skipCited || !this.citationsByItemID[item.id]) { + set.add(String(item.id)); + } + changed = true; + bibliographyDataChanged = true; + } + }; + replaceInSet(this.bibliography.uncitedItemIDs, true); + replaceInSet(this.bibliography.omittedItemIDs); + + for (let [oldItemID, item] of replacementsByID) { + if (oldItemID in this.bibliography.customEntryText) { + this.bibliography.customEntryText[item.id] + = this.bibliography.customEntryText[oldItemID]; + delete this.bibliography.customEntryText[oldItemID]; + changed = true; + bibliographyDataChanged = true; + } + } + if (bibliographyDataChanged) { + this.bibliographyDataHasChanged = true; + } + } + + if (changed) { + this.bibliographyHasChanged = true; + } +}; + + /** * Opens the citation explorer */ @@ -2475,7 +2554,7 @@ Zotero.Integration.Session.prototype.openCitationExplorer = async function () { const data = await citationField.unserialize(); return data.citationID === citation.citationID; }, - updateIndex: index => this.updateIndices[index] = true + relinkItems: replacements => this._relinkItems(replacements) }; await Zotero.Integration.displayDialog('chrome://zotero/content/integration/citationExplorer.xhtml', 'resizable', io); diff --git a/chrome/locale/en-US/zotero/integration.ftl b/chrome/locale/en-US/zotero/integration.ftl index cd1142ae5c..819ddd4700 100644 --- a/chrome/locale/en-US/zotero/integration.ftl +++ b/chrome/locale/en-US/zotero/integration.ftl @@ -18,6 +18,33 @@ integration-editBibliography-wrapper = .aria-description = { -integration-editBibliography-include-uncited } { -integration-editBibliography-exclude-cited } { -integration-editBibliography-edit-reference } + +integration-citationExplorerWizard-window = + .title = Add/Link Document Items +integration-citationExplorerWizard-choose-header = Choose an Action and Destination +integration-citationExplorerWizard-choose-description = Choose how to handle items used in this document and where to place them. +integration-citationExplorerWizard-operation-relink = + .label = Relink unlinked items +integration-citationExplorerWizard-operation-relink-description = Link only unlinked document items to matching items, or add them to the selected library or collection. +integration-citationExplorerWizard-operation-add-to-target = + .label = Copy and relink all items +integration-citationExplorerWizard-operation-add-to-target-description = Ensure that all document items are linked to items in the selected library or collection. +integration-citationExplorerWizard-target = Destination: +integration-citationExplorerWizard-review-header = Review Item Actions +integration-citationExplorerWizard-review-description = Review the proposed action for each item. Choose “Do nothing” to leave an item unchanged. +integration-citationExplorerWizard-action-column = Action +integration-citationExplorerWizard-action-add-to-target = Add to { $target } +integration-citationExplorerWizard-action-relink = Relink to existing item “{ $title }” +integration-citationExplorerWizard-action-copy = Copy an existing item from { $library } +integration-citationExplorerWizard-action-choose = Choose Item… +integration-citationExplorerWizard-action-skip = Do nothing +integration-citationExplorerWizard-choose-button = Choose… +integration-citationExplorerWizard-status-finding = Finding matching items… +integration-citationExplorerWizard-status-find-error = Unable to determine item actions. +integration-citationExplorerWizard-status-no-changes = No changes are needed. +integration-citationExplorerWizard-status-applying = Applying changes… +integration-citationExplorerWizard-status-apply-error = An error occurred while applying changes. + integration-citationDialog = Citation Dialog integration-citationDialog-section-open = Open Documents ({ $count }) integration-citationDialog-section-selected = Selected Items ({ $count }/{ $total }) @@ -152,4 +179,3 @@ integration-warning-command-is-running = A word processor integration command is first-run-guidance-citationDialog = Click the bubble or use the ← and ↓ keys to view the citation details and customize options such as page number, prefix, and suffix. You can also add a page number or other locator by including it with your search terms (e.g., “history { $locator }”) or by typing it after the bubble and pressing { return-or-enter }. - diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index defedbc315..1132fde137 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -919,6 +919,7 @@ integration.mendeleyImport.description = %1$S detected that the document you integration.mendeleyImport.openImporter = Open Mendeley Importer... integration.citationExplorer.citationsModified = Citations in your document have been modified since Citation Explorer has been opened and %S will not be able to activate them until you reopen this window. integration.citationExplorer.unlinkedItems = Unlinked Items +integration.citationExplorer.uncited = Uncited styles.install.title = Install Style styles.install.unexpectedError = An unexpected error occurred while installing "%1$S" diff --git a/scss/_zotero.scss b/scss/_zotero.scss index c3f572158f..7def3dd4ac 100644 --- a/scss/_zotero.scss +++ b/scss/_zotero.scss @@ -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"; \ No newline at end of file +@import "elements/fileRenamingSettings"; diff --git a/scss/components/_citationExplorer.scss b/scss/components/_citationExplorer.scss index c270d738f9..63a811f22a 100644 --- a/scss/components/_citationExplorer.scss +++ b/scss/components/_citationExplorer.scss @@ -1,3 +1,19 @@ +.citation-explorer-item-tree .virtualized-table .row { + .cell .badge { + font-size: 11px; + padding: 1px 4px; + border-radius: 5px; + display: inline-block; + background: #59ADC44D; + margin-left: 4px; + } + + &.selected .cell .badge { + background: rgba(255, 255, 255, 0.2); + color: var(--accent-white); + } +} + #zotero-citation-explorer-dialog { min-height: 500px; min-width: 600px; @@ -55,22 +71,6 @@ background: initial !important; } - .virtualized-table .row { - .cell .badge { - font-size: 11px; - padding: 1px 4px; - border-radius: 5px; - display: inline-block; - background: #59ADC44D; - margin-left: 4px; - } - - &.selected .cell .badge { - background: rgba(255, 255, 255, 0.2); - color: --var(accent-white); - } - } - .button-container { border-bottom: var(--material-border-quarternary); display: flex; diff --git a/scss/components/_citationExplorerWizard.scss b/scss/components/_citationExplorerWizard.scss new file mode 100644 index 0000000000..277d597090 --- /dev/null +++ b/scss/components/_citationExplorerWizard.scss @@ -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; + } + } + } +} diff --git a/scss/xulElementPatches/wizard.scss b/scss/xulElementPatches/wizard.scss index 50175ab338..3cd570d90c 100644 --- a/scss/xulElementPatches/wizard.scss +++ b/scss/xulElementPatches/wizard.scss @@ -44,4 +44,5 @@ html|*.wizard-page-box:not(.specificity) { padding: 16px 0; -} \ No newline at end of file + min-height: 0; +} diff --git a/test/tests/citationExplorerWizardTest.js b/test/tests/citationExplorerWizardTest.js new file mode 100644 index 0000000000..5be57eab3f --- /dev/null +++ b/test/tests/citationExplorerWizardTest.js @@ -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' + ); + }); + }); + }); +}); diff --git a/test/tests/integrationTest.js b/test/tests/integrationTest.js index d137045c40..1ab8e23e00 100644 --- a/test/tests/integrationTest.js +++ b/test/tests/integrationTest.js @@ -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; From 9d4b1d599512b815e5458b37e365d54d9265d216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Thu, 13 Aug 2026 15:02:50 +0300 Subject: [PATCH 38/39] Fix weird virtualized-table header border rules --- scss/components/_item-tree.scss | 8 -------- scss/components/_virtualized-table.scss | 19 +------------------ 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/scss/components/_item-tree.scss b/scss/components/_item-tree.scss index 368eed215c..50113126c0 100644 --- a/scss/components/_item-tree.scss +++ b/scss/components/_item-tree.scss @@ -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 { diff --git a/scss/components/_virtualized-table.scss b/scss/components/_virtualized-table.scss index 17b4cf0800..b7446363a3 100644 --- a/scss/components/_virtualized-table.scss +++ b/scss/components/_virtualized-table.scss @@ -237,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)); @@ -259,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; } From 3d1ece74e47cabd1c2543ed3a57745498d8ca8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adomas=20Ven=C4=8Dkauskas?= Date: Thu, 13 Aug 2026 15:16:49 +0300 Subject: [PATCH 39/39] Citation Explorer: i18n --- .../zotero/integration/citationExplorer.js | 4 ++-- .../zotero/integration/citationExplorer.xhtml | 19 +++++++++-------- chrome/locale/en-US/zotero/integration.ftl | 21 ++++++++++++++++++- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/chrome/content/zotero/integration/citationExplorer.js b/chrome/content/zotero/integration/citationExplorer.js index dfafe9004c..6ce6f441b2 100644 --- a/chrome/content/zotero/integration/citationExplorer.js +++ b/chrome/content/zotero/integration/citationExplorer.js @@ -52,12 +52,12 @@ let disableCitationActivate; const citationColumns = [ { dataKey: 'title', - label: "Citation", + label: 'integration-citationExplorer-citation-column', type: 'html' }, { dataKey: 'isLinked', - label: 'Is Linked', + label: 'integration-citationExplorer-linked-column', iconLabel: , width: 26, staticWidth: true, diff --git a/chrome/content/zotero/integration/citationExplorer.xhtml b/chrome/content/zotero/integration/citationExplorer.xhtml index 79b6da489d..8cde1bc579 100644 --- a/chrome/content/zotero/integration/citationExplorer.xhtml +++ b/chrome/content/zotero/integration/citationExplorer.xhtml @@ -36,7 +36,7 @@ id="zotero-citation-explorer-dialog" class="zotero-dialog-window" orient="vertical" - title="Citation Explorer" + 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" @@ -54,6 +54,7 @@ +