Repository: zotero/zotero (JavaScript)

Issue:
Citation dialog: prefix and suffix values persist on item in the citation
Prefix and suffix on a citation item will stick to it. So if the item is deleted but then re-added into the citation, suffix and prefix remain.

https://github.com/user-attachments/assets/3a4f6b1c-3425-4bdb-bd3b-0feed8a9e829

More curious, this behavior will persist if I close and reopen the dialog.



Fix (patch):
diff --git a/chrome/content/zotero/elements/bubbleInput.js b/chrome/content/zotero/elements/bubbleInput.js
index 309562ab762..d6f21205f6b 100644
--- a/chrome/content/zotero/elements/bubbleInput.js
+++ b/chrome/content/zotero/elements/bubbleInput.js
@@ -50,21 +50,20 @@
 		 * are not present, remove bubbles whose citation items were removed, rearrange bubbles
 		 * if the items were moved, update bubble text if locator/prefix/suffix was changed.
 		 * Make sure that there is an input for user to type in before and after every bubble.
-		 * @param {Object[]} combinedItems - array of objects { zoteroItem, citationItem, dialogReferenceID, selected }.
-		 * zoteroItem - Zotero.Item, citationItem - object from io.citation.citationItems
-		 * dialogReferenceID - String ID of this citation entry, selected - Boolean indicator if bubble should be highlighted
+		 * @param {Object[]} bubblesConfig - array of objects { dialogReferenceID, bubbleString, selected}
+		 * representing each bubble.
 		 */
-		refresh(combinedItems) {
+		refresh(bubblesConfig) {
 			// Remove bubbles of items that are no longer in the citations
 			for (let bubble of this.getAllBubbles()) {
 				let bubbleDialogReferenceID = bubble.getAttribute("dialogReferenceID");
-				let itemExistsForBubble = combinedItems.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID);
+				let itemExistsForBubble = bubblesConfig.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID);
 				if (!itemExistsForBubble) {
 					bubble.remove();
 				}
 			}
 			// Ensure each item in the citation has a bubble in the right position
