From 8233f36e2fe7ce7b1f0153fe558eb6ecd5a2f078 Mon Sep 17 00:00:00 2001 From: Tom Najdek Date: Sat, 24 Jan 2026 00:15:32 +0100 Subject: [PATCH] Add batch editing support for fields --- .../zotero/elements/collapsibleSection.js | 30 +- .../content/zotero/elements/editableText.js | 73 ++- chrome/content/zotero/elements/itemBox.js | 489 +++++++++++---- chrome/content/zotero/elements/itemDetails.js | 17 + chrome/content/zotero/elements/itemPane.js | 108 +++- .../content/zotero/elements/itemPaneHeader.js | 14 +- .../zotero/modules/optionsAutoComplete.mjs | 82 +++ chrome/content/zotero/xpcom/zotero.js | 7 + chrome/content/zotero/zoteroPane.js | 16 +- chrome/locale/en-US/zotero/zotero.ftl | 15 + chrome/skin/default/zotero/overlay.css | 23 + scss/elements/_editableText.scss | 25 +- scss/elements/_itemPane.scss | 4 + scss/elements/_itemPaneHeader.scss | 9 + test/tests/itemPaneTest.js | 565 ++++++++++++++++++ 15 files changed, 1301 insertions(+), 176 deletions(-) create mode 100644 chrome/content/zotero/modules/optionsAutoComplete.mjs diff --git a/chrome/content/zotero/elements/collapsibleSection.js b/chrome/content/zotero/elements/collapsibleSection.js index 00cbc7b13b..539056063b 100644 --- a/chrome/content/zotero/elements/collapsibleSection.js +++ b/chrome/content/zotero/elements/collapsibleSection.js @@ -43,7 +43,8 @@ set open(newOpen) { newOpen = !!newOpen; let oldOpen = this.open; - if (oldOpen === newOpen || this.empty || !this.collapsible) return; + if (oldOpen === newOpen || this.empty) return; + if (!newOpen && !this.collapsible) return; this.render(); // Force open before getting scrollHeight, so we get the right value @@ -118,9 +119,22 @@ this.setAttribute('no-collapse', val); } } - + + get showContextMenu() { + return !this.getAttribute("no-context-menu"); + } + + set showContextMenu(val) { + if (val) { + this.removeAttribute('no-context-menu'); + } + else { + this.setAttribute('no-context-menu', val); + } + } + static get observedAttributes() { - return ['open', 'empty', 'label', 'summary', 'extra-buttons']; + return ['open', 'empty', 'label', 'summary', 'extra-buttons', 'no-collapse']; } attributeChangedCallback(name) { @@ -348,7 +362,7 @@ } _saveOpenState() { - if (this._disableSavingOpenState) return; + if (this._disableSavingOpenState || this._skipSaveOpenState) return; Zotero.Prefs.set(`panes.${this.dataset.pane}.open`, this.open); } @@ -383,11 +397,11 @@ } get _disableContextMenu() { - return !this._getSidenav() || !!this.closest('annotation-items-pane'); + return !this._getSidenav() || !this.showContextMenu || !!this.closest('annotation-items-pane'); } _handleClick = (event) => { - if (this._disableCollapsing) return; + if (this._disableCollapsing || !this.collapsible) return; if (event.target.closest('.section-custom-button, menupopup')) return; this.open = !this.open; }; @@ -417,7 +431,7 @@ } // Space/Enter toggle section open/closed. // ArrowLeft/ArrowRight on actual header will close/open (depending on locale direction) - if (["ArrowLeft", "ArrowRight", " ", "Enter"].includes(event.key) && !this._disableCollapsing) { + if (["ArrowLeft", "ArrowRight", " ", "Enter"].includes(event.key) && !this._disableCollapsing && this.collapsible) { stopEvent(); this.open = ([" ", "Enter"].includes(event.key)) ? !this.open : (event.key == Zotero.arrowNextKey); event.target.focus(); @@ -480,7 +494,7 @@ this._title.textContent = this.label; this._summary.textContent = this.summary; let twisty = this._head.querySelector('.twisty'); - twisty.hidden = this._disableCollapsing; + twisty.hidden = this._disableCollapsing || !this.collapsible; document.l10n.setAttributes(twisty, `section-button-${this.open ? "collapse" : "expand"}`, { section: this._paneName || "" }); } } diff --git a/chrome/content/zotero/elements/editableText.js b/chrome/content/zotero/elements/editableText.js index 22bf14ade1..e331b857bd 100644 --- a/chrome/content/zotero/elements/editableText.js +++ b/chrome/content/zotero/elements/editableText.js @@ -137,6 +137,14 @@ this.setAttribute('value', value || ''); } + get values() { + return this._values ? this._values : [this.value]; + } + + set values(values) { + this._values = values; + } + get initialValue() { return this._input?.dataset.initialValue ?? ''; } @@ -180,6 +188,23 @@ return this._input; } + get multipleValues() { + return this.hasAttribute('multiple-values'); + } + + set multipleValues(multipleValues) { + this.toggleAttribute('multiple-values', !!multipleValues); + } + + // true if the value is unchanged since the last blur event. Relevant for batch editing. + get cancelled() { + return this.hasAttribute('cancelled'); + } + + set cancelled(cancelled) { + this.toggleAttribute('cancelled', !!cancelled); + } + _resetTextDirection() { this._input?.removeAttribute('dir'); } @@ -275,7 +300,15 @@ } } this._input.readOnly = this.readOnly; - this._input.placeholder = this.placeholder; + if (this.readOnly && this.multipleValues) { + this._input.tabIndex = -1; + } + else { + this._input.removeAttribute('tabindex'); + } + if (!(this.multipleValues && this.focused)) { + this._input.placeholder = this.placeholder; + } if (this._input.tagName == "textarea") { // Reset to initial state @@ -381,6 +414,9 @@ this._ignoredWindowInactiveBlur = false; return; } + + this.cancelled = false; + this._clearValue = false; let valueBeforeFocus = this.value; this.dispatchEvent(new CustomEvent('focus')); @@ -400,7 +436,15 @@ } if (!('initialValue' in this._input.dataset)) { - this._input.dataset.initialValue = this._input.value; + this._input.dataset.initialValue = this.value; + } + + if (this.multipleValues) { + this._input.placeholder = ''; + this._input.value = ''; + if (this._input.mController) { + this._input.mController.startSearch(""); + } } }; @@ -410,12 +454,22 @@ this._ignoredWindowInactiveBlur = true; return; } + + if (this.multipleValues) { + if (this.cancelled || (this._input.value === '' && !this._clearValue)) { + this.value = ''; + this.placeholder = Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'); + this.cancelled = true; + } + } + this.dispatchEvent(new Event('blur')); this._resetStateAfterBlur(); }; _resetStateAfterBlur() { this._ignoredWindowInactiveBlur = false; + this._clearValue = false; this._focusMousedownEvent = null; this.classList.remove('focused'); this._input.scrollLeft = 0; @@ -436,9 +490,14 @@ } } else if (event.key === 'Escape') { - let initialValue = this._input.dataset.initialValue ?? ''; - this.setAttribute('value', initialValue); - this._input.value = initialValue; + if (this.multipleValues) { + this.cancelled = true; + } + else { + let initialValue = this._input.dataset.initialValue ?? ''; + this.setAttribute('value', initialValue); + this._input.value = initialValue; + } this._input.blur(); } }; @@ -460,6 +519,10 @@ }; _handleMouseDown = (event) => { + if (this.readOnly && this.multipleValues) { + event.preventDefault(); + return; + } // Prevent a right-click from focusing the input when unfocused if (event.button === 2 && document.activeElement !== this._input) { event.preventDefault(); diff --git a/chrome/content/zotero/elements/itemBox.js b/chrome/content/zotero/elements/itemBox.js index 7016085b19..02384a0a62 100644 --- a/chrome/content/zotero/elements/itemBox.js +++ b/chrome/content/zotero/elements/itemBox.js @@ -50,6 +50,8 @@ this.eventHandlers = []; this.itemTypeMenu = null; + this._extraItems = []; + this._unionFieldDescriptors = null; this._mode = 'view'; this._visibleFields = []; this._hiddenFields = []; @@ -275,6 +277,19 @@ this.updateCustomRowProperty(rowElem); } } + + get extraItems() { + return this._extraItems; + } + + set extraItems(val) { + if (!Array.isArray(val)) { + return; + } + this._extraItems = val.filter(item => item instanceof Zotero.Item && item.isRegularItem()); + this._unionFieldDescriptors = null; + this._resetRenderedFlags(); + } // .ref is an alias for .item get ref() { @@ -438,8 +453,60 @@ } } + /** + * Compute the union of fields across all selected item types for cross-type + * batch editing. Returns null if all items share the same type (caller should + * fall back to the normal single-type field list). + * + * Each descriptor in the returned array has: + * canonicalName -- base field name if base-mapped, else the original field name + * label -- localized label per the labeling rules + */ + _computeUnionFieldList() { + let allItems = [this.item, ...this._extraItems]; + let allTypeIDs = [...new Set(allItems.map(i => i.itemTypeID))]; + + // Same-type batch -- use normal single-type logic + if (allTypeIDs.length === 1) return null; + + let fieldMap = new Map(); // canonicalName -> descriptor + let orderCounter = 0; + + for (let typeID of allTypeIDs) { + let typeFieldIDs = Zotero.ItemFields.getItemTypeFields(typeID); + for (let fieldID of typeFieldIDs) { + let fieldName = Zotero.ItemFields.getName(fieldID); + + // Resolve to base field name if a mapping exists + let baseID = Zotero.ItemFields.getBaseIDFromTypeAndField(typeID, fieldID); + let canonicalName = baseID + ? Zotero.ItemFields.getName(baseID) + : fieldName; + + if (fieldMap.has(canonicalName)) { + // Shared by multiple types -- use base field label + fieldMap.get(canonicalName).label + = Zotero.ItemFields.getLocalizedString(canonicalName); + } + else { + fieldMap.set(canonicalName, { + canonicalName, + firstOrder: orderCounter++, + // Use type-specific label until another type shares this field + label: Zotero.ItemFields.getLocalizedString(fieldName), + }); + } + } + } + + let result = [...fieldMap.values()]; + result.sort((a, b) => a.firstOrder - b.firstOrder); + return result; + } + _renderInternal() { this._saveFieldFocus(); + this._unionFieldDescriptors = null; delete this._linkMenu.dataset.link; @@ -462,7 +529,9 @@ } // Item type menu - this.addItemTypeMenu(); + if (!this._extraItems?.length) { + this.addItemTypeMenu(); + } this.updateItemTypeMenuSelection(); var fieldNames = []; @@ -473,9 +542,29 @@ } } // Get field order from database + else if (this._extraItems.length) { + // Batch editing -- compute field list + let unionFields = this._computeUnionFieldList(); + if (unionFields) { + // Cross-type batch: use union of fields from all item types + this._unionFieldDescriptors = new Map(); + for (let desc of unionFields) { + fieldNames.push(desc.canonicalName); + this._unionFieldDescriptors.set(desc.canonicalName, desc); + } + } + else { + // Same-type batch: use the shared item type's fields + let fields = Zotero.ItemFields.getItemTypeFields(this.item.getField("itemTypeID")); + for (let i = 0; i < fields.length; i++) { + fieldNames.push(Zotero.ItemFields.getName(fields[i])); + } + } + fieldNames.push("dateAdded", "dateModified"); + } else { var fields = Zotero.ItemFields.getItemTypeFields(this.item.getField("itemTypeID")); - + for (let i = 0; i < fields.length; i++) { fieldNames.push(Zotero.ItemFields.getName(fields[i])); } @@ -496,10 +585,14 @@ continue; } let val = ''; + let extraFieldValues = []; if (fieldName) { var fieldID = Zotero.ItemFields.getID(fieldName); - if (fieldID && !Zotero.ItemFields.isValidForType(fieldID, this.item.itemTypeID)) { + // In cross-type batch mode, union fields are pre-validated + if (!this._unionFieldDescriptors + && fieldID + && !Zotero.ItemFields.isValidForType(fieldID, this.item.itemTypeID)) { fieldName = null; } } @@ -519,11 +612,20 @@ else if (fieldName == 'feed') { val = Zotero.Feeds.get(this.item.libraryID)?.name; } + else if (this._unionFieldDescriptors) { + val = this.item.getField(fieldName, false, true); + extraFieldValues = this._extraItems.map(item => item.getField(fieldName, false, true)); + } else { val = this.item.getField(fieldName); + + if (this._extraItems.length) { + extraFieldValues = this._extraItems.map(item => item.getField(fieldName)); + } } - if (!val && this.hideEmptyFields + if (!val && !extraFieldValues.some(v => v) + && this.hideEmptyFields && this._visibleFields.indexOf(fieldName) == -1 && (this.mode != 'fieldmerge' || typeof this._fieldAlternatives[fieldName] == 'undefined')) { continue; @@ -536,7 +638,10 @@ && Zotero.ItemFields.isDate(fieldName) // TEMP - NSF && fieldName != 'dateSent') { - this.addDateRow(fieldName, this.item.getField(fieldName, true)); + let dateVal = this._unionFieldDescriptors + ? this.item.getField(fieldName, true, true) + : this.item.getField(fieldName, true); + this.addDateRow(fieldName, dateVal, extraFieldValues); continue; } } @@ -546,12 +651,14 @@ rowLabel.setAttribute('fieldname', fieldName); let valueElement = this.createFieldValueElement( - val, fieldName + val, fieldName, extraFieldValues ); if (fieldName) { + let labelText = this._unionFieldDescriptors?.get(fieldName)?.label + ?? Zotero.ItemFields.getLocalizedString(fieldName); let label = this.createLabelElement({ - text: Zotero.ItemFields.getLocalizedString(fieldName), + text: labelText, id: `itembox-field-${fieldName}-label`, }); rowLabel.appendChild(label); @@ -560,8 +667,12 @@ let openLinkButton; let link = val; let addLinkContextMenu = false; + // Don't show View Online button in batch edit mode + if (this._extraItems.length) { + // No open-link button in batch edit mode + } // TEMP - NSF (homepage) - if ((fieldName == 'url' || fieldName == 'homepage') + else if ((fieldName == 'url' || fieldName == 'homepage') // Only make plausible HTTP URLs clickable && Zotero.Utilities.isHTTPURL(val, true)) { openLinkButton = this.createOpenLinkIcon(val, fieldName); @@ -636,8 +747,8 @@ onContextMenu = this.createContextMenuHandler(fieldName, () => { let menupopup = ZoteroPane.buildFieldTransformMenu({ target: valueElement, - onTransform: (newValue) => { - this._setFieldTransformedValue(valueElement, newValue); + onTransform: (newValues) => { + this._setFieldTransformedValue(valueElement, newValues); } }); this.querySelector('#info-box > popupset').append(menupopup); @@ -711,6 +822,11 @@ labelKey = 'items-column-modified-by'; } if (userID) { + let hasMultipleUsers = fieldName === 'dateAdded' + ? this._extraItems.some(item => item.createdByUserID !== userID) + : this._extraItems.some( + item => (item.lastModifiedByUserID || item.createdByUserID) !== userID + ); let userLabel = document.createElement("div"); userLabel.className = "meta-label"; userLabel.setAttribute("fieldname", userFieldName); @@ -720,9 +836,18 @@ })); let userData = document.createElement("div"); userData.className = "meta-data"; - userData.appendChild(this.createValueElement({ - text: Zotero.Users.getName(userID), - })); + let valueElem = this.createValueElement({ + text: hasMultipleUsers ? '' : Zotero.Users.getName(userID), + }); + if (this._extraItems.length) { + valueElem.multipleValues = true; + if (hasMultipleUsers) { + valueElem.placeholder = Zotero.getString( + 'item-pane-batch-editing-multiple-values-placeholder' + ); + } + } + userData.appendChild(valueElem); this.addDynamicRow(userLabel, userData); } } @@ -731,103 +856,108 @@ // // Creators // - - // Creator type menu - if (this.editable) { - while (this._creatorTypeMenu.hasChildNodes()) { - this._creatorTypeMenu.removeChild(this._creatorTypeMenu.firstChild); - } - - var creatorTypes = Zotero.CreatorTypes.getTypesForItemType(this.item.itemTypeID); - - var localized = {}; - for (let i = 0; i < creatorTypes.length; i++) { - localized[creatorTypes[i].name] - = Zotero.CreatorTypes.getLocalizedString(creatorTypes[i].name); - } - - for (let i in localized) { - var menuitem = document.createXULElement("menuitem"); - menuitem.setAttribute("label", localized[i]); - menuitem.setAttribute("typeid", Zotero.CreatorTypes.getID(i)); - this._creatorTypeMenu.appendChild(menuitem); - } - this._creatorTypeMenu.addEventListener('popuphidden', () => { - // If the popup was opened with a mouse click, blur the field to hide icons - if (this._creatorTypeMenu.getAttribute("blur-on-hidden")) { - document.activeElement.blur(); - this._creatorTypeMenu.removeAttribute("blur-on-hidden"); + // If batch-editing, skip creators (for now) + if (!this._extraItems?.length) { + // Creator type menu + if (this.editable) { + while (this._creatorTypeMenu.hasChildNodes()) { + this._creatorTypeMenu.removeChild(this._creatorTypeMenu.firstChild); } - }); - } - - // Creator rows - - // Place, in order of preference, after title, after type, - // or at beginning - var field = this.getTitleField(); - if (!field) { - field = this._infoTable.querySelector('[fieldName="itemType"]'); - } - if (field) { - this._firstRowBeforeCreators = field.closest(".meta-row").nextSibling; - } - else { - this._firstRowBeforeCreators = this._infoTable.firstChild; - } - - this._creatorCount = 0; - var num = this.item.numCreators(); - if (num > 0) { - // Limit number of creators display - var max = Math.min(num, this._initialVisibleCreators); - // If only 1 or 2 more, just display - if (num < max + 3 || this._displayAllCreators) { - max = num; - } - for (let i = 0; i < max; i++) { - let data = this.item.getCreator(i); - this.addCreatorRow(data, data.creatorTypeID, false); - } - if (this._draggedCreator) { - this._draggedCreator = false; - // Block hover effects on creators, enable them back on first mouse movement. - // See comment in creatorDragPlaceholder() for explanation - for (let label of document.querySelectorAll(".meta-label[fieldname^='creator-']")) { - label.closest(".meta-row").classList.add("noHover"); + + var creatorTypes = Zotero.CreatorTypes.getTypesForItemType(this.item.itemTypeID); + + var localized = {}; + for (let i = 0; i < creatorTypes.length; i++) { + localized[creatorTypes[i].name] + = Zotero.CreatorTypes.getLocalizedString(creatorTypes[i].name); } - let removeHoverBlock = () => { - let noHoverRows = document.querySelectorAll('.noHover'); - noHoverRows.forEach(el => el.classList.remove('noHover')); - document.removeEventListener('mousemove', removeHoverBlock); - }; - document.addEventListener('mousemove', removeHoverBlock); + + for (let i in localized) { + var menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("label", localized[i]); + menuitem.setAttribute("typeid", Zotero.CreatorTypes.getID(i)); + this._creatorTypeMenu.appendChild(menuitem); + } + this._creatorTypeMenu.addEventListener('popuphidden', () => { + // If the popup was opened with a mouse click, blur the field to hide icons + if (this._creatorTypeMenu.getAttribute("blur-on-hidden")) { + document.activeElement.blur(); + this._creatorTypeMenu.removeAttribute("blur-on-hidden"); + } + }); } - // Additional creators not displayed - if (num > max) { - this.addMoreCreatorsRow(num - max); + // Creator rows + + // Place, in order of preference, after title, after type, + // or at beginning + var field = this.getTitleField(); + if (!field) { + field = this._infoTable.querySelector('[fieldName="itemType"]'); + } + if (field) { + this._firstRowBeforeCreators = field.closest(".meta-row").nextSibling; } else { - // If we didn't start with creators truncated, - // don't truncate for as long as we're viewing - // this item, so that added creators aren't - // immediately hidden - this._displayAllCreators = true; + this._firstRowBeforeCreators = this._infoTable.firstChild; + } + + this._creatorCount = 0; + var num = this.item.numCreators(); + if (num > 0) { + // Limit number of creators display + var max = Math.min(num, this._initialVisibleCreators); + // If only 1 or 2 more, just display + if (num < max + 3 || this._displayAllCreators) { + max = num; + } + for (let i = 0; i < max; i++) { + let data = this.item.getCreator(i); + this.addCreatorRow(data, data.creatorTypeID, false); + } + if (this._draggedCreator) { + this._draggedCreator = false; + // Block hover effects on creators, enable them back on first mouse movement. + // See comment in creatorDragPlaceholder() for explanation + for (let label of document.querySelectorAll(".meta-label[fieldname^='creator-']")) { + label.closest(".meta-row") + .classList + .add("noHover"); + } + let removeHoverBlock = () => { + let noHoverRows = document.querySelectorAll('.noHover'); + noHoverRows.forEach(el => el.classList.remove('noHover')); + document.removeEventListener('mousemove', removeHoverBlock); + }; + document.addEventListener('mousemove', removeHoverBlock); + } + + // Additional creators not displayed + if (num > max) { + this.addMoreCreatorsRow(num - max); + } + else { + // If we didn't start with creators truncated, + // don't truncate for as long as we're viewing + // this item, so that added creators aren't + // immediately hidden + this._displayAllCreators = true; + } + } + else if (this.editable && Zotero.CreatorTypes.itemTypeHasCreators(this.item.itemTypeID)) { + // Add default row + this.addCreatorRow(false, false, false); + } + + + if (this._showCreatorTypeGuidance) { + let creatorTypeLabels = this.querySelectorAll(".creator-type-label"); + this._id("zotero-author-guidance") + .show({ + forEl: creatorTypeLabels[creatorTypeLabels.length - 1] + }); + this._showCreatorTypeGuidance = false; } - } - else if (this.editable && Zotero.CreatorTypes.itemTypeHasCreators(this.item.itemTypeID)) { - // Add default row - this.addCreatorRow(false, false, false); - } - - - if (this._showCreatorTypeGuidance) { - let creatorTypeLabels = this.querySelectorAll(".creator-type-label"); - this._id("zotero-author-guidance").show({ - forEl: creatorTypeLabels[creatorTypeLabels.length - 1] - }); - this._showCreatorTypeGuidance = false; } this._ensureButtonsFocusable(); @@ -1433,12 +1563,14 @@ this.addDynamicRow(rowLabel, rowData); } - addDateRow(field, value) { + addDateRow(field, value, extraFieldValues) { var rowLabel = document.createElement("div"); rowLabel.className = "meta-label"; rowLabel.setAttribute("fieldname", field); + let labelText = this._unionFieldDescriptors?.get(field)?.label + ?? Zotero.ItemFields.getLocalizedString(field); let label = this.createLabelElement({ - text: Zotero.ItemFields.getLocalizedString(field), + text: labelText, id: `itembox-field-${field}-label` }); rowLabel.appendChild(label); @@ -1448,18 +1580,21 @@ var elem = this.createFieldValueElement( Zotero.Date.multipartToStr(value), - field + field, + extraFieldValues ); elem.setAttribute('aria-labelledby', label.id); - // y-m-d status indicator - var ymd = document.createElement('span'); - ymd.id = 'zotero-date-field-status'; - ymd.textContent = Zotero.Date.strToDate(Zotero.Date.multipartToStr(value)) - .order.split('').join(' '); - ymd.className = "show-on-hover"; rowData.appendChild(elem); - rowData.appendChild(ymd); + // Don't show y-m-d status indicator in batch edit mode + if (!this._extraItems.length) { + var ymd = document.createElement('span'); + ymd.id = 'zotero-date-field-status'; + ymd.textContent = Zotero.Date.strToDate(Zotero.Date.multipartToStr(value)) + .order.split('').join(' '); + ymd.className = "show-on-hover"; + rowData.appendChild(ymd); + } rowData.oncontextmenu = this.createContextMenuHandler(field); @@ -1750,7 +1885,7 @@ return valueElement; } - createFieldValueElement(valueText, fieldName) { + createFieldValueElement(valueText, fieldName, extraFieldValues = []) { valueText += ''; if (fieldName) { @@ -1786,7 +1921,7 @@ } let tooltipText; - if (fieldID) { + if (fieldID && !this._extraItems.length) { // Display the SQL date as a tooltip for date fields // TEMP - filingDate if (Zotero.ItemFields.isFieldOfBase(fieldID, 'date') || fieldName == 'filingDate') { @@ -1823,6 +1958,53 @@ // autocomplete for creator names is added in addCreatorRow this.addAutocompleteToElement(valueElement); } + + valueElement.values = [valueText, ...extraFieldValues]; + const hasMultipleValues = extraFieldValues.length && extraFieldValues.some(v => v !== valueText); + if (hasMultipleValues) { + let allValues = [valueText, ...extraFieldValues]; + let optionCounts = {}; + for (let v of allValues) { + if (v.length > 0) { + optionCounts[v] = (optionCounts[v] || 0) + 1; + } + } + let options = Object.keys(optionCounts); + options.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true })); + let optionLabels = options.map(o => `[${optionCounts[o]}] ${o}`); + valueElement.multipleValues = true; + valueElement.value = ''; + valueElement.placeholder = Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'); + if (this._fieldIsClickable(fieldName)) { + valueElement.initialValue = valueText; + valueElement.autocomplete = { + minResultsForPopup: 1, + noRollupOnEmptySearch: true, + completeSelectedIndex: true, + ignoreBlurWhileSearching: false, + search: 'zotero-options', + searchParam: JSON.stringify({ + search: 'zotero-options', + options: optionLabels, + optionValues: options, + includeNoValue: true + }), + popup: 'PopupAutoComplete', + }; + valueElement.onTextEntered = () => { + let input = valueElement.ref; + let controller = input?.controller; + if (!controller?.matchCount) return; + let selectedIndex = input.popup?.selectedIndex ?? -1; + if (selectedIndex >= 0 + && controller.getStyleAt(selectedIndex) === 'options-ac-no-value') { + valueElement._clearValue = true; + valueElement.blur(); + } + }; + } + } + return valueElement; } @@ -1915,7 +2097,9 @@ } } else { - value = this.item.getField(fieldName); + value = this._unionFieldDescriptors + ? this.item.getField(fieldName, false, true) + : this.item.getField(fieldName); // Access date needs to be converted from UTC if (value != '') { let localDate; @@ -2155,6 +2339,10 @@ if (this.ignoreBlur || !textbox) { return; } + + if (textbox.cancelled) { + return; + } var fieldName = textbox.getAttribute('fieldname'); @@ -2292,7 +2480,7 @@ } if (this.saveOnEdit) { - await this.item.saveTx(); + await this._saveItems(); } } @@ -2308,24 +2496,66 @@ || this._clickableFields.indexOf(fieldName) != -1); } - _modifyField(field, value) { - this.item.setField(field, value); + /** + * Check whether a field can be set on an item, considering base field mappings. + */ + _canSetFieldOnItem(field, item) { + let fieldID = Zotero.ItemFields.getID(field); + if (!fieldID) return false; + if (Zotero.ItemFields.isValidForType(fieldID, item.itemTypeID)) return true; + return !!Zotero.ItemFields.getFieldIDFromTypeAndBase(item.itemTypeID, fieldID); + } + + _modifyField(field, value, item = null) { + let items = item ? [item] : [this.item, ...this._extraItems]; + for (let i of items) { + if (this._unionFieldDescriptors && !this._canSetFieldOnItem(field, i)) { + continue; + } + i.setField(field, value); + } } - async _setFieldTransformedValue(label, newValue) { - label.value = newValue; - var fieldName = label.getAttribute('fieldname'); - this._modifyField(fieldName, newValue); + async _saveItems() { + // Cache item and extra items to avoid a race condition where, after `hideEditor`, + // while we yield for `await Zotero.DB.executeTransaction`, itemBox is rendered for + // the new item and this.item is no longer relevant + let item = this.item; + let extraItems = this._extraItems; - if (Zotero.ItemFields.isFieldOfBase(fieldName, 'title')) { - let shortTitleVal = this.item.getField('shortTitle'); - if (newValue.toLowerCase().startsWith(shortTitleVal.toLowerCase())) { - this._modifyField('shortTitle', newValue.substring(0, shortTitleVal.length)); + await Zotero.DB.executeTransaction(async () => { + await item.save(); + for (let extraItem of extraItems) { + await extraItem.save(); } + }); + if (extraItems.length) { + this._forceRenderAll(); } + } + + async _setFieldTransformedValue(label, newValues) { + let fieldName = label.getAttribute('fieldname'); + // In batch mode, don't update the label -- it shows a "Multiple" placeholder + // that should remain unchanged. + if (!this._extraItems.length) { + label.value = newValues[0]; + } + let items = [this.item, ...this._extraItems]; + items.forEach((item, index) => { + let newValue = newValues[index]; + this._modifyField(fieldName, newValue, item); + + if (Zotero.ItemFields.isFieldOfBase(fieldName, 'title')) { + let shortTitleVal = item.getField('shortTitle'); + if (newValue.toLowerCase().startsWith(shortTitleVal.toLowerCase())) { + this._modifyField('shortTitle', newValue.substring(0, shortTitleVal.length), item); + } + } + }); if (this.saveOnEdit) { - await this.item.saveTx(); + await this._saveItems(); } } @@ -2651,6 +2881,9 @@ } getTitleField() { + if (this._unionFieldDescriptors) { + return this._infoTable.querySelector('editable-text[fieldname="title"]'); + } var titleFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(this.item.itemTypeID, 'title'); return this._infoTable.querySelector(`editable-text[fieldname="${Zotero.ItemFields.getName(titleFieldID)}"]`); } diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js index 2c6ea9c1e5..651a3954cf 100644 --- a/chrome/content/zotero/elements/itemDetails.js +++ b/chrome/content/zotero/elements/itemDetails.js @@ -88,6 +88,18 @@ this._item = item; } + get extraItems() { + return this._extraItems ?? []; + } + + set extraItems(val) { + if (!Array.isArray(val)) { + return; + } + + this._extraItems = val.filter(item => item instanceof Zotero.Item && item.isRegularItem()); + } + /* * For contextPane update */ @@ -293,7 +305,12 @@ box.tabID = this.tabID; box.tabType = this.tabType; box.item = item; + box.extraItems = this.extraItems; box.collectionTreeRow = this.collectionTreeRow; + if (this.extraItems.length > 0) { + // mark everything, except the header and the info pane, as hidden + box.hidden = box.dataset.pane !== 'info' && box !== this._header; + } // Discard hidden panes if (box.hidden && box.discard) { box.discard(); diff --git a/chrome/content/zotero/elements/itemPane.js b/chrome/content/zotero/elements/itemPane.js index 21aff91a44..8f26678546 100644 --- a/chrome/content/zotero/elements/itemPane.js +++ b/chrome/content/zotero/elements/itemPane.js @@ -40,8 +40,11 @@ previousfocus="zotero-items-tree" /> - + + +