Add batch editing support for fields

This commit is contained in:
Tom Najdek 2026-01-24 00:15:32 +01:00
parent 714d4416f8
commit 8233f36e2f
No known key found for this signature in database
GPG key ID: EEC61A7B4C667D77
15 changed files with 1301 additions and 176 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -40,8 +40,11 @@
previousfocus="zotero-items-tree" />
<duplicates-merge-pane id="zotero-duplicates-merge-pane" />
<annotation-items-pane id="zotero-annotations-pane" />
<groupbox id="batch-edit-prompt" pack="center" align="center" data-l10n-id="item-pane-batch-editing-prompt">
<description id="batch-edit-prompt-message" />
<button id="batch-edit-prompt-enable" data-l10n-id="item-pane-batch-editing-enable" />
</groupbox>
</deck>
<item-pane-sidenav id="zotero-view-item-sidenav" no-context-notes="true" class="zotero-view-item-sidenav"/>
`);
@ -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) {

View file

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

View file

@ -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",
]);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,4 +29,8 @@ item-pane {
width: 100%;
}
}
#batch-edit-prompt-message {
padding: 3px 8px;
}
}

View file

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

View file

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