mirror of
https://github.com/zotero/zotero.git
synced 2026-09-22 00:32:47 +00:00
Add batch editing support for fields
This commit is contained in:
parent
714d4416f8
commit
8233f36e2f
15 changed files with 1301 additions and 176 deletions
|
|
@ -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 || "" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)}"]`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
82
chrome/content/zotero/modules/optionsAutoComplete.mjs
Normal file
82
chrome/content/zotero/modules/optionsAutoComplete.mjs
Normal 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",
|
||||
]);
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,4 +29,8 @@ item-pane {
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#batch-edit-prompt-message {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue