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" />
-
+
+
+
+
`);
@@ -52,6 +55,8 @@
this._duplicatesPane = this.querySelector("#zotero-duplicates-merge-pane");
this._messagePane = this.querySelector("#zotero-item-message");
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
+ this._batchEditEnableBtn = this.querySelector("#batch-edit-prompt button");
+ this._batchEditPromptMessage = this.querySelector("#batch-edit-prompt-message");
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
this._deck = this.querySelector("#zotero-item-pane-content");
@@ -59,6 +64,14 @@
this._notifierID = Zotero.Notifier.registerObserver(this, ['item']);
+ this._batchEditEnableBtn.addEventListener("command", () => {
+ this._isBatchEditEnabled = true;
+ this._setBatchEditCollapsible(true);
+ this.render();
+ this.updateItemPaneButtons();
+ });
+
+ this._isBatchEditEnabled = false;
this._translationTarget = null;
}
@@ -100,12 +113,12 @@
}
get mode() {
- return ["message", "item", "note", "duplicates"][this._deck.selectedIndex];
+ return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt"][this._deck.selectedIndex];
}
/**
* Set mode of item pane
- * @param {"message" | "item" | "note" | "duplicates"} type view type
+ * @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt"} type view type
*/
set mode(type) {
this.setAttribute("view-type", type);
@@ -126,10 +139,26 @@
if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
return renderStatus = this.renderAnnotations(this.data);
}
+
+ // reset the batch editing flag
+ let IDs = this.data.map(item => item.id);
+ if (!(IDs.length === this._prevIDs?.length && IDs.every((id, i) => id === this._prevIDs?.[i]))) {
+ if (this._isBatchEditEnabled) {
+ this._setBatchEditCollapsible(false);
+ }
+ this._isBatchEditEnabled = false;
+ this._prevIDs = IDs;
+ }
+
+ // Multiple items selected (not duplicates)
+ if (!this.collectionTreeRow.isDuplicates() && this.data.length > 1 && this.data.every(item => item.isRegularItem() && !item.isFeedItem)) {
+ // Hide the batch editing UI until the user opts-in
+ renderStatus = this._isBatchEditEnabled ? this.renderItemPane(this.data) : this.renderBatchEditorPrompt();
+ }
// Single item selected
- if (this.data.length == 1) {
+ else if (this.data.length === 1) {
let item = this.data[0];
-
+
// If a collection or search is selected, it must be in the trash.
if (item instanceof Zotero.Collection || item instanceof Zotero.Search) {
renderStatus = this.renderMessage();
@@ -141,7 +170,7 @@
renderStatus = this.renderItemPane(item);
}
}
- // Zero or multiple items selected
+ // No items selected or multiple, but includes some irregular items
else {
renderStatus = this.renderMessage();
}
@@ -175,9 +204,12 @@
return true;
}
- async renderItemPane(item) {
+ async renderItemPane(items) {
let previousMode = this.mode;
this.mode = "item";
+ if (!Array.isArray(items)) {
+ items = [items];
+ }
// Fix https://forums.zotero.org/discussion/115450/zotero-7-beta-wrong-vertical-position-in-the-item-pane-after-switching-from-a-note
if (previousMode === "note") {
@@ -186,11 +218,12 @@
requestIdleCallback(resolve, { timeout: 50 });
});
}
-
+
this._itemDetails.editable = this.editable;
this._itemDetails.tabID = "zotero-pane";
this._itemDetails.tabType = "library";
- this._itemDetails.item = item;
+ this._itemDetails.item = items[0];
+ this._itemDetails.extraItems = items.slice(1);
this._itemDetails.collectionTreeRow = this.collectionTreeRow;
this._itemDetails.render();
@@ -198,8 +231,8 @@
if (this.getAttribute("collapsed") == "true") {
return true;
}
-
- if (item.isFeedItem) {
+
+ if (items[0].isFeedItem) {
let lastTranslationTarget = Zotero.Prefs.get('feeds.lastTranslationTarget');
if (lastTranslationTarget) {
let id = parseInt(lastTranslationTarget.substr(1));
@@ -218,7 +251,7 @@
// if (!item.isTranslated) {
// item.translate();
// }
- ZoteroPane.startItemReadTimeout(item.id);
+ ZoteroPane.startItemReadTimeout(items[0].id);
}
return true;
}
@@ -298,6 +331,16 @@
return true;
}
+ renderBatchEditorPrompt() {
+ this.mode = 'batch-edit-prompt';
+ document.l10n.setAttributes(
+ this._batchEditPromptMessage,
+ 'item-pane-message-items-selected',
+ { count: this.data.length }
+ );
+ return true;
+ }
+
setItemPaneMessage(msg) {
this.mode = "message";
this._messagePane.render(msg);
@@ -348,6 +391,11 @@
return;
}
+ if (this._isBatchEditEnabled && this.data.length > 1) {
+ container.renderCustomHead(this.renderBatchEditHead.bind(this));
+ return;
+ }
+
container.renderCustomHead();
}
@@ -426,6 +474,17 @@
append(button);
}
+ renderBatchEditHead(data) {
+ let { doc, append } = data;
+ let description = doc.createXULElement("description");
+ document.l10n.setAttributes(
+ description,
+ 'item-pane-batch-editing-header',
+ { count: this.data.length }
+ );
+ append(description);
+ }
+
updateReadLabel() {
var items = this.data;
var isUnread = false;
@@ -535,6 +594,25 @@
this.setTranslateButton();
}
+ _setBatchEditCollapsible(enabled) {
+ let section = this._itemDetails.querySelector('collapsible-section[data-pane="info"]');
+ if (!section) return;
+ if (enabled) {
+ // Force open without saving to prefs, so the previous state is preserved
+ section._skipSaveOpenState = true;
+ section.open = true;
+ section._skipSaveOpenState = false;
+ section.collapsible = false;
+ section.showContextMenu = false;
+ }
+ else {
+ section.collapsible = true;
+ section.showContextMenu = true;
+ // Restore the pref-saved open state
+ section._restoreOpenState();
+ }
+ }
+
getCurrentPane(mode = undefined) {
if (!mode) {
// Guess a mode from the current data
@@ -543,7 +621,7 @@
mode = "annotations";
}
// No/multiple objects are selected OR selected object is a trashed collection/search
- else if (!this.data.length || this.data.length > 1
+ else if (!this.data.length || (this.data.length > 1 && !this._isBatchEditEnabled)
|| this.data[0] instanceof Zotero.Collection || this.data[0] instanceof Zotero.Search) {
mode = "message";
}
@@ -642,6 +720,10 @@
this._deck.selectedIndex = 4;
break;
}
+ case "batch-edit-prompt": {
+ this._deck.selectedIndex = 5;
+ break;
+ }
}
let isViewingItem = type == "item";
if (previousViewType != "item" && isViewingItem) {
diff --git a/chrome/content/zotero/elements/itemPaneHeader.js b/chrome/content/zotero/elements/itemPaneHeader.js
index 41853cf001..bf206f6753 100644
--- a/chrome/content/zotero/elements/itemPaneHeader.js
+++ b/chrome/content/zotero/elements/itemPaneHeader.js
@@ -65,6 +65,10 @@
_editable = true;
+ get _renderDependencies() {
+ return [this._tabID, this._item?.id, this.extraItems?.length ?? 0];
+ }
+
get item() {
return this._item;
}
@@ -145,8 +149,8 @@
event.preventDefault();
let menupopup = ZoteroPane.buildFieldTransformMenu({
target: this.titleField,
- onTransform: (newValue) => {
- this._setTransformedValue(newValue);
+ onTransform: (newValues) => {
+ this._setTransformedValue(newValues[0]);
},
});
@@ -226,6 +230,12 @@
if (this._item.isAttachment()) {
headerMode = 'title';
}
+
+ if (this.extraItems?.length) {
+ headerMode = 'none';
+ }
+
+ this.classList.toggle('batch-edit', !!this.extraItems?.length);
this.title.hidden = true;
this.creatorYear.hidden = true;
diff --git a/chrome/content/zotero/modules/optionsAutoComplete.mjs b/chrome/content/zotero/modules/optionsAutoComplete.mjs
new file mode 100644
index 0000000000..3db803bb26
--- /dev/null
+++ b/chrome/content/zotero/modules/optionsAutoComplete.mjs
@@ -0,0 +1,82 @@
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+const OPTIONS_AC_CLASS_ID = Components.ID('{882f1f42-c1ff-458c-9157-06a4a55d32e8}');
+const OPTIONS_AC_NAME = "zotero-options";
+const OPTIONS_AC_CONTRACT_ID = `@mozilla.org/autocomplete/search;1?name=${OPTIONS_AC_NAME}`;
+
+function makeResult(searchString, matches, { noValueLabel, values } = {}) {
+ const r = Cc["@mozilla.org/autocomplete/simple-result;1"]
+ .createInstance(Ci.nsIAutoCompleteSimpleResult);
+
+ r.setSearchString(searchString);
+
+ if (matches.length === 0 && !noValueLabel) {
+ r.setSearchResult(Ci.nsIAutoCompleteResult.RESULT_NOMATCH);
+ r.setDefaultIndex(-1);
+ return r;
+ }
+
+ r.setSearchResult(Ci.nsIAutoCompleteResult.RESULT_SUCCESS);
+ r.setDefaultIndex(0);
+
+ for (let i = 0; i < matches.length; i++) {
+ let label = matches[i];
+ let value = values?.[i] ?? label;
+ r.appendMatch(label, "", null, "options-ac-value", value, label);
+ }
+ if (noValueLabel) {
+ r.appendMatch("", noValueLabel, null, "options-ac-no-value", "", noValueLabel);
+ }
+ return r;
+}
+
+export class OptionsAutoComplete {
+ static init() {
+ const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ if (registrar.isCIDRegistered(OPTIONS_AC_CLASS_ID)) {
+ return;
+ }
+ registrar.registerFactory(
+ OPTIONS_AC_CLASS_ID, "", OPTIONS_AC_CONTRACT_ID, new OptionsAutoComplete()
+ );
+ }
+
+ // nsIAutoCompleteSearch
+ startSearch(searchString, searchParams, previousResult, listener) {
+ searchParams = JSON.parse(searchParams);
+ if (!searchParams) {
+ throw new Error("Invalid JSON passed to autocomplete");
+ }
+
+ let allOptions = searchParams?.options ?? [];
+ let allValues = searchParams?.optionValues ?? allOptions;
+ let search = (searchString || "").toLowerCase();
+ let filtered = allOptions
+ .map((label, i) => ({ label, value: allValues[i] ?? label }))
+ .filter(({ value }) => value.toLowerCase().startsWith(search))
+ .slice(0, 10);
+ let matches = filtered.map(({ label }) => label);
+ let values = filtered.map(({ value }) => value);
+ let noValueLabel = searchParams?.includeNoValue && !searchString
+ ? Zotero.getString('item-pane-batch-editing-no-value')
+ : null;
+ const result = makeResult(searchString, matches, { noValueLabel, values });
+ listener.onSearchResult(this, result);
+ }
+
+ // nsIAutoCompleteSearch
+ stopSearch() {
+ }
+
+ // nsIFactory
+ createInstance(iid) {
+ return this.QueryInterface(iid);
+ }
+}
+
+OptionsAutoComplete.prototype.classID = OPTIONS_AC_CLASS_ID;
+OptionsAutoComplete.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIFactory",
+ "nsIAutoCompleteSearch",
+]);
diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js
index e6c17ba7b8..26b4d84a86 100644
--- a/chrome/content/zotero/xpcom/zotero.js
+++ b/chrome/content/zotero/xpcom/zotero.js
@@ -671,8 +671,15 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte
const { ZoteroAutoComplete } = ChromeUtils.importESModule(
`chrome://zotero/content/zotero-autocomplete.mjs`
);
+
ZoteroAutoComplete.init();
+ const { OptionsAutoComplete } = ChromeUtils.importESModule(
+ `chrome://zotero/content/modules/optionsAutoComplete.mjs`
+ );
+
+ OptionsAutoComplete.init();
+
await Zotero.Users.init();
await Zotero.Libraries.init();
diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js
index 35135497f2..65521ff6df 100644
--- a/chrome/content/zotero/zoteroPane.js
+++ b/chrome/content/zotero/zoteroPane.js
@@ -7032,30 +7032,30 @@ var ZoteroPane = new function () {
this.buildFieldTransformMenu = function ({ target, onTransform }) {
let doc = target.ownerDocument;
- let value = target.value;
- let valueTitleCased = Zotero.Utilities.capitalizeTitle(value, true);
- let valueSentenceCased = Zotero.Utilities.sentenceCase(value);
+ let values = target.values;
+ let valuesTitleCased = values.map(v => Zotero.Utilities.capitalizeTitle(v, true));
+ let valuesSentenceCased = values.map(v => Zotero.Utilities.sentenceCase(v));
let menupopup = doc.createXULElement('menupopup');
let titleCase = doc.createXULElement('menuitem');
titleCase.setAttribute('label', Zotero.getString('zotero.item.textTransform.titlecase'));
titleCase.addEventListener('command', () => {
- onTransform(valueTitleCased);
+ onTransform(valuesTitleCased);
});
- titleCase.disabled = valueTitleCased == value;
+ titleCase.disabled = values.every((v, i) => valuesTitleCased[i] === v);
menupopup.append(titleCase);
let sentenceCase = doc.createXULElement('menuitem');
sentenceCase.setAttribute('label', Zotero.getString('zotero.item.textTransform.sentencecase'));
sentenceCase.addEventListener('command', () => {
- onTransform(valueSentenceCased);
+ onTransform(valuesSentenceCased);
});
- sentenceCase.disabled = valueSentenceCased == value;
+ sentenceCase.disabled = values.every((v, i) => valuesSentenceCased[i] === v);
menupopup.append(sentenceCase);
Zotero.Utilities.Internal.updateEditContextMenu(menupopup, target);
-
+
return menupopup;
};
};
diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl
index df65a30cbf..e951c8ab10 100644
--- a/chrome/locale/en-US/zotero/zotero.ftl
+++ b/chrome/locale/en-US/zotero/zotero.ftl
@@ -900,3 +900,18 @@ plugins-blocked-plugin =
.message = This plugin has been disabled by { -app-name }.
data-dir-unsupported-storage = This can happen if the { -app-name } data directory is in a cloud storage folder (OneDrive, Dropbox, etc.) or on a network share.
+
+item-pane-batch-editing-prompt =
+ .aria-label = Batch editing
+
+item-pane-batch-editing-enable =
+ .label = Enter Batch Edit Mode
+
+item-pane-batch-editing-multiple-values-placeholder = Multiple…
+
+item-pane-batch-editing-no-value = (No value)
+
+item-pane-batch-editing-header = { $count ->
+ [one] Editing { $count } item
+ *[other] Editing { $count } items
+}
diff --git a/chrome/skin/default/zotero/overlay.css b/chrome/skin/default/zotero/overlay.css
index 5ceb25685a..432e0248d1 100644
--- a/chrome/skin/default/zotero/overlay.css
+++ b/chrome/skin/default/zotero/overlay.css
@@ -181,3 +181,26 @@
background-color: Highlight;
color: HighlightText;
}
+
+.autocomplete-richlistitem[type="options-ac-value"],
+.autocomplete-richlistitem[type="options-ac-no-value"] {
+ height: auto;
+ min-height: auto;
+}
+
+.autocomplete-richlistitem[type="options-ac-value"] > .ac-title,
+.autocomplete-richlistitem[type="options-ac-no-value"] > .ac-title {
+ height: 2em;
+ min-height: 2em;
+ padding-inline: 8px;
+}
+
+.autocomplete-richlistitem[type="options-ac-value"] > .ac-title > .ac-text-overflow-container,
+.autocomplete-richlistitem[type="options-ac-no-value"] > .ac-title > .ac-text-overflow-container {
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.autocomplete-richlistitem[type="options-ac-no-value"] {
+ color: var(--fill-secondary);
+}
diff --git a/scss/elements/_editableText.scss b/scss/elements/_editableText.scss
index c8eaae0cbb..19df8d0a60 100644
--- a/scss/elements/_editableText.scss
+++ b/scss/elements/_editableText.scss
@@ -1,7 +1,7 @@
@include comfortable {
--editable-text-padding-inline: 4px;
--editable-text-padding-block: 4px;
-
+
--editable-text-tight-padding-inline: 4px;
--editable-text-tight-padding-block: 2px;
}
@@ -9,7 +9,7 @@
@include compact {
--editable-text-padding-inline: 4px;
--editable-text-padding-block: 1px;
-
+
--editable-text-tight-padding-inline: 3px;
--editable-text-tight-padding-block: 1px;
}
@@ -26,10 +26,10 @@ editable-text {
// Fun auto-sizing approach from CSSTricks:
// https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/
-
+
display: grid;
scrollbar-color: var(--color-scrollbar) var(--color-scrollbar-background);
-
+
&:not([nowrap])::after {
content: attr(value) ' ';
visibility: hidden;
@@ -39,7 +39,7 @@ editable-text {
line-height: inherit;
overflow: hidden;
}
-
+
&:not([nowrap])::after, &:not([nowrap]) .input {
grid-area: 1 / 1 / 2 / 2;
overflow-wrap: anywhere;
@@ -56,10 +56,10 @@ editable-text {
scrollbar-gutter: stable;
}
}
-
+
.input {
border-radius: 5px;
-
+
// No focus ring for read-only fields
&:read-only {
--width-focus-border: 0px;
@@ -80,7 +80,7 @@ editable-text {
}
// Necessary for consistent padding, even if it's actually an
-moz-default-appearance: textarea;
-
+
min-height: calc(var(--line-height) * var(--min-visible-lines));
margin: 0;
border: 1px solid transparent;
@@ -89,13 +89,13 @@ editable-text {
line-height: inherit;
color: inherit;
padding: var(--editable-text-padding-block) var(--editable-text-padding-inline);
-
+
&:read-only, &:not(:focus) {
appearance: none;
background: transparent;
text-align: inherit;
}
-
+
&:hover:not(:read-only, :focus) {
background: var(--fill-quinary);
box-shadow: 0 0 0 1px var(--fill-quinary);
@@ -104,7 +104,7 @@ editable-text {
&:focus:not(:read-only) {
background: var(--material-background);
}
-
+
::placeholder {
color: var(--fill-tertiary);
}
@@ -120,11 +120,12 @@ editable-text {
&[hidden] {
display: none;
}
+
textarea {
// Per https://stackoverflow.com/a/22700700, somehow this removes an extra half-line
// at the bottom of textarea on all platforms with non-overlay scrollbars
overflow-x: hidden;
-
+
// Match the gutters we apply to ::after
overflow-y: scroll;
}
diff --git a/scss/elements/_itemPane.scss b/scss/elements/_itemPane.scss
index 2dd29f22ef..66316b8abc 100644
--- a/scss/elements/_itemPane.scss
+++ b/scss/elements/_itemPane.scss
@@ -29,4 +29,8 @@ item-pane {
width: 100%;
}
}
+
+ #batch-edit-prompt-message {
+ padding: 3px 8px;
+ }
}
diff --git a/scss/elements/_itemPaneHeader.scss b/scss/elements/_itemPaneHeader.scss
index 153b632a84..6d702a8825 100644
--- a/scss/elements/_itemPaneHeader.scss
+++ b/scss/elements/_itemPaneHeader.scss
@@ -78,4 +78,13 @@ item-pane-header {
border: none;
height: 24px;
}
+
+ &.batch-edit.has-custom-head .custom-head {
+ description {
+ font-weight: 600;
+ line-height: 1.333;
+ margin: 0;
+ padding-inline: calc(var(--editable-text-tight-padding-inline) + 1px);
+ }
+ }
}
diff --git a/test/tests/itemPaneTest.js b/test/tests/itemPaneTest.js
index ce9afb8a78..f3a9fea2d0 100644
--- a/test/tests/itemPaneTest.js
+++ b/test/tests/itemPaneTest.js
@@ -2596,4 +2596,569 @@ describe("Item pane", function () {
await waitForToggle('reader toolbar');
});
});
+
+ describe("Batch Edit", function () {
+ let createdItems = [];
+ let _createDataObject = async (...args) => {
+ let item = await createDataObject(...args);
+ createdItems.push(item);
+ return item;
+ };
+ afterEach(async function () {
+ for (let item of createdItems.reverse()) {
+ await item.eraseTx();
+ }
+ createdItems = [];
+ });
+
+ it("should enter and exit batch edit mode", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
+ let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+ await waitForFrame();
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ // Enter batch edit mode
+ assert.equal(itemPane.mode, "batch-edit-prompt");
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ assert.ok(batchEditEnableBtn, "Enter Batch Edit Mode button should exist");
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ // Should now be in item mode with batch editing enabled
+ assert.equal(itemPane.mode, "item");
+
+ // Header should hide title-head and show items selected label
+ let header = itemDetails._header;
+ assert.ok(header.classList.contains('no-title-head'), "title-head should be hidden in batch edit mode");
+ assert.ok(header.querySelector('[data-l10n-id="item-pane-batch-editing-header"]'), "batch editing header label should be in header");
+
+ // Exit batch edit mode by changing selection to a single item
+ await ZoteroPane.selectItem(item1.id);
+ await waitForFrame();
+
+ // Should be back in batch-edit-prompt mode when re-selecting both
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+ await waitForFrame();
+ assert.equal(itemPane.mode, "batch-edit-prompt");
+ });
+ it("should restore collapsed info section state after exiting batch edit via selection change", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
+ let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ // Select item1 and collapse the info section
+ await ZoteroPane.selectItem(item1.id);
+ await waitForFrame();
+ let infoSection = itemDetails.querySelector('collapsible-section[data-pane="info"]');
+ infoSection.open = false;
+ assert.isFalse(infoSection.open, "info section should be collapsed");
+
+ // Select both items to enter batch edit prompt
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+ await waitForFrame();
+ assert.equal(itemPane.mode, "batch-edit-prompt");
+
+ // Enter batch edit mode
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+ assert.equal(itemPane.mode, "item");
+
+ // Info section should be forced open with no twisty
+ infoSection = itemDetails.querySelector('collapsible-section[data-pane="info"]');
+ assert.isTrue(infoSection.open, "info section should be open in batch edit mode");
+ let twisty = infoSection.querySelector('.twisty');
+ assert.isTrue(twisty.hidden, "twisty should be hidden in batch edit mode");
+
+ // Change selection to just one item -- exits batch edit
+ await ZoteroPane.selectItem(item1.id);
+ await waitForFrame();
+
+ // Info section should restore its previous collapsed state
+ infoSection = itemDetails.querySelector('collapsible-section[data-pane="info"]');
+ assert.isFalse(infoSection.open, "info section should be collapsed after exiting batch edit");
+ twisty = infoSection.querySelector('.twisty');
+ assert.isFalse(twisty.hidden, "twisty should be visible after exiting batch edit");
+ });
+ it("should apply autocomplete value to all items in batch edit mode", async function () {
+ let sharedTitle = "Journal of Shared Research";
+ let differentTitle = "Journal of Different Research";
+
+ let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
+ item1.setField('publicationTitle', sharedTitle);
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
+ item2.setField('publicationTitle', sharedTitle);
+ await item2.saveTx();
+
+ let item3 = await _createDataObject('item', { itemType: 'journalArticle' });
+ item3.setField('publicationTitle', differentTitle);
+ await item3.saveTx();
+
+ let item4 = await _createDataObject('item', { itemType: 'journalArticle' });
+ await item4.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id, item3.id, item4.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+ let pubTitleField = itemBox.querySelector('editable-text[fieldname="publicationTitle"]');
+ assert.ok(pubTitleField, "publicationTitle field should exist");
+
+ pubTitleField._ignoredWindowInactiveBlur = false;
+ await activateZoteroPane();
+ await Zotero.Promise.delay(50);
+ pubTitleField.focus();
+
+ // 2 value options + 1 "no value" option
+ await waitForCallback(() => pubTitleField.ref.mController.matchCount === 3, 100, 10);
+ let controller = pubTitleField.ref.mController;
+ // options should be sorted alphabetically, empty values are ignored
+ assert.equal(controller.matchCount, 3);
+ assert.equal(controller.getValueAt(0), `[1] ${differentTitle}`);
+ assert.equal(controller.getFinalCompleteValueAt(0), differentTitle);
+ assert.equal(controller.getValueAt(1), `[2] ${sharedTitle}`);
+ assert.equal(controller.getFinalCompleteValueAt(1), sharedTitle);
+ // Last option should be "no value"
+ assert.equal(controller.getStyleAt(2), 'options-ac-no-value');
+
+ let modifyPromise = waitForItemEvent('modify');
+ pubTitleField.ref.dispatchEvent(new KeyboardEvent(
+ 'keydown', { key: "ArrowDown", code: 'ArrowDown', keyCode: KeyboardEvent.DOM_VK_DOWN, bubbles: true, }
+ ));
+ await Zotero.Promise.delay(50);
+ pubTitleField.ref.dispatchEvent(new KeyboardEvent(
+ 'keydown', { key: "Enter", code: "Enter", keyCode: KeyboardEvent.DOM_VK_RETURN, bubbles: true }
+ ));
+ await modifyPromise;
+
+ assert.equal(item1.getField('publicationTitle'), differentTitle);
+ assert.equal(item2.getField('publicationTitle'), differentTitle);
+ assert.equal(item3.getField('publicationTitle'), differentTitle);
+ assert.equal(item4.getField('publicationTitle'), differentTitle);
+ });
+
+ it("should not show View Online button for URL and DOI fields in batch edit mode", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
+ item1.setField('url', 'https://example.com/1');
+ item1.setField('DOI', '10.1234/test1');
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
+ item2.setField('url', 'https://example.com/2');
+ item2.setField('DOI', '10.1234/test2');
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+
+ let urlLink = itemBox.querySelector('#itembox-field-url-link');
+ let doiLink = itemBox.querySelector('#itembox-field-DOI-link');
+
+ // View Online buttons should be hidden in batch edit mode
+ assert.ok(urlLink.hidden, "URL View Online button should be hidden in batch edit mode");
+ assert.ok(doiLink.hidden, "DOI View Online button should be hidden in batch edit mode");
+ });
+
+ it("should not show date field status or tooltip in batch edit mode", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'journalArticle' });
+ item1.setField('date', '2024-01-15');
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'journalArticle' });
+ item2.setField('date', '2023-06-20');
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+
+ // Date field status indicator (y m d) should not be present
+ let dateStatus = itemBox.querySelector('#zotero-date-field-status');
+ assert.isNull(dateStatus, "date field status should not be present in batch edit mode");
+
+ // Date field should not have a tooltip
+ let dateField = itemBox.querySelector('editable-text[fieldname="date"]');
+ assert.ok(dateField, "date field should exist");
+ assert.isNull(dateField.getAttribute('tooltiptext'), "date field should not have a tooltip in batch edit mode");
+ });
+
+ it("should show union of fields from all item types in cross-type batch edit", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'book' });
+ item1.setField('publisher', 'Test Publisher');
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'audioRecording' });
+ item2.setField('label', 'Test Label');
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+
+ // Shared base field 'publisher' should appear (book: publisher, audioRecording: label)
+ let publisherField = itemBox.querySelector('editable-text[fieldname="publisher"]');
+ assert.ok(publisherField, "publisher (base field) should appear");
+
+ // Shared base field 'medium' should appear (book: format, audioRecording: audioRecordingFormat)
+ let mediumField = itemBox.querySelector('editable-text[fieldname="medium"]');
+ assert.ok(mediumField, "medium (base field) should appear");
+
+ // Book-only fields should appear
+ let editionField = itemBox.querySelector('editable-text[fieldname="edition"]');
+ assert.ok(editionField, "edition (book-only) should appear");
+
+ // audioRecording-only fields should appear
+ let runningTimeField = itemBox.querySelector('editable-text[fieldname="runningTime"]');
+ assert.ok(runningTimeField, "runningTime (audioRecording-only) should appear");
+ });
+ it("should use base field label when field is shared across types, type-specific label otherwise", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'book' });
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'audioRecording' });
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+
+ // Shared field 'publisher' should use base field label
+ let publisherLabel = itemBox.querySelector('#itembox-field-publisher-label');
+ assert.ok(publisherLabel, "publisher label should exist");
+ assert.equal(publisherLabel.textContent, Zotero.ItemFields.getLocalizedString('publisher'));
+
+ // audioRecording-only field 'runningTime' should use type-specific label
+ let runningTimeLabel = itemBox.querySelector('#itembox-field-runningTime-label');
+ assert.ok(runningTimeLabel, "runningTime label should exist");
+ assert.equal(runningTimeLabel.textContent, Zotero.ItemFields.getLocalizedString('runningTime'));
+ });
+ it("should show 'Multiple' placeholder for shared base-mapped fields with different values across types", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'book' });
+ item1.setField('publisher', 'Book Publisher');
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'audioRecording' });
+ item2.setField('label', 'Audio Label');
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+ let publisherField = itemBox.querySelector('editable-text[fieldname="publisher"]');
+ assert.ok(publisherField, "publisher field should exist");
+ assert.isTrue(publisherField.multipleValues, "publisher should show multiple values");
+ assert.equal(
+ publisherField.placeholder,
+ Zotero.getString('item-pane-batch-editing-multiple-values-placeholder')
+ );
+ });
+ it("should apply value to base-mapped fields across different item types", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'book' });
+ item1.setField('publisher', 'Old Publisher');
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'audioRecording' });
+ item2.setField('label', 'Old Label');
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+ let publisherField = itemBox.querySelector('editable-text[fieldname="publisher"]');
+
+ // Simulate editing the field
+ publisherField._ignoredWindowInactiveBlur = false;
+ await activateZoteroPane();
+ await Zotero.Promise.delay(50);
+ publisherField.focus();
+ await Zotero.Promise.delay(50);
+
+ // Type a new value
+ let modifyPromise = waitForItemEvent('modify');
+ publisherField.value = 'New Shared Publisher';
+ publisherField.blur();
+ await modifyPromise;
+
+ // Both items should have the new value in their type-specific fields
+ assert.equal(item1.getField('publisher'), 'New Shared Publisher', "book publisher should be updated");
+ assert.equal(item2.getField('label'), 'New Shared Publisher', "audioRecording label should be updated");
+ });
+ it("should skip items when setting a type-specific field that doesn't apply to all items", async function () {
+ let item1 = await _createDataObject('item', { itemType: 'audioRecording' });
+ item1.setField('runningTime', '3:45');
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'book' });
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+ let runningTimeField = itemBox.querySelector('editable-text[fieldname="runningTime"]');
+ assert.ok(runningTimeField, "runningTime field should exist");
+
+ // Simulate editing the field
+ runningTimeField._ignoredWindowInactiveBlur = false;
+ await activateZoteroPane();
+ await Zotero.Promise.delay(50);
+ runningTimeField.focus();
+ await Zotero.Promise.delay(50);
+
+ // Type a new value -- should not throw for book
+ let modifyPromise = waitForItemEvent('modify');
+ runningTimeField.value = '5:00';
+ runningTimeField.blur();
+ await modifyPromise;
+
+ assert.equal(item1.getField('runningTime'), '5:00', "audioRecording runningTime should be updated");
+ // Book should be unaffected -- no error thrown
+ });
+ it("should show 'Multiple' for Added By in group library batch edit with different users", async function () {
+ let group = await createGroup();
+ await Zotero.Users.setName(1, 'User One');
+ await Zotero.Users.setName(2, 'User Two');
+
+ let item1 = createUnsavedDataObject('item', { libraryID: group.libraryID });
+ item1.setField('createdByUserID', 1);
+ await item1.saveTx();
+
+ let item2 = createUnsavedDataObject('item', { libraryID: group.libraryID });
+ item2.setField('createdByUserID', 2);
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+
+ // "Added By" field should show "Multiple..." placeholder
+ let addedByRow = itemBox.querySelector('.meta-label[fieldname="addedBy"]');
+ assert.ok(addedByRow, "addedBy row should exist");
+ let addedByValue = addedByRow.parentElement.querySelector('editable-text');
+ assert.isTrue(addedByValue.multipleValues, "addedBy should have multipleValues");
+ assert.equal(
+ addedByValue.placeholder,
+ Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'),
+ "addedBy should show Multiple placeholder"
+ );
+ assert.equal(addedByValue.value, '', "addedBy value should be empty");
+
+ // Should not be focusable
+ assert.equal(addedByValue.ref.tabIndex, -1, "addedBy tabIndex should be -1");
+
+ // Group erasure cascades to items
+ await group.eraseTx();
+ });
+ it("should show user name for Added By in group library batch edit when all items have the same user", async function () {
+ let group = await createGroup();
+ await Zotero.Users.setName(1, 'Same User');
+
+ let item1 = createUnsavedDataObject('item', { libraryID: group.libraryID });
+ item1.setField('createdByUserID', 1);
+ await item1.saveTx();
+
+ let item2 = createUnsavedDataObject('item', { libraryID: group.libraryID });
+ item2.setField('createdByUserID', 1);
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+
+ // "Added By" field should show the user name
+ let addedByRow = itemBox.querySelector('.meta-label[fieldname="addedBy"]');
+ assert.ok(addedByRow, "addedBy row should exist");
+ let addedByValue = addedByRow.parentElement.querySelector('editable-text');
+ assert.equal(addedByValue.value, 'Same User', "addedBy should show user name");
+ assert.isTrue(addedByValue.multipleValues, "addedBy should have multipleValues for non-focusable behavior");
+ assert.equal(addedByValue.ref.tabIndex, -1, "addedBy tabIndex should be -1");
+
+ await group.eraseTx();
+ });
+ it("should transform title case for all items in batch edit mode", async function () {
+ let titleCaseTitle = "The Great Gatsby";
+ let sentenceCaseTitle = "to kill a mockingbird";
+
+ // Create two books with different titles in different cases
+ let item1 = await _createDataObject('item', { itemType: 'book' });
+ item1.setField('title', titleCaseTitle);
+ await item1.saveTx();
+
+ let item2 = await _createDataObject('item', { itemType: 'book' });
+ item2.setField('title', sentenceCaseTitle);
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ // Find the title field
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+ let titleField = itemBox.querySelector('editable-text[fieldname="title"]');
+ assert.ok(titleField, "title field should exist");
+
+ // Find and click the options button for the title field
+ let optionsButton = itemBox.querySelector('#itembox-field-title-options');
+ assert.ok(optionsButton, "options button should exist");
+
+ // Click the options button to open the context menu
+ let menuPromise = new Promise((resolve) => {
+ let observer = new MutationObserver((mutations) => {
+ for (let mutation of mutations) {
+ for (let node of mutation.addedNodes) {
+ if (node.tagName === 'menupopup') {
+ observer.disconnect();
+ resolve(node);
+ }
+ }
+ }
+ });
+ observer.observe(itemBox.querySelector('#info-box > popupset'), { childList: true });
+ });
+
+ optionsButton.click();
+ let menupopup = await menuPromise;
+
+ let titleCaseMenuItem = Array.from(menupopup.querySelectorAll('menuitem'))
+ .find(item => item.getAttribute('label') === Zotero.getString('zotero.item.textTransform.titlecase'));
+ assert.ok(titleCaseMenuItem, "title case menu item should exist");
+
+ let modifyPromise = waitForItemEvent('modify');
+ titleCaseMenuItem.click();
+ // Label should not flash individual values -- it stays as "Multiple" in batch mode
+ assert.equal(titleField.value, '', "title field value should remain empty in batch mode");
+ assert.equal(titleField.placeholder, 'Multiple\u2026', "title field should still show Multiple placeholder");
+ await modifyPromise;
+
+ assert.equal(item1.getField('title'), "The Great Gatsby", "item1 should remain in title case");
+ assert.equal(item2.getField('title'), "To Kill a Mockingbird", "item2 should be transformed to title case");
+ });
+ });
+
+ it("should not focus read-only fields with multiple values", async function () {
+ let item1 = await createDataObject('item', { itemType: 'journalArticle' });
+ item1.setField('title', 'Item 1');
+ await item1.saveTx();
+
+ let item2 = await createDataObject('item', { itemType: 'journalArticle' });
+ item2.setField('title', 'Item 2');
+ await item2.saveTx();
+
+ await ZoteroPane.selectItems([item1.id, item2.id]);
+
+ let itemPane = win.ZoteroPane.itemPane;
+ let itemDetails = ZoteroPane.itemPane._itemDetails;
+
+ let batchEditEnableBtn = itemPane.querySelector('button[label="Enter Batch Edit Mode"]');
+ batchEditEnableBtn.click();
+ await itemDetails._renderPromise;
+
+ let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
+ let dateModifiedField = itemBox.querySelector('editable-text[fieldname="dateModified"]');
+ assert.ok(dateModifiedField, "dateModified field should exist");
+ assert.isTrue(dateModifiedField.readOnly, "dateModified should be read-only");
+ assert.isTrue(dateModifiedField.multipleValues, "dateModified should have multiple values");
+ assert.equal(
+ dateModifiedField.placeholder,
+ Zotero.getString('item-pane-batch-editing-multiple-values-placeholder')
+ );
+
+ // tabIndex should be -1 to prevent tab navigation
+ assert.equal(dateModifiedField.ref.tabIndex, -1, "tabIndex should be -1");
+
+ // Clicking the field should not focus it
+ await activateZoteroPane();
+ await Zotero.Promise.delay(50);
+ dateModifiedField.ref.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ await waitForFrame();
+
+ assert.isFalse(dateModifiedField.focused, "read-only multiple-values field should not be focusable via click");
+ assert.equal(
+ dateModifiedField.placeholder,
+ Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'),
+ "Multiple placeholder should not be cleared"
+ );
+ await item2.eraseTx();
+ await item1.eraseTx();
+ });
});