/**
* bulkEdit.js — Bulk Edit Tool
*
* Full-screen modal for selecting multiple inventory items and applying
* field changes, copying, or deleting in bulk. Integrates with Numista
* catalog lookup to populate field values.
*
* Selection uses item.serial (stable unique ID) — never array indices.
*/
// =============================================================================
// MODULE STATE
// =============================================================================
let bulkSelection = new Set(); // Set of item serial strings
let bulkFieldValues = {}; // { fieldId: value } for enabled fields
let bulkEnabledFields = new Set(); // Which field checkboxes are checked
let bulkSearchTerm = ''; // Current search/filter text
let bulkSearchTimer = null; // Debounce timer for search input
let bulkSortCol = null; // Column key to sort by, or null
let bulkSortDir = 'asc'; // 'asc' | 'desc'
// Tracks blob URLs created for bulk image thumbnails so we can revoke them
// when the modal closes, preventing memory leaks.
const _bulkBlobUrls = new Set();
// =============================================================================
// SEARCH FILTER HELPER
// =============================================================================
const getFilteredItems = (term) => {
if (typeof inventory === 'undefined' || !Array.isArray(inventory)) return [];
const t = (term || '').toLowerCase().trim();
if (!t) return inventory.slice();
return inventory.filter(item =>
(item.name || '').toLowerCase().includes(t) ||
(item.metal || '').toLowerCase().includes(t) ||
(item.type || '').toLowerCase().includes(t) ||
(item.year || '').toLowerCase().includes(t) ||
(item.storageLocation || '').toLowerCase().includes(t) ||
(item.purchaseLocation || '').toLowerCase().includes(t) ||
String(item.serial).includes(t)
);
};
// =============================================================================
// EDITABLE FIELDS DEFINITION
// =============================================================================
const BULK_EDITABLE_FIELDS = [
{ id: 'name', label: 'Name', inputType: 'text' },
{ id: 'metal', label: 'Metal', inputType: 'select',
options: ['Silver', 'Gold', 'Platinum', 'Palladium'] },
{ id: 'type', label: 'Type', inputType: 'select',
options: ['Coin', 'Bar', 'Round', 'Note', 'Aurum', 'Set', 'Other'] },
{ id: 'qty', label: 'Quantity', inputType: 'number',
attrs: { min: '1', step: '1' } },
{ id: 'weight', label: 'Weight', inputType: 'number',
attrs: { min: '0', step: '0.001' } },
{ id: 'weightUnit', label: 'Weight Unit', inputType: 'select',
options: [
{ value: 'oz', label: 'ounce' },
{ value: 'g', label: 'gram' },
{ value: 'gb', label: 'goldback' }
] },
{ id: 'purity', label: 'Purity', inputType: 'select',
options: [
{ value: '1.0', label: '100% — Pure' },
{ value: '0.9999', label: '.9999 — Four Nines' },
{ value: '0.9995', label: '.9995 — Pure Platinum' },
{ value: '0.999', label: '.999 — Fine' },
{ value: '0.925', label: '.925 — Sterling' },
{ value: '0.9167', label: '.9167 — 22K (Krugerrand)' },
{ value: '0.900', label: '.900 — 90% Silver' },
{ value: '0.800', label: '.800 — 80% (European)' },
{ value: '0.600', label: '.600 — 60%' },
{ value: '0.400', label: '.400 — 40% Silver' },
{ value: '0.350', label: '.350 — War Nickels' },
{ value: 'custom', label: 'Custom…' }
] },
{ id: 'price', label: 'Purchase Price', inputType: 'number',
attrs: { min: '0', step: '0.01' } },
{ id: 'marketValue', label: 'Retail Price', inputType: 'number',
attrs: { min: '0', step: '0.01' } },
{ id: 'year', label: 'Year', inputType: 'text' },
{ id: 'grade', label: 'Grade', inputType: 'select',
options: [
{ value: '', label: '-- None --' },
{ value: 'AG', label: 'AG - About Good' },
{ value: 'G', label: 'G - Good' },
{ value: 'VG', label: 'VG - Very Good' },
{ value: 'F', label: 'F - Fine' },
{ value: 'VF', label: 'VF - Very Fine' },
{ value: 'XF', label: 'XF - Extremely Fine' },
{ value: 'AU', label: 'AU - About Uncirculated' },
{ value: 'UNC', label: 'UNC - Uncirculated' },
{ value: 'BU', label: 'BU - Brilliant Uncirculated' },
{ value: 'MS-60', label: 'MS-60' },
{ value: 'MS-61', label: 'MS-61' },
{ value: 'MS-62', label: 'MS-62' },
{ value: 'MS-63', label: 'MS-63' },
{ value: 'MS-64', label: 'MS-64' },
{ value: 'MS-65', label: 'MS-65' },
{ value: 'MS-66', label: 'MS-66' },
{ value: 'MS-67', label: 'MS-67' },
{ value: 'MS-68', label: 'MS-68' },
{ value: 'MS-69', label: 'MS-69' },
{ value: 'MS-70', label: 'MS-70' },
{ value: 'PF-60', label: 'PF-60' },
{ value: 'PF-61', label: 'PF-61' },
{ value: 'PF-62', label: 'PF-62' },
{ value: 'PF-63', label: 'PF-63' },
{ value: 'PF-64', label: 'PF-64' },
{ value: 'PF-65', label: 'PF-65' },
{ value: 'PF-66', label: 'PF-66' },
{ value: 'PF-67', label: 'PF-67' },
{ value: 'PF-68', label: 'PF-68' },
{ value: 'PF-69', label: 'PF-69' },
{ value: 'PF-70', label: 'PF-70' }
] },
{ id: 'gradingAuthority', label: 'Grading Auth', inputType: 'select',
options: [
{ value: '', label: '-- None --' },
{ value: 'PCGS', label: 'PCGS' },
{ value: 'NGC', label: 'NGC' },
{ value: 'ANACS', label: 'ANACS' },
{ value: 'ICG', label: 'ICG' }
] },
{ id: 'certNumber', label: 'Cert #', inputType: 'text' },
{ id: 'pcgsNumber', label: 'PCGS Number', inputType: 'text' },
{ id: 'purchaseLocation', label: 'Purchase Loc', inputType: 'text' },
{ id: 'storageLocation', label: 'Storage Loc', inputType: 'text' },
{ id: 'date', label: 'Purchase Date', inputType: 'date' },
{ id: 'serialNumber', label: 'Serial Number', inputType: 'text' },
{ id: 'notes', label: 'Notes', inputType: 'textarea' },
{ id: 'numistaId', label: 'Numista #', inputType: 'text' },
{ id: 'obverseImageUrl', label: 'Obverse URL', inputType: 'text',
attrs: { placeholder: 'https://example.com/obverse.jpg' } },
{ id: 'reverseImageUrl', label: 'Reverse URL', inputType: 'text',
attrs: { placeholder: 'https://example.com/reverse.jpg' } },
];
// =============================================================================
// OPEN / CLOSE
// =============================================================================
const openBulkEdit = () => {
const modal = safeGetElement('bulkEditModal');
if (!modal) return;
// Always start with a clean selection (STACK-55)
bulkSelection = new Set();
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
renderBulkFieldPanel();
renderBulkTable();
renderBulkFooter();
// Focus search input after render
const searchInput = safeGetElement('bulkEditSearch');
if (searchInput) searchInput.focus();
};
const closeBulkEdit = () => {
const modal = safeGetElement('bulkEditModal');
if (!modal) return;
// Clear Numista callback
window._bulkEditNumistaCallback = null;
// Revoke all blob URLs created for thumbnails to free memory
_bulkBlobUrls.forEach(u => { try { URL.revokeObjectURL(u); } catch (e) { /* ignore */ } });
_bulkBlobUrls.clear();
modal.style.display = 'none';
document.body.style.overflow = '';
};
// =============================================================================
// HELPER FACTORIES
// =============================================================================
/**
* Creates the appropriate input element for a bulk edit field definition.
* @param {Object} field - Field definition from BULK_EDITABLE_FIELDS
* @returns {HTMLElement} The input/select/textarea element
*/
const createFieldInput = (field) => {
let input;
if (field.inputType === 'select') {
input = document.createElement('select');
field.options.forEach(opt => {
const option = document.createElement('option');
if (typeof opt === 'object' && opt !== null) {
option.value = opt.value;
option.textContent = opt.label;
} else {
option.value = opt;
option.textContent = opt;
}
input.appendChild(option);
});
} else if (field.inputType === 'textarea') {
input = document.createElement('textarea');
input.rows = 2;
} else {
input = document.createElement('input');
input.type = field.inputType;
if (field.attrs) {
Object.keys(field.attrs).forEach(k => input.setAttribute(k, field.attrs[k]));
}
}
input.className = 'field-input';
input.id = 'bulkFieldVal_' + field.id;
return input;
};
/** Coercion rules: fieldId → (rawValue) => coerced value */
const FIELD_COERCIONS = {
qty: (v) => { const n = parseInt(v, 10); return (isNaN(n) || n < 1) ? 1 : n; },
weight: (v) => { const n = parseFloat(v); return (isNaN(n) || n < 0) ? 0 : n; },
price: (v) => { const n = parseFloat(v); return (isNaN(n) || n < 0) ? 0 : n; },
marketValue: (v) => { const n = parseFloat(v); return (isNaN(n) || n < 0) ? 0 : n; },
purity: (v) => { const n = parseFloat(v); return (isNaN(n) || n <= 0 || n > 1) ? 1.0 : n; },
};
/**
* Coerces a bulk edit field value to the correct type based on field ID.
* @param {string} fieldId - The field identifier
* @param {string} value - The raw string value from the input
* @returns {*} The coerced value
*/
const coerceFieldValue = (fieldId, value) => {
const coerce = FIELD_COERCIONS[fieldId];
if (coerce) return coerce(value);
return (typeof value === 'string') ? sanitizeHtml(value) : value;
};
/**
* Builds a table row element for a single inventory item in the bulk edit table.
* @param {Object} item - The inventory item
* @param {boolean} isPinned - Whether the row is in the pinned section
* @returns {HTMLTableRowElement} The constructed row
*/
const buildBulkItemRow = (item, isPinned) => {
const serial = String(item.serial);
const tr = document.createElement('tr');
tr.setAttribute('data-serial', serial);
const isSelected = bulkSelection.has(serial);
if (isSelected) tr.classList.add('bulk-edit-selected');
if (isPinned) tr.classList.add('bulk-edit-pinned');
// Row click toggles selection
tr.addEventListener('click', (e) => {
if (e.target.type === 'checkbox') return;
toggleItemSelection(serial);
});
// Checkbox cell
const cbTd = document.createElement('td');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = isSelected;
cb.addEventListener('change', () => toggleItemSelection(serial));
cbTd.appendChild(cb);
tr.appendChild(cbTd);
// Image thumbnail cell — resolved async from IDB after row is appended
const imgTd = document.createElement('td');
imgTd.className = 'bulk-img-cell';
// Placeholder pair shown until IDB resolves
imgTd.innerHTML = '<span class="bulk-img-placeholder" data-side="obverse"></span>';
// Store item identity for the async loader and upload popover
imgTd.dataset.uuid = item.uuid || '';
imgTd.dataset.numistaId = item.numistaId || '';
imgTd.dataset.itemName = item.name || '';
imgTd.dataset.serial = serial;
imgTd.title = 'Click to manage photos';
imgTd.style.cursor = 'pointer';
imgTd.addEventListener('click', (e) => {
e.stopPropagation();
_openBulkImagePopover(imgTd, item);
});
tr.appendChild(imgTd);
// Data cells
const addCell = (text) => {
const td = document.createElement('td');
td.textContent = text || '';
td.title = text || '';
tr.appendChild(td);
};
addCell(item.name);
addCell(item.metal);
addCell(item.type);
addCell(item.qty != null ? String(item.qty) : '1');
addCell(item.weight != null ? (typeof formatWeight === 'function' ? formatWeight(item.weight, item.weightUnit) : String(item.weight)) : '');
addCell(item.purity != null ? String(item.purity) : '');
addCell(item.year || '');
addCell(item.grade || '');
addCell(item.price != null ? (typeof formatCurrency === 'function' ? formatCurrency(item.price) : String(item.price)) : '');
addCell(item.storageLocation || '');
addCell(item.purchaseLocation || '');
addCell(item.date || '');
return tr;
};
// =============================================================================
// FIELD PANEL (left side)
// =============================================================================
const renderBulkFieldPanel = () => {
const panel = safeGetElement('bulkEditFieldPanel');
if (!panel) return;
// Clear existing content
while (panel.firstChild) panel.removeChild(panel.firstChild);
// Header
const heading = document.createElement('h3');
heading.textContent = 'Fields to Update';
panel.appendChild(heading);
const hint = document.createElement('p');
hint.style.cssText = 'font-size:0.75rem;color:var(--text-secondary);margin:0 0 0.75rem 0;';
hint.textContent = 'Check a field to enable it, then set the value to apply.';
panel.appendChild(hint);
// Build field rows
BULK_EDITABLE_FIELDS.forEach(field => {
const row = document.createElement('div');
row.className = 'bulk-edit-field-row';
// Checkbox
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = 'bulkField_' + field.id;
cb.checked = bulkEnabledFields.has(field.id);
// Label
const lbl = document.createElement('label');
lbl.setAttribute('for', 'bulkField_' + field.id);
lbl.textContent = field.label;
// Input
const input = createFieldInput(field);
input.disabled = !bulkEnabledFields.has(field.id);
// Restore persisted value
if (bulkFieldValues[field.id] !== undefined) {
input.value = bulkFieldValues[field.id];
}
// Checkbox toggle — also re-renders footer to update Apply button disabled state
cb.addEventListener('change', () => {
if (cb.checked) {
bulkEnabledFields.add(field.id);
input.disabled = false;
input.focus();
} else {
bulkEnabledFields.delete(field.id);
input.disabled = true;
}
renderBulkFooter();
});
// Track value changes
input.addEventListener('input', () => {
bulkFieldValues[field.id] = input.value;
});
input.addEventListener('change', () => {
bulkFieldValues[field.id] = input.value;
});
row.appendChild(cb);
row.appendChild(lbl);
row.appendChild(input);
panel.appendChild(row);
});
// Wire up denomination picker swap for weight field (mirrors main modal)
const bwInput = safeGetElement('bulkFieldVal_weight');
const bwUnitSelect = safeGetElement('bulkFieldVal_weightUnit');
const bwLabel = panel.querySelector('label[for="bulkField_weight"]');
const bwCheckbox = safeGetElement('bulkField_weight');
if (bwInput && bwUnitSelect && typeof GOLDBACK_DENOMINATIONS !== 'undefined') {
// Build hidden denomination select
const denomSelect = document.createElement('select');
denomSelect.className = 'field-input';
denomSelect.id = 'bulkFieldVal_weightDenom';
denomSelect.style.display = 'none';
denomSelect.disabled = bwInput.disabled;
GOLDBACK_DENOMINATIONS.forEach(d => {
const opt = document.createElement('option');
opt.value = String(d.weight);
opt.textContent = d.label;
denomSelect.appendChild(opt);
});
// Insert right after weight input in the same row
bwInput.parentNode.insertBefore(denomSelect, bwInput.nextSibling);
// Restore persisted value
if (bulkFieldValues['weight'] !== undefined) {
denomSelect.value = String(bulkFieldValues['weight']);
}
// Track denomination changes → update weight field value
denomSelect.addEventListener('change', () => {
bulkFieldValues['weight'] = denomSelect.value;
});
// Swap function
const toggleBulkGbPicker = () => {
const isGb = bwUnitSelect.value === 'gb';
bwInput.style.display = isGb ? 'none' : '';
denomSelect.style.display = isGb ? '' : 'none';
if (bwLabel) bwLabel.textContent = isGb ? 'Denomination' : 'Weight';
if (isGb) {
denomSelect.disabled = bwInput.disabled;
bulkFieldValues['weight'] = denomSelect.value;
}
};
// Listen for unit changes
bwUnitSelect.addEventListener('change', toggleBulkGbPicker);
// Sync disabled state when weight checkbox toggles
if (bwCheckbox) {
bwCheckbox.addEventListener('change', () => {
denomSelect.disabled = !bwCheckbox.checked;
});
}
// Initialize state (e.g. if weightUnit was persisted as 'gb')
if (bulkFieldValues['weightUnit'] === 'gb') {
bwUnitSelect.value = 'gb';
toggleBulkGbPicker();
}
}
// Wire up custom purity input behavior (matches inventory modal pattern)
const puritySelect = safeGetElement('bulkFieldVal_purity');
const purityCheckbox = safeGetElement('bulkField_purity');
if (puritySelect) {
const purityCustomInput = document.createElement('input');
purityCustomInput.type = 'number';
purityCustomInput.id = 'bulkFieldVal_purityCustom';
purityCustomInput.className = 'field-input';
purityCustomInput.min = '0.001';
purityCustomInput.max = '1';
purityCustomInput.step = '0.0001';
purityCustomInput.placeholder = 'e.g. 0.9995';
purityCustomInput.setAttribute('aria-label', 'Custom purity');
purityCustomInput.style.display = 'none';
purityCustomInput.disabled = puritySelect.disabled;
puritySelect.parentNode.insertBefore(purityCustomInput, puritySelect.nextSibling);
const optionValues = new Set(Array.from(puritySelect.options).map(option => option.value));
const savedPurity = bulkFieldValues.purity;
if (savedPurity !== undefined) {
const savedPurityStr = String(savedPurity);
if (optionValues.has(savedPurityStr) && savedPurityStr !== 'custom') {
puritySelect.value = savedPurityStr;
} else {
puritySelect.value = 'custom';
purityCustomInput.value = savedPurityStr;
}
}
const syncPurityState = () => {
const isCustom = puritySelect.value === 'custom';
purityCustomInput.style.display = isCustom ? '' : 'none';
purityCustomInput.disabled = puritySelect.disabled || !isCustom;
if (isCustom) {
bulkFieldValues.purity = purityCustomInput.value;
} else {
bulkFieldValues.purity = puritySelect.value;
}
};
puritySelect.addEventListener('change', syncPurityState);
purityCustomInput.addEventListener('input', () => {
bulkFieldValues.purity = purityCustomInput.value;
});
purityCustomInput.addEventListener('change', () => {
bulkFieldValues.purity = purityCustomInput.value;
});
if (purityCheckbox) {
purityCheckbox.addEventListener('change', () => {
syncPurityState();
});
}
syncPurityState();
}
};
// =============================================================================
// ITEM TABLE (right side)
// =============================================================================
/**
* Renders the toolbar (search, buttons, badge) — called once on open.
* The toolbar persists across search/selection updates.
*/
const renderBulkToolbar = () => {
const toolbar = safeGetElement('bulkEditToolbar');
if (!toolbar) return;
while (toolbar.firstChild) toolbar.removeChild(toolbar.firstChild);
// Numista Lookup button (left of search)
if (typeof catalogAPI !== 'undefined') {
const numistaBtn = document.createElement('button');
numistaBtn.type = 'button';
numistaBtn.className = 'bulk-edit-numista-btn';
numistaBtn.textContent = 'Numista Lookup';
numistaBtn.title = 'Search Numista catalog and fill field values';
numistaBtn.addEventListener('click', triggerBulkNumistaLookup);
toolbar.appendChild(numistaBtn);
}
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.id = 'bulkEditSearch';
searchInput.placeholder = 'Search items...';
searchInput.value = bulkSearchTerm || '';
searchInput.addEventListener('input', () => {
// Debounce: wait 250ms after last keystroke before filtering
if (bulkSearchTimer) clearTimeout(bulkSearchTimer);
bulkSearchTimer = setTimeout(() => {
bulkSearchTerm = searchInput.value;
renderBulkTableBody();
}, 250);
});
toolbar.appendChild(searchInput);
const selectAllBtn = document.createElement('button');
selectAllBtn.type = 'button';
selectAllBtn.className = 'btn btn-secondary';
selectAllBtn.textContent = 'Select All';
selectAllBtn.addEventListener('click', () => selectAllItems(true));
toolbar.appendChild(selectAllBtn);
const selectNoneBtn = document.createElement('button');
selectNoneBtn.type = 'button';
selectNoneBtn.className = 'btn btn-secondary';
selectNoneBtn.textContent = 'Select None';
selectNoneBtn.addEventListener('click', () => selectAllItems(false));
toolbar.appendChild(selectNoneBtn);
const badge = document.createElement('span');
badge.className = 'bulk-edit-count-badge';
badge.id = 'bulkEditCountBadge';
badge.textContent = bulkSelection.size + ' selected';
toolbar.appendChild(badge);
};
/**
* Renders the table body (rows) — called on search, selection, and data changes.
* Does NOT touch the toolbar, preserving search input focus.
*/
const renderBulkTableBody = () => {
const wrap = safeGetElement('bulkEditTableWrap');
if (!wrap) return;
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
if (typeof inventory === 'undefined' || !Array.isArray(inventory) || inventory.length === 0) {
const empty = document.createElement('p');
empty.style.cssText = 'padding:2rem;text-align:center;color:var(--text-secondary);';
empty.textContent = 'No inventory items found.';
wrap.appendChild(empty);
return;
}
// Filter by search term
const filtered = getFilteredItems(bulkSearchTerm);
const term = (bulkSearchTerm || '').toLowerCase().trim();
// Compute pinned items — selected items NOT in search results (only when search active)
let pinnedItems = [];
if (term) {
const filteredSerials = new Set(filtered.map(i => String(i.serial)));
pinnedItems = inventory.filter(item =>
bulkSelection.has(String(item.serial)) && !filteredSerials.has(String(item.serial))
);
}
const table = document.createElement('table');
table.className = 'bulk-edit-table';
// Column definitions
const columns = [
{ key: 'cb', label: '', nosort: true },
{ key: 'img', label: 'Img', nosort: true },
{ key: 'name', label: 'Name' },
{ key: 'metal', label: 'Metal' },
{ key: 'type', label: 'Type' },
{ key: 'qty', label: 'Qty' },
{ key: 'weight', label: 'Weight' },
{ key: 'purity', label: 'Purity' },
{ key: 'year', label: 'Year' },
{ key: 'grade', label: 'Grade' },
{ key: 'price', label: 'Price' },
{ key: 'storageLocation', label: 'Location' },
{ key: 'purchaseLocation',label: 'Purchased At' },
{ key: 'date', label: 'Date' },
];
const colCount = columns.length;
// Sort filtered items (preserves original array for selection state checks)
const sortedFiltered = bulkSortCol
? [...filtered].sort((a, b) => {
const av = a[bulkSortCol] ?? '';
const bv = b[bulkSortCol] ?? '';
const cmp = (typeof av === 'number' && typeof bv === 'number')
? av - bv
: String(av).localeCompare(String(bv), undefined, { numeric: true });
return bulkSortDir === 'asc' ? cmp : -cmp;
})
: filtered;
// Master checkbox state (based on filtered items only, excludes pinned)
const allFilteredSelected = filtered.length > 0 &&
filtered.every(item => bulkSelection.has(String(item.serial)));
const someFilteredSelected = !allFilteredSelected &&
filtered.some(item => bulkSelection.has(String(item.serial)));
// Thead
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
columns.forEach(col => {
const th = document.createElement('th');
if (col.key === 'cb') {
const masterCb = document.createElement('input');
masterCb.type = 'checkbox';
masterCb.title = 'Toggle all visible';
masterCb.checked = allFilteredSelected;
masterCb.indeterminate = someFilteredSelected;
masterCb.addEventListener('change', () => selectAllItems(masterCb.checked));
th.appendChild(masterCb);
} else if (col.nosort) {
th.textContent = col.label;
} else {
th.textContent = col.label;
th.classList.add('bulk-sortable');
if (bulkSortCol === col.key) {
th.classList.add(bulkSortDir === 'asc' ? 'sort-asc' : 'sort-desc');
}
th.addEventListener('click', () => {
if (bulkSortCol === col.key) {
bulkSortDir = bulkSortDir === 'asc' ? 'desc' : 'asc';
} else {
bulkSortCol = col.key;
bulkSortDir = 'asc';
}
renderBulkTableBody();
});
}
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// Tbody
const tbody = document.createElement('tbody');
// Pinned section (selected items not matching current search)
if (pinnedItems.length > 0) {
// Section header
const headerTr = document.createElement('tr');
headerTr.className = 'bulk-edit-pinned-header';
const headerTd = document.createElement('td');
headerTd.colSpan = colCount;
headerTd.textContent = 'Pinned selections (' + pinnedItems.length + ')';
headerTr.appendChild(headerTd);
tbody.appendChild(headerTr);
// Pinned rows
pinnedItems.forEach(item => {
tbody.appendChild(buildBulkItemRow(item, true));
});
// Divider
const divTr = document.createElement('tr');
divTr.className = 'bulk-edit-pinned-divider';
const divTd = document.createElement('td');
divTd.colSpan = colCount;
divTr.appendChild(divTd);
tbody.appendChild(divTr);
}
// Filtered rows (sorted)
sortedFiltered.forEach(item => {
tbody.appendChild(buildBulkItemRow(item, false));
});
table.appendChild(tbody);
wrap.appendChild(table);
// Update badge count
const badge = safeGetElement('bulkEditCountBadge');
if (badge) badge.textContent = bulkSelection.size + ' selected';
// Async-load images for all rows now that they are in the DOM
const allRows = [...pinnedItems, ...sortedFiltered];
allRows.forEach(item => {
const tr = tbody.querySelector(`tr[data-serial="${CSS.escape(String(item.serial))}"]`);
if (tr) _loadBulkRowImages(tr, item);
});
};
/**
* Full render — toolbar + table body. Called on open and after bulk actions.
*/
const renderBulkTable = () => {
renderBulkToolbar();
renderBulkTableBody();
};
// =============================================================================
// SELECTION MANAGEMENT
// =============================================================================
const toggleItemSelection = (serial) => {
serial = String(serial);
if (bulkSelection.has(serial)) {
bulkSelection.delete(serial);
} else {
bulkSelection.add(serial);
}
// When search is active, pinned rows appear/disappear — full re-render needed
const term = (bulkSearchTerm || '').toLowerCase().trim();
if (term) {
renderBulkTableBody();
} else {
updateBulkSelectionUI();
}
};
const selectAllItems = (select) => {
const filtered = getFilteredItems(bulkSearchTerm);
if (select) {
// Select All: add only filtered (search-matched) items
filtered.forEach(item => bulkSelection.add(String(item.serial)));
} else {
// Deselect All: clear everything including pinned
bulkSelection.clear();
}
renderBulkTableBody();
renderBulkFooter();
};
const updateBulkSelectionUI = () => {
// Update count badge
const badge = safeGetElement('bulkEditCountBadge');
if (badge) badge.textContent = bulkSelection.size + ' selected';
// Targeted row updates via data-serial attribute
const wrap = safeGetElement('bulkEditTableWrap');
if (wrap) {
const rows = wrap.querySelectorAll('tbody tr[data-serial]');
rows.forEach(tr => {
const serial = tr.getAttribute('data-serial');
const isSelected = bulkSelection.has(serial);
const cb = tr.querySelector('input[type="checkbox"]');
if (isSelected) {
tr.classList.add('bulk-edit-selected');
} else {
tr.classList.remove('bulk-edit-selected');
}
if (cb) cb.checked = isSelected;
});
// Update master checkbox — exclude pinned rows from the calculation
const masterCb = wrap.querySelector('thead input[type="checkbox"]');
if (masterCb) {
const filteredRows = wrap.querySelectorAll('tbody tr[data-serial]:not(.bulk-edit-pinned)');
const allSelected = filteredRows.length > 0 &&
Array.from(filteredRows).every(tr => bulkSelection.has(tr.getAttribute('data-serial')));
const someSelected = !allSelected &&
Array.from(filteredRows).some(tr => bulkSelection.has(tr.getAttribute('data-serial')));
masterCb.checked = allSelected;
masterCb.indeterminate = someSelected;
}
}
renderBulkFooter();
};
// =============================================================================
// FOOTER (action buttons)
// =============================================================================
const renderBulkFooter = () => {
const footer = safeGetElement('bulkEditFooter');
if (!footer) return;
while (footer.firstChild) footer.removeChild(footer.firstChild);
const count = bulkSelection.size;
const enabledCount = bulkEnabledFields.size;
// Apply Changes button
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'btn btn-primary';
applyBtn.textContent = 'Apply Changes' + (count ? ' (' + count + ')' : '');
applyBtn.disabled = count === 0 || enabledCount === 0;
applyBtn.title = count === 0 ? 'Select items first' : enabledCount === 0 ? 'Enable at least one field' : '';
applyBtn.addEventListener('click', applyBulkEdit);
footer.appendChild(applyBtn);
// Copy Selected button
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'btn btn-secondary';
copyBtn.textContent = 'Copy Selected' + (count ? ' (' + count + ')' : '');
copyBtn.disabled = count === 0;
copyBtn.addEventListener('click', copySelectedItems);
footer.appendChild(copyBtn);
// Delete Selected button (danger, pushed right)
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn btn-danger';
deleteBtn.textContent = 'Delete Selected' + (count ? ' (' + count + ')' : '');
deleteBtn.disabled = count === 0;
deleteBtn.addEventListener('click', deleteSelectedItems);
footer.appendChild(deleteBtn);
};
// =============================================================================
// CONFIRM HELPER (replaces window.confirm suppressed inside modal context)
// =============================================================================
/**
* Show an inline confirmation modal and return a Promise<boolean>.
* Resolves true on Confirm, false on Cancel or close.
* @param {string} message
* @returns {Promise<boolean>}
*/
const showBulkConfirm = (message) => {
return new Promise(function (resolve) {
var modal = document.getElementById('bulkConfirmModal');
var msgEl = document.getElementById('bulkConfirmMessage');
var okBtn = document.getElementById('bulkConfirmOkBtn');
var canBtn = document.getElementById('bulkConfirmCancelBtn');
if (!modal || !okBtn || !canBtn) { resolve(window.confirm(message)); return; }
if (msgEl) msgEl.textContent = message;
modal.style.display = 'flex';
function cleanup(result) {
modal.style.display = 'none';
okBtn.removeEventListener('click', onOk);
canBtn.removeEventListener('click', onCancel);
resolve(result);
}
function onOk() { cleanup(true); }
function onCancel() { cleanup(false); }
okBtn.addEventListener('click', onOk);
canBtn.addEventListener('click', onCancel);
});
};
// =============================================================================
// BULK ACTIONS
// =============================================================================
const applyBulkEdit = async () => {
const count = bulkSelection.size;
const enabledCount = bulkEnabledFields.size;
if (count === 0 || enabledCount === 0) return;
// Collect current field values from inputs
const valuesToApply = {};
bulkEnabledFields.forEach(fieldId => {
const input = safeGetElement('bulkFieldVal_' + fieldId);
if (input) valuesToApply[fieldId] = input.value;
});
if (bulkEnabledFields.has('purity') && valuesToApply.purity === 'custom') {
const purityCustomInput = safeGetElement('bulkFieldVal_purityCustom');
const rawPurity = purityCustomInput ? purityCustomInput.value.trim() : '';
const numericPurity = Number(rawPurity);
if (!rawPurity || !Number.isFinite(numericPurity) || numericPurity < 0.001 || numericPurity > 1) {
if (typeof showCloudToast === 'function') showCloudToast('Please enter a custom purity between 0.001 and 1 before applying bulk changes.');
return;
}
// Keep the original string; coercion logic will normalize as needed.
valuesToApply.purity = rawPurity;
}
// Convert gram weight to ozt for storage (matches parseWeight in events.js)
if (bulkEnabledFields.has('weight') && valuesToApply.weight !== undefined) {
const unitSelect = safeGetElement('bulkFieldVal_weightUnit');
const effectiveUnit = valuesToApply.weightUnit || (unitSelect ? unitSelect.value : null);
if (effectiveUnit === 'g') {
const grams = parseFloat(valuesToApply.weight);
if (!isNaN(grams)) {
valuesToApply.weight = String(gramsToOzt(grams));
}
}
}
// When gb denomination mode is active, read weight from the denomination picker
// (the hidden number input has stale/empty value).
// Check both: explicit weightUnit in apply set, OR denomination picker visibly active.
if (bulkEnabledFields.has('weight')) {
const denomSelect = safeGetElement('bulkFieldVal_weightDenom');
const unitSelect = safeGetElement('bulkFieldVal_weightUnit');
const isGbMode = (valuesToApply['weightUnit'] === 'gb') ||
(unitSelect && unitSelect.value === 'gb');
if (isGbMode && denomSelect && denomSelect.style.display !== 'none') {
valuesToApply['weight'] = denomSelect.value;
}
}
const fieldNames = [...bulkEnabledFields].map(id => {
const def = BULK_EDITABLE_FIELDS.find(f => f.id === id);
return def ? def.label : id;
}).join(', ');
if (!await showBulkConfirm('Apply ' + enabledCount + ' field(s) (' + fieldNames + ') to ' + count + ' item(s)?')) {
return;
}
let updated = 0;
inventory.forEach(item => {
if (!bulkSelection.has(String(item.serial))) return;
// Snapshot old item for change logging
const oldItem = Object.assign({}, item);
// Apply each enabled field
Object.keys(valuesToApply).forEach(fieldId => {
item[fieldId] = coerceFieldValue(fieldId, valuesToApply[fieldId]);
});
// Log changes for undo support
if (typeof logItemChanges === 'function') {
logItemChanges(oldItem, item);
}
updated++;
});
// Record price data points for bulk-edited items with price-related changes (STACK-43)
if (typeof recordItemPrice === 'function') {
const priceFields = ['price', 'marketValue', 'weight', 'weightUnit', 'qty', 'metal', 'purity'];
if ([...bulkEnabledFields].some(id => priceFields.includes(id))) {
inventory.forEach(item => {
if (bulkSelection.has(String(item.serial))) recordItemPrice(item, 'bulk');
});
saveItemPriceHistory();
}
}
// Persist and re-render
if (typeof saveInventory === 'function') saveInventory();
if (typeof renderTable === 'function') renderTable();
if (typeof renderActiveFilters === 'function') renderActiveFilters();
if (typeof showCloudToast === 'function') showCloudToast('Updated ' + updated + ' item(s).');
// Refresh bulk table to reflect changes
renderBulkTable();
renderBulkFooter();
};
const copySelectedItems = async () => {
const count = bulkSelection.size;
if (count === 0) return;
if (!await showBulkConfirm('Copy ' + count + ' item(s)? New copies will be added to your inventory.')) {
return;
}
let copied = 0;
const serialsToProcess = [...bulkSelection];
serialsToProcess.forEach(serial => {
const item = inventory.find(i => String(i.serial) === serial);
if (!item) return;
// Deep clone
const clone = JSON.parse(JSON.stringify(item));
clone.serial = getNextSerial();
clone.uuid = generateUUID();
inventory.push(clone);
// Record initial price data point for the copy (STACK-43)
if (typeof recordSingleItemPrice === 'function') {
recordSingleItemPrice(clone, 'add');
}
// Log the copy
if (typeof logChange === 'function') {
logChange(clone.name, 'Copied', 'from serial ' + serial, 'new serial ' + clone.serial, inventory.length - 1);
}
copied++;
});
if (typeof saveInventory === 'function') saveInventory();
if (typeof renderTable === 'function') renderTable();
if (typeof showCloudToast === 'function') showCloudToast('Copied ' + copied + ' item(s).');
renderBulkTable();
renderBulkFooter();
};
const deleteSelectedItems = async () => {
const count = bulkSelection.size;
if (count === 0) return;
if (!await showBulkConfirm('Delete ' + count + ' item(s)? You can undo deletions from the Change Log.')) {
return;
}
// Collect indices to delete (sorted descending to avoid splice shift issues)
const indicesToDelete = [];
inventory.forEach((item, idx) => {
if (bulkSelection.has(String(item.serial))) {
indicesToDelete.push(idx);
}
});
indicesToDelete.sort((a, b) => b - a);
indicesToDelete.forEach(idx => {
const item = inventory[idx];
if (typeof logChange === 'function') {
logChange(item.name, 'Deleted', JSON.stringify(item), '', idx);
}
inventory.splice(idx, 1);
});
// Clear deleted serials from selection
indicesToDelete.forEach(() => {
// Already spliced — remove from selection by checking what's left
});
const remaining = new Set(inventory.map(i => String(i.serial)));
bulkSelection.forEach(s => {
if (!remaining.has(s)) bulkSelection.delete(s);
});
if (typeof saveInventory === 'function') saveInventory();
if (typeof renderTable === 'function') renderTable();
if (typeof renderActiveFilters === 'function') renderActiveFilters();
if (typeof showCloudToast === 'function') showCloudToast('Deleted ' + indicesToDelete.length + ' item(s).');
renderBulkTable();
renderBulkFooter();
};
// =============================================================================
// NUMISTA INTEGRATION
// =============================================================================
const triggerBulkNumistaLookup = async () => {
if (!catalogAPI || !catalogAPI.activeProvider) {
if (typeof showCloudToast === 'function') showCloudToast('Configure Numista API key in Settings first.');
return;
}
// Set our callback — fillFormFromNumistaResult checks this before normal form fill
window._bulkEditNumistaCallback = receiveBulkNumistaResult;
// Prompt user for search query
const query = typeof showAppPrompt === 'function'
? await showAppPrompt('Enter a coin name or Numista N# to search:', '', 'Numista Lookup')
: prompt('Enter a coin name or Numista N# to search:');
if (!query || !query.trim()) {
window._bulkEditNumistaCallback = null;
return;
}
// Perform search
const trimmed = query.trim();
const isDirectLookup = /^N?\d+$/i.test(trimmed);
(async () => {
try {
let results;
if (isDirectLookup) {
const result = await catalogAPI.lookupItem(trimmed);
results = result ? [result] : [];
if (typeof showNumistaResults === 'function') {
showNumistaResults(results, true, trimmed);
}
} else {
results = await catalogAPI.searchItems(trimmed, { limit: 20 });
if (typeof showNumistaResults === 'function') {
showNumistaResults(results, false, trimmed);
}
}
} catch (error) {
console.error('Bulk Numista search error:', error);
if (typeof showCloudToast === 'function') showCloudToast('Numista search failed: ' + error.message);
window._bulkEditNumistaCallback = null;
}
})();
};
const receiveBulkNumistaResult = (fieldMap) => {
if (!fieldMap || typeof fieldMap !== 'object') return;
// Populate bulk edit field inputs and enable them
Object.keys(fieldMap).forEach(fieldId => {
const fieldDef = BULK_EDITABLE_FIELDS.find(f => f.id === fieldId);
if (!fieldDef) return;
const input = safeGetElement('bulkFieldVal_' + fieldId);
const cb = safeGetElement('bulkField_' + fieldId);
if (!input) return;
if (fieldId === 'purity' && input.tagName === 'SELECT') {
const optionExists = Array.from(input.options).some(option => option.value === String(fieldMap[fieldId]));
input.value = optionExists ? String(fieldMap[fieldId]) : 'custom';
const purityCustomInput = safeGetElement('bulkFieldVal_purityCustom');
if (purityCustomInput && !optionExists) {
purityCustomInput.value = String(fieldMap[fieldId]);
}
// Enable field and check checkbox before dispatching change event
// so syncPurityState() sees the correct disabled state
input.disabled = false;
bulkFieldValues[fieldId] = fieldMap[fieldId];
bulkEnabledFields.add(fieldId);
if (cb) cb.checked = true;
input.dispatchEvent(new Event('change'));
} else {
input.value = fieldMap[fieldId];
input.disabled = false;
bulkFieldValues[fieldId] = fieldMap[fieldId];
bulkEnabledFields.add(fieldId);
if (cb) cb.checked = true;
}
});
// Update footer to reflect newly enabled fields
renderBulkFooter();
// Clear the callback
window._bulkEditNumistaCallback = null;
};
// =============================================================================
// IMAGE LOADING & UPLOAD
// =============================================================================
/**
* Reads tableImageSides setting and returns which sides to display.
* @returns {{ showObv: boolean, showRev: boolean }}
*/
const _getBulkImageSides = () => {
const sides = localStorage.getItem('tableImageSides') || 'both';
return {
showObv: sides === 'both' || sides === 'obverse',
showRev: sides === 'both' || sides === 'reverse',
};
};
/**
* Resolves IDB images for one item and injects <img> elements into its
* IMG cell, replacing the placeholder. Respects tableImageSides setting.
* Blob URLs are tracked in _bulkBlobUrls for cleanup on modal close.
*
* @param {HTMLTableRowElement} tr
* @param {Object} item
*/
const _loadBulkRowImages = async (tr, item) => {
const imgTd = tr.querySelector('.bulk-img-cell');
if (!imgTd) return;
// IDB unavailable (e.g. file:// protocol) — fall back to URL strings only
if (!window.imageCache?.isAvailable()) {
const { showObv, showRev } = _getBulkImageSides();
imgTd.innerHTML = '';
if (showObv && item.obverseImageUrl) {
const img = document.createElement('img');
img.src = item.obverseImageUrl; img.alt = ''; img.className = 'bulk-img-thumb'; img.dataset.side = 'obverse';
img.onerror = () => { img.style.display = 'none'; };
imgTd.appendChild(img);
}
if (showRev && item.reverseImageUrl) {
const img = document.createElement('img');
img.src = item.reverseImageUrl; img.alt = ''; img.className = 'bulk-img-thumb'; img.dataset.side = 'reverse';
img.onerror = () => { img.style.display = 'none'; };
imgTd.appendChild(img);
}
if (!imgTd.querySelector('img')) imgTd.innerHTML = '<span class="bulk-img-placeholder"></span>';
return;
}
const { showObv, showRev } = _getBulkImageSides();
// Resolve best source via the same cascade inventory table uses
const resolved = await imageCache.resolveImageForItem(item);
if (!tr.isConnected) return;
/**
* Get a URL for one side from the resolved source.
* @param {'obverse'|'reverse'} side
* @returns {Promise<string|null>}
*/
const _getUrl = async (side) => {
if (!resolved) {
// Fall back to item.obverseImageUrl / item.reverseImageUrl strings
return side === 'obverse' ? (item.obverseImageUrl || null) : (item.reverseImageUrl || null);
}
let url = null;
if (resolved.source === 'user') {
url = await imageCache.getUserImageUrl(item.uuid, side);
} else if (resolved.source === 'pattern') {
url = await imageCache.getPatternImageUrl(resolved.catalogId, side);
} else if (resolved.source === 'numista') {
url = await imageCache.getImageUrl(resolved.catalogId, side);
}
if (url) _bulkBlobUrls.add(url);
return url;
};
const obvUrl = showObv ? await _getUrl('obverse') : null;
const revUrl = showRev ? await _getUrl('reverse') : null;
// Build replacement content
imgTd.innerHTML = '';
const _makeImg = (url, side) => {
const img = document.createElement('img');
img.alt = '';
img.className = 'bulk-img-thumb';
img.dataset.side = side;
if (url) {
img.src = url;
img.onerror = () => { img.style.display = 'none'; };
} else {
img.style.display = 'none';
}
return img;
};
const _makePh = () => {
const ph = document.createElement('span');
ph.className = 'bulk-img-placeholder';
return ph;
};
const hasAny = obvUrl || revUrl;
if (showObv) imgTd.appendChild(obvUrl ? _makeImg(obvUrl, 'obverse') : _makePh());
if (showRev && (revUrl || (resolved && resolved.source === 'user'))) {
imgTd.appendChild(revUrl ? _makeImg(revUrl, 'reverse') : _makePh());
}
if (!hasAny) {
// Nothing resolved — ensure at least one placeholder is visible
if (!imgTd.querySelector('.bulk-img-placeholder')) imgTd.appendChild(_makePh());
}
};
/**
* Opens a small inline image-management popover anchored to the IMG cell.
* Lets the user upload obverse/reverse photos or remove existing ones for
* a single item. Saves directly to imageCache and refreshes that row.
*
* @param {HTMLTableDataCellElement} imgTd
* @param {Object} item
*/
const _openBulkImagePopover = (imgTd, item) => {
// Remove any existing popover first
const existing = document.getElementById('bulkImagePopover');
if (existing) {
existing.remove();
// If clicking the same cell again, just close
if (existing.dataset.forSerial === String(item.serial)) return;
}
const { showObv, showRev } = _getBulkImageSides();
const pop = document.createElement('div');
pop.id = 'bulkImagePopover';
pop.className = 'bulk-img-popover';
pop.dataset.forSerial = String(item.serial);
const _sideHtml = (key, label) => `
<div class="bulk-img-popover-side">
<span class="bulk-img-popover-label">${label}</span>
<div class="bulk-img-popover-preview" id="bulkPop${key}Preview"></div>
<div class="bulk-img-popover-actions">
<input type="file" id="bulkPop${key}File" accept="image/jpeg,image/png,image/webp" style="display:none" />
<button class="btn btn-sm" id="bulkPop${key}Upload" type="button">Upload</button>
<button class="btn btn-sm btn-danger" id="bulkPop${key}Remove" type="button" style="display:none">Remove</button>
</div>
</div>`;
pop.innerHTML = `
<div class="bulk-img-popover-header">
<span class="bulk-img-popover-title">Photos</span>
<button class="bulk-img-popover-close" type="button" aria-label="Close">×</button>
</div>
<div class="bulk-img-popover-sides">
${showObv ? _sideHtml('Obv', 'Obverse') : ''}
${showRev ? _sideHtml('Rev', 'Reverse') : ''}
</div>
`;
// Position below the cell
document.body.appendChild(pop);
const rect = imgTd.getBoundingClientRect();
const popW = 260;
// position: fixed — coords are viewport-relative, no scroll offset needed
let left = rect.left;
if (left + popW > window.innerWidth - 8) left = window.innerWidth - popW - 8;
let top = rect.bottom + 4;
// Flip above cell if popover would overflow viewport bottom
if (top + 280 > window.innerHeight) top = rect.top - 284;
pop.style.top = Math.max(4, top) + 'px';
pop.style.left = Math.max(4, left) + 'px';
// --- Close ---
const closePopover = () => pop.remove();
pop.querySelector('.bulk-img-popover-close').addEventListener('click', closePopover);
const _outsideClick = (e) => {
if (!pop.contains(e.target) && e.target !== imgTd) {
closePopover();
document.removeEventListener('click', _outsideClick, true);
}
};
setTimeout(() => document.addEventListener('click', _outsideClick, true), 10);
// --- Load existing images into previews ---
const _loadPreview = async (previewEl, removeBtn, side) => {
let url = null;
let source = null;
if (window.imageCache?.isAvailable()) {
const rec = await imageCache.resolveImageForItem(item);
source = rec?.source || null;
if (source === 'user') {
url = await imageCache.getUserImageUrl(item.uuid, side);
} else if (source === 'pattern') {
url = await imageCache.getPatternImageUrl(rec.catalogId, side);
} else if (source === 'numista') {
url = await imageCache.getImageUrl(rec.catalogId, side);
}
}
if (!url) {
url = side === 'obverse' ? (item.obverseImageUrl || null) : (item.reverseImageUrl || null);
}
if (!url && imgTd) {
const rowThumb = imgTd.querySelector(`img.bulk-img-thumb[data-side="${side}"]`);
if (rowThumb && rowThumb.src) {
url = rowThumb.src;
}
}
if (url) {
_bulkBlobUrls.add(url);
const img = document.createElement('img');
img.src = url;
img.alt = side;
img.className = 'bulk-img-popover-img';
img.onerror = () => { img.style.display = 'none'; };
previewEl.innerHTML = '';
previewEl.appendChild(img);
// "Remove" only applies to user-uploaded images stored in userImages.
removeBtn.style.display = source === 'user' ? '' : 'none';
} else {
previewEl.innerHTML = '<span class="thumb-popover-empty">No image</span>';
removeBtn.style.display = 'none';
}
};
const obvPreview = pop.querySelector('#bulkPopObvPreview');
const revPreview = pop.querySelector('#bulkPopRevPreview');
const obvRemove = pop.querySelector('#bulkPopObvRemove');
const revRemove = pop.querySelector('#bulkPopRevRemove');
if (showObv) _loadPreview(obvPreview, obvRemove, 'obverse');
if (showRev) _loadPreview(revPreview, revRemove, 'reverse');
// --- Upload handlers ---
const _handleUpload = async (file, side) => {
if (!file || typeof imageProcessor === 'undefined') return;
const result = await imageProcessor.processFile(file, {
maxDim: typeof IMAGE_MAX_DIM !== 'undefined' ? IMAGE_MAX_DIM : 600,
maxBytes: typeof IMAGE_MAX_BYTES !== 'undefined' ? IMAGE_MAX_BYTES : 512000,
});
if (!result?.blob) return;
// Merge with existing (keep the other side if present)
let obvBlob = side === 'obverse' ? result.blob : null;
let revBlob = side === 'reverse' ? result.blob : null;
try {
const existing = await imageCache.getUserImage(item.uuid);
if (existing) {
if (!obvBlob && existing.obverse) obvBlob = existing.obverse;
if (!revBlob && existing.reverse) revBlob = existing.reverse;
}
} catch (e) { /* ignore */ }
if (!obvBlob && revBlob) { obvBlob = revBlob; revBlob = null; }
await imageCache.cacheUserImage(item.uuid, obvBlob, revBlob);
// Refresh the preview in the popover
const previewEl = side === 'obverse' ? obvPreview : revPreview;
const removeBtn = side === 'obverse' ? obvRemove : revRemove;
const previewUrl = URL.createObjectURL(result.blob);
_bulkBlobUrls.add(previewUrl);
previewEl.innerHTML = `<img src="${previewUrl}" alt="${side}" class="bulk-img-popover-img" />`;
removeBtn.style.display = '';
// Refresh the row thumbnail
const tr = imgTd.closest('tr');
if (tr) _loadBulkRowImages(tr, item);
};
const _wireUpload = (btnId, fileId, side) => {
const btn = pop.querySelector('#' + btnId);
const file = pop.querySelector('#' + fileId);
if (!btn || !file) return;
btn.addEventListener('click', () => file.click());
file.addEventListener('change', () => { if (file.files[0]) _handleUpload(file.files[0], side); });
};
if (showObv) _wireUpload('bulkPopObvUpload', 'bulkPopObvFile', 'obverse');
if (showRev) _wireUpload('bulkPopRevUpload', 'bulkPopRevFile', 'reverse');
// --- Remove handlers ---
const _handleRemove = async (side) => {
if (!window.imageCache?.isAvailable()) return;
const existing = await imageCache.getUserImage(item.uuid);
if (!existing) return;
const keepObv = side === 'reverse' ? existing.obverse : null;
const keepRev = side === 'obverse' ? existing.reverse : null;
if (!keepObv && !keepRev) {
await imageCache.deleteUserImage(item.uuid);
} else {
const obvToSave = keepObv || keepRev;
const revToSave = keepObv ? keepRev : null;
await imageCache.cacheUserImage(item.uuid, obvToSave, revToSave);
}
const previewEl = side === 'obverse' ? obvPreview : revPreview;
const removeBtn = side === 'obverse' ? obvRemove : revRemove;
previewEl.innerHTML = '<span class="thumb-popover-empty">No image</span>';
removeBtn.style.display = 'none';
const tr = imgTd.closest('tr');
if (tr) _loadBulkRowImages(tr, item);
};
if (obvRemove) obvRemove.addEventListener('click', () => _handleRemove('obverse'));
if (revRemove) revRemove.addEventListener('click', () => _handleRemove('reverse'));
};
// =============================================================================
// WINDOW EXPORTS
// =============================================================================
window.openBulkEdit = openBulkEdit;
window.closeBulkEdit = closeBulkEdit;