-			for (let [index, { dialogReferenceID, bubbleString }] of Object.entries(combinedItems)) {
+			for (let [index, { dialogReferenceID, bubbleString }] of Object.entries(bubblesConfig)) {
 				let allBubbles = this.getAllBubbles();
 				let bubbleNode = allBubbles.find(candidate => candidate.getAttribute("dialogReferenceID") == dialogReferenceID);
 				// Create bubble if it does not exist and append to the input
@@ -95,7 +94,7 @@
 			// Highlight bubbles selected in the library view
 			for (let bubble of this.getAllBubbles()) {
 				let bubbleDialogReferenceID = bubble.getAttribute("dialogReferenceID");
-				let itemObj = combinedItems.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID);
+				let itemObj = bubblesConfig.find(({ dialogReferenceID }) => dialogReferenceID == bubbleDialogReferenceID);
 				if (itemObj) {
 					bubble.classList.toggle("has-item-selected", !!itemObj.selected);
 				}
diff --git a/chrome/content/zotero/integration/citationDialog.js b/chrome/content/zotero/integration/citationDialog.js
index 7cde71b9374..864037ebf1c 100644
--- a/chrome/content/zotero/integration/citationDialog.js
+++ b/chrome/content/zotero/integration/citationDialog.js
@@ -181,8 +181,8 @@ class Layout {
 		let sections = [];
 
 		// Tell SearchHandler which currently cited items are so they are not included in results
-		let citedItems = CitationDataManager.getCitationItems();
-		let searchResultGroups = SearchHandler.getOrderedSearchResultGroups(citedItems);
+		let citedIDs = CitationDataManager.getCitedLibraryItemIDs();
+		let searchResultGroups = SearchHandler.getOrderedSearchResultGroups(citedIDs);
 		for (let { key, group, isLibrary } of searchResultGroups) {
 			// selected items become a collapsible deck/list if there are multiple items
 			let isGroupCollapsible = key == "selected" && group.length > 1;
@@ -440,13 +440,8 @@ class LibraryLayout extends Layout {
 	updateSelectedItems() {
 		if (!libraryLayout.itemsView) return;
 		let selectedItemIDs = new Set(libraryLayout.itemsView.getSelectedItems().map(item => item.id));
-		for (let itemObj of CitationDataManager.items) {
-			if (selectedItemIDs.has(itemObj.zoteroItem.id)) {
-				itemObj.selected = true;
-			}
-			else {
-				itemObj.selected = false;
-			}
+		for (let bubbleItem of CitationDataManager.items) {
+			bubbleItem.selected = selectedItemIDs.has(bubbleItem.id);
 		}
 		IOManager.updateBubbleInput();
 	}
@@ -711,10 +706,9 @@ class LibraryLayout extends Layout {
 		if (event.key == "Backspace") {
 			let itemsToRemove = this.itemsView.getSelectedItems();
 			for (let item of itemsToRemove) {
-				let citationItems = CitationDataManager.getItems({ zoteroItemID: item.id });
-				for (let citationItem of citationItems) {
-					let { dialogReferenceID } = citationItem;
-					IOManager._deleteItem(dialogReferenceID);
+				let items = CitationDataManager.getItems({ itemID: item.id });
+				for (let item of items) {
+					IOManager._deleteItem(item.dialogReferenceID);
 				}
 			}
 		}
@@ -722,12 +716,12 @@ class LibraryLayout extends Layout {
 
 	// Highlight/de-highlight selected rows
 	async _refreshItemsViewHighlightedRows() {
-		let selectedIDs = CitationDataManager.items.map(({ zoteroItem }) => zoteroItem.id).filter(id => !!id);
+		let selectedIDs = CitationDataManager.getCitedLibraryItemIDs();
 		// Wait for the tree to fully load to avoid a logged error that the tree is undefined
 		while (!this.itemsView.tree) {
 			await Zotero.Promise.delay(10);
 		}
-		this.itemsView.setHighlightedRows(selectedIDs);
+		this.itemsView.setHighlightedRows([...selectedIDs]);
 	}
 
 	_scrollHorizontallyOnWheel(event) {
@@ -820,13 +814,8 @@ class ListLayout extends Layout {
 
 	updateSelectedItems() {
 		let selectedIDs = new Set([...doc.querySelectorAll(".item.selected")].map(node => parseInt(node.getAttribute("itemID"))));
-		for (let itemObj of CitationDataManager.items) {
-			if (selectedIDs.has(itemObj.zoteroItem.id)) {
-				itemObj.selected = true;
-			}
-			else {
-				itemObj.selected = false;
-			}
+		for (let bubbleItem of CitationDataManager.items) {
+			bubbleItem.selected = selectedIDs.has(bubbleItem.id);
 		}
 		IOManager.updateBubbleInput();
 	}
@@ -950,6 +939,10 @@ const IOManager = {
 			let mode = _id("mode-button").getAttribute("mode");
 			newMode = mode == "library" ? "list" : "library";
 		}
+		// Do nothing if switching to a mode that is already active
+		let currentMode = _id("mode-button").getAttribute("mode");
+		if (currentMode == newMode) return;
+		
 		_id("list-layout").hidden = newMode == "library";
 		_id("library-layout").hidden = newMode == "list";
 
@@ -990,9 +983,15 @@ const IOManager = {
 	updateBubbleInput() {
 		// re-generate the bubble string for each item, in case a locator/prefix/suffix/etc. was changed
 		for (let item of CitationDataManager.items) {
-			item.bubbleString = Helpers.buildBubbleString({ citationItem: item.citationItem, zoteroItem: item.zoteroItem });
-		}
-		_id("bubble-input").refresh(CitationDataManager.items);
+			item.updateBubbleString();
+		}
+		_id("bubble-input").refresh(CitationDataManager.items.map((item) => {
+			return {
+				dialogReferenceID: item.dialogReferenceID,
+				bubbleString: item.bubbleString,
+				selected: item.selected,
+			};
+		}));
 		_id("accept-button").disabled = !CitationDataManager.items.length;
 	},
 
@@ -1005,7 +1004,8 @@ const IOManager = {
 		if (isCitingNotes) {
 			if (!items[0].isNote()) return;
 			CitationDataManager.items = [];
-			await CitationDataManager.addItems({ citationItems: items });
+			let bubbleItem = BubbleItem.fromItem(items[0]);
+			await CitationDataManager.addItems({ bubbleItems: [bubbleItem] });
 			accept();
 			return;
 		}
@@ -1019,18 +1019,13 @@ const IOManager = {
 		
 		// If multiple items are being added, only add ones that are not included in the citation
 		if (items.length > 1) {
-			items = items.filter(item => !(item.id && CitationDataManager.getItems({ zoteroItemID: item.id }).length));
+			items = items.filter(item => !(item.id && CitationDataManager.getItems({ itemID: item.id }).length));
 		}
 
 		// If the last input has a locator, add it into the item
 		let input = _id("bubble-input").getCurrentInput();
 		let inputValue = SearchHandler.cleanSearchQuery(input?.value || "");
 		let locator = Helpers.extractLocator(inputValue);
-		// If there is no locator, make sure we clear it from the citation item
-		for (let item of items) {
-			item.label = locator?.label || null;
-			item.locator = locator?.locator || null;
-		}
 		// Add the item at a position based on current input if it is not explicitly specified
 		if (index === null && input) {
 			index = _id("bubble-input").getFutureBubbleIndex();
@@ -1040,7 +1035,15 @@ const IOManager = {
 			input.remove();
 		}
 
-		await CitationDataManager.addItems({ citationItems: items, index });
+		// Add entries into the citation with the current locator if specified
+		let bubbleItems = items.map(item => BubbleItem.fromItem(item));
+		if (locator) {
+			for (let bubbleItem of bubbleItems) {
+				bubbleItem.locator = locator.locator;
+				bubbleItem.label = locator.label;
+			}
+		}
+		await CitationDataManager.addItems({ bubbleItems, index });
 		// Refresh the itemTree if in library mode
 		if (currentLayout.type == "library") {
 			libraryLayout.refreshItemsView();
@@ -1255,8 +1258,8 @@ const IOManager = {
 		let bubble = input.previousElementSibling;
 		let item = CitationDataManager.getItem({ dialogReferenceID: bubble?.getAttribute("dialogReferenceID") });
 		if (item && locator && locator.onlyLocator && bubble) {
-			item.citationItem.locator = locator.locator;
-			item.citationItem.label = locator.label;
+			item.locator = locator.locator;
+			item.label = locator.label;
 			input.value = "";
 			input.dispatchEvent(new Event('input', { bubbles: true }));
 			this.updateBubbleInput();
@@ -1308,8 +1311,8 @@ const IOManager = {
 	},
 
 	_openItemDetailsPopup(dialogReferenceID) {
-		let { zoteroItem, citationItem } = CitationDataManager.getItem({ dialogReferenceID });
-		PopupsHandler.openItemDetails(dialogReferenceID, zoteroItem, citationItem, Helpers.buildItemDescription(zoteroItem));
+		let bubbleItem = CitationDataManager.getItem({ dialogReferenceID });
+		PopupsHandler.openItemDetails(bubbleItem, Helpers.buildItemDescription(bubbleItem.item));
 	},
 
 	_handleInput({ query, eventType }) {
@@ -1425,27 +1428,112 @@ const IOManager = {
 	}
 };
 
+// Representation of a single entry in the citation.
+class BubbleItem {
+	// Can be created from either Zotero.Item or citation item from io.citation.citationItems
+	static fromItem(item) {
+		let citationItem = {};
+		return new BubbleItem({ item, citationItem });
+	}
+
+	static fromCitationItem(citationItem) {
+		let item;
+		if (io.customGetItem) {
+			item = io.customGetItem(citationItem);
+		}
+		if (!item) {
+			item = Zotero.Cite.getItem(citationItem.id);
+		}
+		return new BubbleItem({ item, citationItem });
+	}
+
+	constructor({ item, citationItem }) {
+		if (!item || !citationItem) {
+			throw new Error("Both Zotero.Item and citation item must be provided");
+		}
+		this.item = item;
+		this.cslItemID = item.cslItemID;
+		this.cslItemData = item.cslItemData;
+		this.cslURIs = item.cslURIs;
+
+		this.locator = citationItem.locator;
+		this.label = citationItem.label;
+		this.suffix = citationItem.suffix;
+		this.prefix = citationItem.prefix;
+		this.suppressAuthor = citationItem["suppress-author"];
+		
+		this.bubbleString = "";
+		this.selected = false;
+		// Add a new ID to our citation item and set the same ID on the bubble
+		// so we have a reliable way to identify which bubble refers to which citationItem.
+		this.dialogReferenceID = Zotero.Utilities.randomString(5);
+		this.updateBubbleString();
+	}
+
+	get id() {
+		return this.cslItemID || this.item.id;
+	}
+
+	updateBubbleString() {
+		this.bubbleString = Helpers.buildBubbleString(this);
+	}
+
+	// Return an object with relevant fields that cipeproc can consume.
+	// Can optionally include dialogReferenceID for sorting in CitationDataManager.sort()
+	getCitationItem({ includeDialogReferenceID } = {}) {
+		let citationItem = {
+			id: this.id,
+		};
+		if (this.locator) {
+			citationItem.locator = this.locator;
+			citationItem.label = this.label;
+		}
+		if (this.suffix) {
+			citationItem.suffix = this.suffix;
+		}
+
+		if (this.prefix) {
+			citationItem.prefix = this.prefix;
+		}
+		if (this.suppressAuthor) {
+			citationItem["suppress-author"] = this.suppressAuthor;
+		}
+		if (this.cslItemData) {
+			citationItem.itemData = this.cslItemData;
+		}
+		if (this.cslURIs) {
+			citationItem.uris = this.cslURIs;
+		}
+		if (includeDialogReferenceID) {
+			citationItem.dialogReferenceID = this.dialogReferenceID;
+		}
+		return citationItem;
+	}
+}
+
 //
 // Singleton to store and handle items in this citation.
-// CitationDataManager.items is an array of { zoteroItem, citationItem } objects,
-// where zoteroItem is Zotero.Item and citationItem is a citation item provided by io.
-// They are stored as a pair to make it easier to access both item properties (e.g. item.getDisplayTitle())
-// and properties of citation item (e.g. locator) across different components.
+// CitationDataManager.items is an array of BubbleItem objects, which maps
+// directly to the bubbles in the citation.
 //
 const CitationDataManager = {
 	items: [],
 	itemAddedCache: new Set(),
 
-	getCitationItems() {
-		return this.items.map(item => item.citationItem);
+	getCitedLibraryItemIDs() {
+		return new Set(this.items.map(item => item.item.id).filter(id => id));
+	},
+
+	getSelectedIDs() {
+		return this.items.filter(item => item.selected).map(item => item.id);
 	},
 
 	getItem({ dialogReferenceID }) {
-		return this.items.find(item => item.dialogReferenceID === dialogReferenceID);
+		return this.items.find(bubbleItem => bubbleItem.dialogReferenceID === dialogReferenceID);
 	},
 
-	getItems({ zoteroItemID }) {
-		return this.items.filter(item => item.zoteroItem.id === zoteroItemID);
+	getItems({ itemID }) {
+		return this.items.filter(bubbleItem => bubbleItem.item.id === itemID);
 	},
 
 	getItemIndex({ dialogReferenceID }) {
@@ -1454,28 +1542,28 @@ const CitationDataManager = {
 
 	updateItemAddedCache() {
 		this.itemAddedCache = new Set();
-		for (let { zoteroItem } of this.items) {
-			if (!zoteroItem.id) continue;
-			this.itemAddedCache.add(zoteroItem.id);
+		for (let bubbleItem of this.items) {
+			if (!bubbleItem.item.id) continue;
+			this.itemAddedCache.add(bubbleItem.item.id.id);
 		}
 	},
-	
-	// Include specified items into the citation
-	async addItems({ citationItems = [], index = null }) {
-		for (let item of citationItems) {
-			let zoteroItem = this._citationItemToZoteroItem(item);
-			// Add a new ID to our citation item and set the same ID on the bubble
-			// so we have a reliable way to identify which bubble refers to which citationItem.
-			let dialogReferenceID = Zotero.Utilities.randomString(5);
-			let toInsert = { citationItem: item, zoteroItem: zoteroItem, dialogReferenceID };
+ 	
+	/**
+	 * Include specified items into the citation.
+
+	 */
+	async addItems({ bubbleItems = [], index = null }) {
+		for (let bubbleItem of bubbleItems) {
 			if (index !== null) {
-				this.items.splice(index, 0, toInsert);
+				this.items.splice(index, 0, bubbleItem);
 				index += 1;
 			}
 			else {
-				this.items.push(toInsert);
+				this.items.push(bubbleItem);
 			}
 		}
+		// No sorting happens when citing notes, since the dialog is accepted right after
+		if (isCitingNotes) return;
 		await this.sort();
 		this.updateItemAddedCache();
 	},
@@ -1499,33 +1587,7 @@ const CitationDataManager = {
 
 	// Update io citation object based on Citation.items array
 	updateCitationObject(final = false) {
-		let result = [];
-		for (let item of this.items) {
-			let dialogReferenceID = item.dialogReferenceID;
-			item = item.citationItem;
-			if (item instanceof Zotero.Item) {
-				let ioResult = { id: item.cslItemID || item.id };
-				if (typeof ioResult.id === "string" && ioResult.id.indexOf("/") !== -1) {
-					let item = Zotero.Cite.getItem(ioResult.id);
-					ioResult.uris = item.cslURIs;
-					ioResult.itemData = item.cslItemData;
-				}
-				ioResult.label = item.label || null;
-				ioResult.locator = item.locator || null;
-				ioResult.prefix = item.prefix || null;
-				ioResult.suffix = item.suffix || null;
-				ioResult['suppress-author'] = item["suppress-author"] || null;
-				result.push(ioResult);
-			}
-			else {
-				result.push(item);
-			}
-			if (!final) {
-				result[result.length - 1].dialogReferenceID = dialogReferenceID;
-			}
-		}
-		io.citation.citationItems = result;
-
+		io.citation.citationItems = this.items.map(item => item.getCitationItem({ includeDialogReferenceID: !final }));
 		if (final && io.sortable) {
 			io.citation.properties.unsorted = !_id("keepSorted").checked;
 		}
@@ -1546,54 +1608,24 @@ const CitationDataManager = {
 	
 	// Construct citation upon initial load
 	async buildCitation() {
+		let citationItems = [];
 		if (!io.citation.properties.unsorted
 				&& _id("keepSorted").checked
 				&& io.citation.sortedItems?.length) {
-			await this.addItems({ citationItems: io.citation.sortedItems.map(entry => entry[1]) });
+			citationItems = io.citation.sortedItems.map(entry => entry[1]);
 		}
 		else {
-			await this.addItems({ citationItems: io.citation.citationItems });
-		}
-	},
-
-	// Check if two given items are the same to prevent an item being inserted more
-	// than once into the citation. Compare firstCreator and title fields, instead of just
-	// itemIDs to account for cited items that may not have ids.
-	potentialDuplicateExists(targetZoteroItem) {
-		if (!(targetZoteroItem instanceof Zotero.Item)) {
-			targetZoteroItem = this._citationItemToZoteroItem(targetZoteroItem);
-		}
-		for (let item of this.items) {
-			let sameCreator = item.zoteroItem.getField("firstCreator") === targetZoteroItem.getField("firstCreator");
-			let sameTitle = item.zoteroItem.getDisplayTitle() === targetZoteroItem.getDisplayTitle();
-			if (sameCreator && sameTitle) return true;
+			citationItems = io.citation.citationItems;
 		}
-		return false;
+		let bubbleItems = citationItems.map(item => BubbleItem.fromCitationItem(item));
+		await this.addItems({ bubbleItems });
 	},
-
-	// check if items have the same id, comparing .cslItemID for cited items or .id for
-	// usual items
-	_itemsHaveSameID(itemOne, itemTwo) {
-		let itemOneID = itemOne.cslItemID || itemOne.id;
-		let itemTwoID = itemTwo.cslItemID || itemTwo.id;
-		if (!itemOneID || !itemTwoID) return false;
-		return itemOneID == itemTwoID;
-	},
-
-	// Shortcut to fetch Zotero.Item based on citationItem
-	_citationItemToZoteroItem(citationItem) {
-		if (citationItem instanceof Zotero.Item) return citationItem;
-		if (io.customGetItem) {
-			let item = io.customGetItem(citationItem);
-			if (item) return item;
-		}
-		if (citationItem.id) {
-			return Zotero.Cite.getItem(citationItem.id);
-		}
-		return null;
-	}
 };
 
+// Explicitly expose singletons to global window for tests
+window.CitationDataManager = CitationDataManager;
+window.IOManager = IOManager;
+
 // Top level listeners
 window.addEventListener("load", onLoad);
 window.addEventListener("unload", onUnload);
diff --git a/chrome/content/zotero/integration/citationDialog/helpers.mjs b/chrome/content/zotero/integration/citationDialog/helpers.mjs
index b5d4317b001..c1034abc96d 100644
--- a/chrome/content/zotero/integration/citationDialog/helpers.mjs
+++ b/chrome/content/zotero/integration/citationDialog/helpers.mjs
@@ -282,13 +282,13 @@ export class CitationDialogHelpers {
 		return height + margins + border;
 	}
 
-	buildBubbleString({ citationItem, zoteroItem }) {
+	buildBubbleString(bubbleItem) {
 		// Creator
 		var title;
-		var str = zoteroItem.getField("firstCreator");
+		var str = bubbleItem.item.getField("firstCreator");
 		
 		// Title, if no creator (getDisplayTitle in order to get case, e-mail, statute which don't have a title field)
-		title = zoteroItem.getDisplayTitle();
+		title = bubbleItem.item.getDisplayTitle();
 		title = title.substr(0, 32) + (title.length > 32 ? "…" : "");
 		if (!str && title) {
 			str = Zotero.getString("punctuation.openingQMark") + title + Zotero.getString("punctuation.closingQMark");
@@ -298,32 +298,32 @@ export class CitationDialogHelpers {
 		}
 		
 		// Date
-		var date = zoteroItem.getField("date", true, true);
+		var date = bubbleItem.item.getField("date", true, true);
 		if (date && (date = date.substr(0, 4)) !== "0000") {
 			str += ", " + parseInt(date);
 		}
 		
 		// Locator
-		if (citationItem.locator) {
+		if (bubbleItem.locator) {
 			// Try to fetch the short form of the locator label. E.g. "p." for "page"
 			// If there is no locator label, default to "page" for now
-			let label = (Zotero.Cite.getLocatorString(citationItem.label || 'page', 'short') || '').toLocaleLowerCase();
+			let label = (Zotero.Cite.getLocatorString(bubbleItem.label || 'page', 'short') || '').toLocaleLowerCase();
 			
-			str += `, ${label} ${citationItem.locator}`;
+			str += `, ${label} ${bubbleItem.locator}`;
 		}
 		
 		// Prefix
-		if (citationItem.prefix && Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP) {
-			let prefix = citationItem.prefix.substr(0, 10) + (citationItem.prefix.length > 10 ? "…" : "");
+		if (bubbleItem.prefix && Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP) {
+			let prefix = bubbleItem.prefix.substr(0, 10) + (bubbleItem.prefix.length > 10 ? "…" : "");
 			str = prefix
-				+ (Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP.test(citationItem.prefix) ? " " : "")
+				+ (Zotero.CiteProc.CSL.ENDSWITH_ROMANESQUE_REGEXP.test(bubbleItem.prefix) ? " " : "")
 				+ str;
 		}
 		
 		// Suffix
-		if (citationItem.suffix && Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP) {
-			let suffix = citationItem.suffix.substr(0, 10) + (citationItem.suffix.length > 10 ? "…" : "");
-			str += (Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP.test(citationItem.suffix) ? " " : "") + suffix;
+		if (bubbleItem.suffix && Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP) {
+			let suffix = bubbleItem.suffix.substr(0, 10) + (bubbleItem.suffix.length > 10 ? "…" : "");
+			str += (Zotero.CiteProc.CSL.STARTSWITH_ROMANESQUE_REGEXP.test(bubbleItem.suffix) ? " " : "") + suffix;
 		}
 		
 		return str;
diff --git a/chrome/content/zotero/integration/citationDialog/popupHandler.mjs b/chrome/content/zotero/integration/citationDialog/popupHandler.mjs
index fd065409f17..9c5c7844322 100644
--- a/chrome/content/zotero/integration/citationDialog/popupHandler.mjs
+++ b/chrome/content/zotero/integration/citationDialog/popupHandler.mjs
@@ -30,8 +30,7 @@ export class CitationDialogPopupsHandler {
 	constructor({ doc }) {
 		this.doc = doc;
 
-		this.item = null;
-		this.citationItem = null;
+		this.bubbleItem = null;
 		this.discardItemDetailsEdits = false;
 		this.itemDetailsWhenOpened = {};
 		this.itemDetailsTimeOpened = null;
@@ -60,7 +59,7 @@ export class CitationDialogPopupsHandler {
 			let event = new CustomEvent("delete-item", {
 				bubbles: true,
 				detail: {
-					dialogReferenceID: this.dialogReferenceID
+					dialogReferenceID: this.bubbleItem.dialogReferenceID
 				}
 			});
 			this.doc.dispatchEvent(event);
@@ -71,7 +70,7 @@ export class CitationDialogPopupsHandler {
 		this._getNode("#itemDetails .show").addEventListener("click", (_) => {
 			this.discardItemDetailsEdits = true;
 			this._getNode("#itemDetails").hidePopup();
-			Zotero.Utilities.Internal.showInLibrary(this.item);
+			Zotero.Utilities.Internal.showInLibrary(this.bubbleItem.item);
 		});
 		this._getNode("#itemDetails .done").addEventListener("click", (_) => {
 			this._getNode("#itemDetails").hidePopup();
@@ -88,20 +87,18 @@ export class CitationDialogPopupsHandler {
 		this._getNode("#itemDetails").addEventListener("keypress", this.handleItemDetailsKeypress.bind(this));
 	}
 
-	openItemDetails(dialogReferenceID, item, citationItem, itemDescription) {
-		this.item = item;
-		this.citationItem = citationItem;
-		this.dialogReferenceID = dialogReferenceID;
+	openItemDetails(bubbleItem, itemDescription) {
+		this.bubbleItem = bubbleItem;
 		// record initial properties when popup is opened to be able to discard edits on Escape
 		this.itemDetailsWhenOpened = {
-			label: citationItem.label,
-			locator: citationItem.locator,
-			prefix: citationItem.prefix,
-			suffix: citationItem.suffix,
-			suppressAuthor: citationItem["suppress-author"]
+			label: bubbleItem.label,
+			locator: bubbleItem.locator,
+			prefix: bubbleItem.prefix,
+			suffix: bubbleItem.suffix,
+			suppressAuthor: bubbleItem.suppressAuthor
 		};
 
-		let bubble = this._getNode(`[dialogReferenceID='${dialogReferenceID}']`);
+		let bubble = this._getNode(`[dialogReferenceID='${this.bubbleItem.dialogReferenceID}']`);
 		let bubbleRect = bubble.getBoundingClientRect();
 		let popup = this._getNode("#itemDetails");
 		popup.openPopup(bubble, "after_start", 0, 4, false, false, null);
@@ -120,21 +117,21 @@ export class CitationDialogPopupsHandler {
 				this._getNode("#label").appendChild(option);
 			}
 		}
-		this._getNode("#itemDetails .show").hidden = !this.item.id;
+		this._getNode("#itemDetails .show").hidden = !this.bubbleItem.item.id;
 
 		// Add header and fill inputs with their values
 		let description = itemDescription;
 		this._getNode("#itemDetails").querySelector(".description")?.remove();
-		this._getNode("#itemTitle").textContent = this.item.getDisplayTitle();
+		this._getNode("#itemTitle").textContent = this.bubbleItem.item.getDisplayTitle();
 		this._getNode("#itemTitle").after(description);
-		let dataTypeLabel = this.item.getItemTypeIconName(true);
+		let dataTypeLabel = this.bubbleItem.item.getItemTypeIconName(true);
 		this._getNode("#itemDetails").querySelector(".icon").setAttribute("data-item-type", dataTypeLabel);
 
-		this._getNode("#label").value = this.citationItem.label || "page";
-		this._getNode("#locator").value = this.citationItem.locator || "";
-		this._getNode("#prefix").value = this.citationItem.prefix || "";
-		this._getNode("#suffix").value = this.citationItem.suffix || "";
-		this._getNode("#suppress-author").checked = !!this.citationItem["suppress-author"];
+		this._getNode("#label").value = this.bubbleItem.label || "page";
+		this._getNode("#locator").value = this.bubbleItem.locator || "";
+		this._getNode("#prefix").value = this.bubbleItem.prefix || "";
+		this._getNode("#suffix").value = this.bubbleItem.suffix || "";
+		this._getNode("#suppress-author").checked = !!this.bubbleItem.suppressAuthor;
 		bubble.classList.add("showingDetails");
 		this.itemDetailsTimeOpened = (new Date()).getTime();
 	}
@@ -158,17 +155,17 @@ export class CitationDialogPopupsHandler {
 
 	// When item details popup is closed, sync it's data to citationItems
 	handleItemDetailsClosure() {
-		let bubble = this._getNode(`[dialogReferenceID='${this.dialogReferenceID}']`);
+		let bubble = this._getNode(`[dialogReferenceID='${this.bubbleItem.dialogReferenceID}']`);
 		if (!bubble) return;
 		bubble.classList.remove("showingDetails");
 		// Restore properties to what they were when popup opened
 		if (this.discardItemDetailsEdits) {
 			this.discardItemDetailsEdits = false;
-			this.citationItem.label = this.itemDetailsWhenOpened.label;
-			this.citationItem.locator = this.itemDetailsWhenOpened.locator;
-			this.citationItem.prefix = this.itemDetailsWhenOpened.prefix;
-			this.citationItem.suffix = this.itemDetailsWhenOpened.suffix;
-			this.citationItem["suppress-author"] = this.itemDetailsWhenOpened.suppressAuthor;
+			this.bubbleItem.label = this.itemDetailsWhenOpened.label;
+			this.bubbleItem.locator = this.itemDetailsWhenOpened.locator;
+			this.bubbleItem.prefix = this.itemDetailsWhenOpened.prefix;
+			this.bubbleItem.suffix = this.itemDetailsWhenOpened.suffix;
+			this.bubbleItem.suppressAuthor = this.itemDetailsWhenOpened.suppressAuthor;
 			this.itemDetailsWhenOpened = {};
 			this.notifyCitationDialogOfChange();
 		}
@@ -191,11 +188,11 @@ export class CitationDialogPopupsHandler {
 
 	// Update item details and notify citation dialog about changes
 	handleItemDetailsChange() {
-		this.citationItem.label = this._getNode("#locator").value ? this._getNode("#label").value : null;
-		this.citationItem.locator = this._getNode("#locator").value;
-		this.citationItem.prefix = this._getNode("#prefix").value;
-		this.citationItem.suffix = this._getNode("#suffix").value;
-		this.citationItem["suppress-author"] = this._getNode("#suppress-author").checked;
+		this.bubbleItem.label = this._getNode("#locator").value ? this._getNode("#label").value : null;
+		this.bubbleItem.locator = this._getNode("#locator").value;
+		this.bubbleItem.prefix = this._getNode("#prefix").value;
+		this.bubbleItem.suffix = this._getNode("#suffix").value;
+		this.bubbleItem.suppressAuthor = this._getNode("#suppress-author").checked;
 		this.notifyCitationDialogOfChange();
 	}
 
@@ -204,7 +201,7 @@ export class CitationDialogPopupsHandler {
 		let event = new CustomEvent("item-details-updated", {
 			bubbles: true,
 			detail: {
-				dialogReferenceID: this.dialogReferenceID
+				dialogReferenceID: this.bubbleItem.dialogReferenceID
 			}
 		});
 		this.doc.dispatchEvent(event);
diff --git a/chrome/content/zotero/integration/citationDialog/searchHandler.mjs b/chrome/content/zotero/integration/citationDialog/searchHandler.mjs
index 8aac1d19584..365cab40b5e 100644
--- a/chrome/content/zotero/integration/citationDialog/searchHandler.mjs
+++ b/chrome/content/zotero/integration/citationDialog/searchHandler.mjs
@@ -91,10 +91,9 @@ export class CitationDialogSearchHandler {
 	// by the number of results in each library.
 	// Items/notes in the libraries group are sorted via _createItemsSort/_createNotesSort comparators.
 	// Takes citedItems as a parameter to filter them out from Selected, Opened and Cited groups.
-	getOrderedSearchResultGroups(citedItems = []) {
+	getOrderedSearchResultGroups(citedItemIDs = new Set()) {
 		let removeItemsIncludedInCitation = (items) => {
-			let citedItemsIDs = new Set(citedItems.map(item => item.cslItemID || item.id));
-			return items.filter(i => !citedItemsIDs.has(i.cslItemID ? i.cslItemID : i.id));
+			return items.filter(i => !citedItemIDs.has(i.cslItemID ? i.cslItemID : i.id));
 		};
 		let result = [];
 		// selected/open/cited go first
@@ -184,6 +183,7 @@ export class CitationDialogSearchHandler {
 		else {
 			this.results.cited = this.searchValue ? this._filterNonMatchingItems(this.citedItems) : [];
 		}
+		this._deduplicate();
 	}
 
 	// clear selected/open items cache to re-fetch those items
@@ -219,13 +219,16 @@ export class CitationDialogSearchHandler {
 
 	// make sure that each item appears only in one group.
 	// Items that are selected are removed from opened.
-	// Items that are selected or opened are removed from library results.
+	// Items that are selected or opened are removed from cited.
+	// Items that are selected or opened or cited are removed from library results.
 	_deduplicate() {
 		let selectedIDs = new Set(this.results.selected.map(item => item.id));
 		let openIDs = new Set(this.results.open.map(item => item.id));
+		let citedIDs = new Set(this.results.cited.map(item => item.id));
 
 		this.results.open = this.results.open.filter(item => !selectedIDs.has(item.id));
-		this.results.found = this.results.found.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id));
+		this.results.cited = this.results.cited.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id));
+		this.results.found = this.results.found.filter(item => !selectedIDs.has(item.id) && !openIDs.has(item.id) && !citedIDs.has(item.id));
 	}
 		
 	// Run the actual search query and find all items matching query across all libraries
diff --git a/chrome/content/zotero/xpcom/editorInstance.js b/chrome/content/zotero/xpcom/editorInstance.js
index affd0253d26..9b3f53a184a 100644
--- a/chrome/content/zotero/xpcom/editorInstance.js
+++ b/chrome/content/zotero/xpcom/editorInstance.js
@@ -1079,6 +1079,10 @@ class EditorInstance {
 				else if (!citationItem.id && citationItem.itemData) {
 					let item = new Zotero.Item();
 					Zotero.Utilities.itemFromCSLJSON(item, citationItem.itemData);
+					// Add csl data in the same format as in Zotero.Integration.Citation.loadItemData
+					item.cslItemID = citationItem.id;
+					item.cslURIs = citationItem.uris;
+					item.cslItemData = citationItem.itemData;
 					return item;
 				}
 				// Otherwise returns `undefined` which makes this function to be