citation dlg: data structure refactor, bug fixes

- replace objects with zoteroItem, citationItem, etc.
as the main representation of an entry in the citation
(CitationDataManager.items) with instances of a new BubbleItem class.
BubbleItem has two static methods to create
a new instance: BubbleItem.fromCitationItem() used
to create entries from io.citation.citationItem on load,
and BubbleItem.fromItem used to create entries from
Zotero.Item when a new item is being added into the citation.
BubbleItem.getCitationItem will return a citationItem
in a format that citeproc consumes. This streamlines
the flow of data, makes it easier to manage and avoids
unnecessary conversions between citationItem and Zotero.Item.
- This refactoring fixes the issue of added suffix/prefix
sticking to an item and persisting even if the bubble
is removed and re-added. Fixes: #5282
- add csl data to virtual item in io.customGetItem
to remain consistent with Zotero.Integration.Citation.loadItemData.
That way, csl data is always found on Zotero.Item
and we don't have to check citationItems.

Also:
- apply deduplication to cited items, so that a cited
item from the library that appears in selected or
opened section does not have a duplicate entry in
the cited section.
- added initial test suite for citation dialog
This commit is contained in:
Bogdan Abaev 2025-05-16 09:38:18 -07:00 • committed by Adomas Ven
parent 086b0c3d84
commit dfc31d9961
7 changed files with 654 additions and 189 deletions

View file

@ -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);
}

View file

@ -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 });
item.updateBubbleString();
}
_id("bubble-input").refresh(CitationDataManager.items);
_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 });
citationItems = io.citation.citationItems;
}
let bubbleItems = citationItems.map(item => BubbleItem.fromCitationItem(item));
await this.addItems({ bubbleItems });
},
// 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;
}
return false;
},
// 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);

View file

@ -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;

View file

@ -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();
@ -86,20 +85,18 @@ export class CitationDialogPopupsHandler {
}, true);
}
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);
@ -118,21 +115,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();
}
@ -156,17 +153,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();
}
@ -182,11 +179,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();
}
@ -195,7 +192,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);

View file

@ -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

View file

@ -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

View file

@ -5,6 +5,7 @@ describe("Citation Dialog", function () {
sort() {},
sortable: false,
citation: {
citationItems: [],
properties: {
unsorted: false,
}
@ -13,7 +14,7 @@ describe("Citation Dialog", function () {
return [];
}
};
let dialog, win;
let dialog, win, IOManager, CitationDataManager, SearchHandler;
before(async function () {
// one of helper functions of searchHandler uses zotero pane
@ -21,11 +22,22 @@ describe("Citation Dialog", function () {
let dialogPromise = waitForWindow("chrome://zotero/content/integration/citationDialog.xhtml");
Services.ww.openWindow(null, "chrome://zotero/content/integration/citationDialog.xhtml", "", "", io);
dialog = await dialogPromise;
IOManager = dialog.IOManager;
CitationDataManager = dialog.CitationDataManager;
SearchHandler = dialog.SearchHandler;
// wait for everything (e.g. itemTree/collectionTree) inside of the dialog to be loaded.
// it is not used currently but may be required when more complex tests are added
// while (!dialog.loaded) {
// await Zotero.Promise.delay(10);
// }
while (!dialog.loaded) {
await Zotero.Promise.delay(10);
}
});
beforeEach(async function () {
// Many operations (e.g. IOManager.addItemsToCitation) are disabled
// when search runs. Search can be triggered by a variety of events
// so before each test, we make sure that search has finished running
while (SearchHandler.searching) {
await Zotero.Promise.delay(10);
}
});
after(function () {
@ -33,6 +45,424 @@ describe("Citation Dialog", function () {
win.close();
});
describe("Manage entries in the citation", function () {
let citedItemNotInLibrary = {
id: "o7HzMbH6/6iMXHx6s",
itemData: {
id: "o7HzMbH6/6iMXHx6s",
type: "book",
title: "cited_not_in_library_test_title",
author: [
{
family: "Last",
given: "First"
}
]
},
uris: [
"http://zotero.org/users/11573780/items/K22KNVZL"
],
item: {
id: "o7HzMbH6/6iMXHx6s",
type: "book",
title: "cited_not_in_library_test_title",
author: [
{
family: "Last",
given: "First"
}
],
"title-main": "cited_not_in_library_test_title",
"title-sub": "",
"title-subjoin": ""
},
label: undefined,
locator: undefined,
prefix: undefined,
suffix: undefined,
"suppress-author": undefined
};
let citedItemOne = {
id: null,
item: {
id: null,
type: "book",
title: "cited_in_library_test_title",
author: [
{
family: "Last",
given: "First"
}
],
"title-main": "cited_in_library_test_title",
"title-sub": "",
"title-subjoin": ""
},
label: undefined,
locator: undefined,
prefix: undefined,
suffix: undefined,
"suppress-author": undefined
};
let itemOne, itemTwo, bubbleInput, ZoteroCiteGetItemStub, surrogateCitedItem;
before(async function () {
bubbleInput = dialog.document.querySelector("bubble-input");
// Virtual Zotero.Item for a cited item that does not exist in the library.
// Same logic as in Zotero.Integration.Citation.loadItemData.
surrogateCitedItem = new Zotero.Item();
Zotero.Utilities.itemFromCSLJSON(surrogateCitedItem, citedItemNotInLibrary.itemData);
surrogateCitedItem.cslItemID = citedItemNotInLibrary.id;
surrogateCitedItem.cslURIs = citedItemNotInLibrary.uris;
surrogateCitedItem.cslItemData = citedItemNotInLibrary.itemData;
// Zotero.Cite.getItem called with citedItemNotInLibrary returns virtual Zotero.Item from above
ZoteroCiteGetItemStub = sinon.stub(Zotero.Cite, 'getItem').callsFake(function (id) {
if (id === citedItemNotInLibrary.id) {
return surrogateCitedItem;
}
return Zotero.Items.get(id);
});
itemOne = await createDataObject('item', { title: "one" });
itemOne.setCreators([
{
firstName: "First_One",
lastName: "Last_One",
creatorType: "author"
}
]);
await itemOne.saveTx();
// citedItemOne is an earlier cited itemOne
citedItemOne.id = itemOne.id;
citedItemOne.item.id = itemOne.id;
itemTwo = await createDataObject('item', { title: "two" });
itemTwo.setCreators([
{
firstName: "First_Two",
lastName: "Last_Two",
creatorType: "author"
}
]);
await itemTwo.saveTx();
});
after(function () {
ZoteroCiteGetItemStub.restore();
});
beforeEach(function () {
io.citation.citationItems = [];
io.citation.sortable = false;
dialog.document.getElementById("keepSorted").checked = false;
io.sort = () => {};
CitationDataManager.items = [];
IOManager.updateBubbleInput();
});
it("should add an item to citation", async function () {
await IOManager.addItemsToCitation([itemOne]);
let bubbles = bubbleInput.getAllBubbles();
assert.equal(CitationDataManager.items.length, 1);
assert.equal(bubbles.length, 1);
assert.equal(bubbles[0].textContent, itemOne.getCreator(0).lastName);
});
it("should remove an item from citation", async function () {
await IOManager.addItemsToCitation([itemOne, itemTwo]);
let bubbles = bubbleInput.getAllBubbles();
assert.equal(bubbles.length, 2);
let firstBubbleItem = CitationDataManager.items[0];
IOManager._deleteItem(firstBubbleItem.dialogReferenceID);
bubbles = bubbleInput.getAllBubbles();
assert.equal(CitationDataManager.items.length, 1);
assert.equal(CitationDataManager.items[0].id, itemTwo.id);
assert.equal(bubbles.length, 1);
assert.equal(bubbles[0].textContent, itemTwo.getCreator(0).lastName);
});
it("should build citation with a cited item in library", async function () {
io.citation.citationItems = [citedItemOne];
await CitationDataManager.buildCitation();
IOManager.updateBubbleInput();
let bubbles = bubbleInput.getAllBubbles();
assert.equal(CitationDataManager.items.length, 1);
assert.equal(bubbles.length, 1);
assert.equal(bubbles[0].textContent, itemOne.getCreator(0).lastName);
});
it("should build citation with a cited item not in library", async function () {
io.citation.citationItems = [citedItemNotInLibrary];
await CitationDataManager.buildCitation();
IOManager.updateBubbleInput();
let bubbles = bubbleInput.getAllBubbles();
assert.equal(CitationDataManager.items.length, 1);
assert.equal(bubbles.length, 1);
assert.equal(bubbles[0].textContent, surrogateCitedItem.getCreator(0).lastName);
});
it("should add a locator/suffix/prefix to a bubble", async function () {
// add two bubbles for the same item
await IOManager.addItemsToCitation([itemOne, itemOne]);
assert.equal(CitationDataManager.items.length, 2);
// open popup
let firstBubble = CitationDataManager.items[0];
IOManager._openItemDetailsPopup(firstBubble.dialogReferenceID);
let popup = dialog.document.getElementById("itemDetails");
// give the popup time to open
await Zotero.Promise.delay(50);
assert.equal(popup.state, "open");
// set locator/suffix/prefix values
popup.querySelector("#locator").value = "10";
popup.querySelector("#suffix").value = "suffix";
popup.querySelector("#prefix").value = "prefix";
popup.querySelector("#prefix").dispatchEvent(new Event('input', { bubbles: true }));
// make sure they are set on the bubbleItem
assert.equal(firstBubble.locator, "10");
assert.equal(firstBubble.suffix, "suffix");
assert.equal(firstBubble.prefix, "prefix");
let bubble = dialog.document.querySelector(`.bubble[dialogReferenceID="${firstBubble.dialogReferenceID}"]`);
assert.equal(bubble.textContent, "prefix Last_One, p. 10 suffix");
// make sure the other bubbleItem is not affected
let secondBubble = CitationDataManager.items[1];
assert.notOk(secondBubble.locator);
assert.notOk(secondBubble.suffix);
assert.notOk(secondBubble.prefix);
});
it("should change the order of bubbles", async function () {
// add two items
await IOManager.addItemsToCitation([itemOne, itemTwo]);
let bubbleItemOne = CitationDataManager.items[0];
let bubbleItemTwo = CitationDataManager.items[1];
// check initial order
let bubbles = bubbleInput.getAllBubbles();
assert.equal(bubbles[0].getAttribute("dialogReferenceID"), bubbleItemOne.dialogReferenceID);
assert.equal(bubbles[1].getAttribute("dialogReferenceID"), bubbleItemTwo.dialogReferenceID);
// move the second item to the first position
IOManager._moveItem(bubbleItemTwo.dialogReferenceID, 0);
// ensure the order is correct
assert.equal(CitationDataManager.items[0].dialogReferenceID, bubbleItemTwo.dialogReferenceID);
assert.equal(CitationDataManager.items[1].dialogReferenceID, bubbleItemOne.dialogReferenceID);
bubbles = dialog.document.querySelector("bubble-input").getAllBubbles();
assert.equal(bubbles[0].getAttribute("dialogReferenceID"), bubbleItemTwo.dialogReferenceID);
assert.equal(bubbles[1].getAttribute("dialogReferenceID"), bubbleItemOne.dialogReferenceID);
});
it("should sort the citation", async function () {
// Make dialog sortable
io.citation.sortable = true;
dialog.document.getElementById("keepSorted").checked = true;
// Mock sort.io implementation that sorts itemOne to the first position
io.sort = () => {
let items = io.citation.citationItems;
items.sort((a, b) => {
if (a.id === itemOne.id) return -1;
if (b.id === itemOne.id) return 1;
return 0;
});
io.citation.sortedItems = [
[null, items[0]],
[null, items[1]]
];
};
// Add items to citation in wrong order
await IOManager.addItemsToCitation([itemTwo, itemOne]);
// Make sure the bubbleItems are sorted with itemOne being first
let firstBubbleItem = CitationDataManager.items[0];
let secondBubbleItem = CitationDataManager.items[1];
assert.equal(firstBubbleItem.id, itemOne.id);
assert.equal(secondBubbleItem.id, itemTwo.id);
let bubbles = bubbleInput.getAllBubbles();
assert.equal(bubbles[0].getAttribute("dialogReferenceID"), firstBubbleItem.dialogReferenceID);
assert.equal(bubbles[1].getAttribute("dialogReferenceID"), secondBubbleItem.dialogReferenceID);
});
it("should update io.citation.items from bubbles", async function () {
let bubbleItems = CitationDataManager.items;
// Build citation with several cited items
io.citation.citationItems = [citedItemOne, citedItemNotInLibrary];
await CitationDataManager.buildCitation();
// Add another item
await IOManager.addItemsToCitation([itemTwo], { index: 2 });
// Add modifications
bubbleItems[0].label = "page";
bubbleItems[0].locator = "10";
bubbleItems[1].prefix = "prefix";
bubbleItems[1].suffix = "suffix";
bubbleItems[2].suppressAuthor = true;
// Update io.citation.items and make sure it looks right
CitationDataManager.updateCitationObject(true);
let expected = [
{
id: itemOne.id,
locator: "10",
label: "page"
},
{
id: citedItemNotInLibrary.id,
suffix: "suffix",
prefix: "prefix",
itemData: citedItemNotInLibrary.itemData,
uris: citedItemNotInLibrary.uris,
},
{
id: itemTwo.id,
"suppress-author": true
}
];
assert.deepEqual(io.citation.citationItems, expected);
});
});
describe("UI", function () {
beforeEach(function () {
CitationDataManager.items = [];
IOManager.updateBubbleInput();
});
it("should switch dialog mode", async function () {
IOManager.toggleDialogMode("list");
assert.isFalse(dialog.document.getElementById("list-layout").hidden);
assert.isTrue(dialog.document.getElementById("library-layout").hidden);
IOManager.toggleDialogMode("library");
assert.isFalse(dialog.document.getElementById("library-layout").hidden);
assert.isTrue(dialog.document.getElementById("list-layout").hidden);
});
it("should highlight bubbles whose items are selected", async function () {
let itemOne = await createDataObject('item');
let itemTwo = await createDataObject('item');
IOManager.toggleDialogMode("library");
await IOManager.addItemsToCitation([itemOne, itemTwo]);
// Select row of the first bubble
await dialog.libraryLayout.itemsView.selectItem(itemOne.id);
// Check that the bubble is highlighted
let bubbleOne = dialog.document.querySelector(`.bubble[dialogReferenceID="${CitationDataManager.items[0].dialogReferenceID}"]`);
assert.isTrue(bubbleOne.classList.contains("has-item-selected"));
// Check the other bubble is unaffected
let bubbleTwo = dialog.document.querySelector(`.bubble[dialogReferenceID="${CitationDataManager.items[1].dialogReferenceID}"]`);
assert.isFalse(bubbleTwo.classList.contains("has-item-selected"));
});
it("should highlight rows of items in the citation", async function () {
let itemOne = await createDataObject('item');
IOManager.toggleDialogMode("library");
// Add the item to citation
await IOManager.addItemsToCitation([itemOne]);
// Select the row in itemTree, so it is visible
await dialog.libraryLayout.itemsView.selectItem(itemOne.id);
// Make sure the row node is highlighted
let rowIndex = dialog.libraryLayout.itemsView.getRowIndexByID(itemOne.id);
let rowID = "item-tree-citationDialog-row-" + rowIndex;
let rowNode = dialog.document.getElementById(rowID);
assert.isTrue(rowNode.classList.contains("highlighted"));
});
});
describe("Search", function () {
let selectedOne, selectedTwo, openOne, openTwo, selectedAndOpenOne, citedOne, citedAndOpenOne, libraryOne, libraryTwo;
before(async function () {
selectedOne = await createDataObject('item', { title: "one_selected" });
selectedTwo = await createDataObject('item', { title: "two_selected" });
openOne = await createDataObject('item', { title: "one_open" });
openTwo = await createDataObject('item', { title: "two_open" });
selectedAndOpenOne = await createDataObject('item', { title: "one_selected_open" });
libraryOne = await createDataObject('item', { title: "one_library" });
libraryTwo = await createDataObject('item', { title: "two_library" });
citedOne = await createDataObject('item', { title: "one_cited" });
citedAndOpenOne = await createDataObject('item', { title: "one_open_cited" });
// Present these items are selected/open/cited
SearchHandler.selectedItems = [selectedOne, selectedTwo, selectedAndOpenOne];
SearchHandler.openItems = [openOne, openTwo, selectedAndOpenOne, citedAndOpenOne];
SearchHandler.citedItems = [citedOne, citedAndOpenOne];
});
after(function () {
SearchHandler.openItems = [];
SearchHandler.selectedItems = [];
});
it("should perform search in list mode", async function () {
IOManager.toggleDialogMode("list");
// Search for "one"
await dialog.currentLayout.search("one", { skipDebounce: true });
// Selected items should have both "one_selected" and "one_selected_open"
let selectedIDs = SearchHandler.results.selected.map(item => item.id);
assert.sameMembers(selectedIDs, [selectedOne.id, selectedAndOpenOne.id]);
// Open items should have "one_open" and "one_open_cited" but not "one_selected_open", since it is selected
let openIDs = SearchHandler.results.open.map(item => item.id);
assert.sameMembers(openIDs, [openOne.id, citedAndOpenOne.id]);
// Cited items should have "one_cited"
let citedIDs = SearchHandler.results.cited.map(item => item.id);
assert.sameMembers(citedIDs, [citedOne.id]);
// Library items should have "one_library" but not "two_library", "one_cited", or "one_open_cited"
let libraryIDs = SearchHandler.results.found.map(item => item.id);
assert.include(libraryIDs, libraryOne.id);
assert.notInclude(libraryIDs, libraryTwo.id);
assert.notInclude(libraryIDs, citedOne.id);
// Make sure actual nodes for search matches are rendered
let expectedItemCardIDs = [...selectedIDs, ...openIDs, ...citedIDs, ...libraryIDs];
for (let itemID of expectedItemCardIDs) {
let node = dialog.document.querySelector(`.item[id="${itemID}"]`);
assert.isOk(node);
}
});
it("should perform search in library mode", async function () {
IOManager.toggleDialogMode("library");
// Search for "one"
await dialog.currentLayout.search("one", { skipDebounce: true });
// Selected items should have both "one_selected" and "one_selected_open"
let selectedIDs = SearchHandler.results.selected.map(item => item.id);
assert.sameMembers(selectedIDs, [selectedOne.id, selectedAndOpenOne.id]);
// Open items should have "one_open" and "one_open_cited" but not "one_selected_open", since it is selected
let openIDs = SearchHandler.results.open.map(item => item.id);
assert.sameMembers(openIDs, [openOne.id, citedAndOpenOne.id]);
// Cited items should have "one_cited"
let citedIDs = SearchHandler.results.cited.map(item => item.id);
assert.sameMembers(citedIDs, [citedOne.id]);
// In library mode, library is searched via itemTree, so this should be empty
assert.equal(SearchHandler.results.found.length, 0);
// Make sure actual nodes for search matches are rendered
let expectedItemCardIDs = [...selectedIDs, ...openIDs, ...citedIDs];
for (let itemID of expectedItemCardIDs) {
let node = dialog.document.querySelector(`.item[id="${itemID}"]`);
assert.isOk(node);
}
});
});
describe("Helpers.extractLocator", function () {
let locator;
describe("Invalid locators", function () {