// NUMISTA BULK SYNC UI (STACK-87/88)
// =============================================================================
// Inline UI within Settings > API > Numista card for bulk syncing metadata.
// Shows stats, eligible item table with per-row status, real-time activity log,
// and sync controls. Resolves catalog IDs from catalogManager.
// Image caching is handled on-demand by the view modal (viewModal.js).
// =============================================================================
/** @type {Map<string, HTMLElement>} Status cells keyed by catalogId for live updates */
let _statusCells = new Map();
/**
* Renders the Numista Bulk Sync inline UI: stats, eligible items table.
* Called when the Numista provider tab is shown and conditions are met.
*/
const renderNumistaSyncUI = async () => {
_statusCells.clear();
await renderSyncStats();
await renderEligibleItemsTable();
};
// ---------------------------------------------------------------------------
// Stats bar
// ---------------------------------------------------------------------------
/**
* Renders the cache statistics bar: count, total size, quota percentage.
*/
const renderSyncStats = async () => {
const container = document.getElementById('numistaSyncStats');
if (!container) return;
if (!window.imageCache?.isAvailable()) {
container.textContent = 'Image cache not available';
return;
}
const usage = await imageCache.getStorageUsage();
const sizeMb = (usage.totalBytes / (1024 * 1024)).toFixed(2);
const limitMb = (usage.limitBytes / (1024 * 1024)).toFixed(0);
const pct = usage.limitBytes > 0 ? ((usage.totalBytes / usage.limitBytes) * 100).toFixed(1) : '0.0';
// Count eligible items for the summary line
const eligible = window.BulkImageCache ? BulkImageCache.buildEligibleList() : [];
container.textContent = '';
const statsText = document.createElement('span');
statsText.textContent = `${usage.count} cached \u00b7 ${eligible.length} eligible \u00b7 ${sizeMb} MB / ${limitMb} MB (${pct}%)`;
container.appendChild(statsText);
const bar = document.createElement('progress');
bar.value = usage.totalBytes;
bar.max = usage.limitBytes;
bar.style.cssText = 'width:100%;margin-top:0.35rem;';
container.appendChild(bar);
};
// ---------------------------------------------------------------------------
// Eligible items table (shows all N# items with cache status)
// ---------------------------------------------------------------------------
/**
* Renders the table of all inventory items that have Numista catalog IDs.
* Each row shows: N#, item name, cache status, and action buttons.
* Status cells are tracked in _statusCells for live updates during bulk sync.
*/
const renderEligibleItemsTable = async () => {
const container = document.getElementById('numistaSyncTableContainer');
if (!container) return;
container.textContent = '';
_statusCells.clear();
if (!window.imageCache?.isAvailable()) {
const empty = document.createElement('div');
empty.className = 'chip-grouping-empty';
empty.textContent = 'Image cache not available';
container.appendChild(empty);
return;
}
// Get eligible items from BulkImageCache (resolves catalogManager mappings)
const entries = window.BulkImageCache ? BulkImageCache.buildEligibleList() : [];
if (!entries.length) {
const empty = document.createElement('div');
empty.className = 'chip-grouping-empty';
empty.textContent = 'No items with Numista catalog IDs found';
container.appendChild(empty);
return;
}
const table = document.createElement('table');
table.className = 'chip-grouping-table';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['N#', 'Item Name', 'Status', ''].forEach(text => {
const th = document.createElement('th');
th.textContent = text;
th.style.cssText = 'font-size:0.75rem;font-weight:normal;opacity:0.6;padding:0.2rem 0.4rem';
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const { item, catalogId } of entries) {
const hasMeta = !!(await imageCache.getMetadata(catalogId));
const tr = document.createElement('tr');
// Catalog ID cell
const tdId = document.createElement('td');
tdId.style.cssText = 'font-family:monospace;font-size:0.85rem;white-space:nowrap';
tdId.textContent = catalogId;
// Item name cell
const tdName = document.createElement('td');
tdName.textContent = item.name || '\u2014';
tdName.style.cssText = 'max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
// Status cell (updated live during bulk sync)
const tdStatus = document.createElement('td');
tdStatus.style.cssText = 'font-size:0.8rem;white-space:nowrap';
if (hasMeta) {
tdStatus.textContent = '\u2713 Synced';
tdStatus.style.color = 'var(--success-color, green)';
} else {
tdStatus.textContent = 'Needs sync';
tdStatus.style.color = 'var(--warning-color, orange)';
}
_statusCells.set(catalogId, tdStatus);
// Actions cell
const tdActions = document.createElement('td');
tdActions.style.cssText = 'white-space:nowrap;text-align:right';
if (hasMeta) {
// Re-sync button
const syncBtn = document.createElement('button');
syncBtn.type = 'button';
syncBtn.className = 'inline-chip-move';
syncBtn.textContent = '\u21BB';
syncBtn.title = 'Re-sync';
syncBtn.addEventListener('click', async () => {
syncBtn.disabled = true;
await resyncCachedEntry(catalogId);
syncBtn.disabled = false;
await renderEligibleItemsTable();
await renderSyncStats();
});
// Delete button
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'inline-chip-move';
delBtn.textContent = '\u2715';
delBtn.title = 'Delete cached data';
delBtn.addEventListener('click', async () => {
await imageCache.deleteImages(catalogId);
logSyncActivity(`Deleted cache for ${catalogId}`, 'warn');
await renderEligibleItemsTable();
await renderSyncStats();
});
tdActions.append(syncBtn, delBtn);
}
tr.append(tdId, tdName, tdStatus, tdActions);
tbody.appendChild(tr);
}
table.appendChild(tbody);
container.appendChild(table);
};
/**
* Re-syncs a single cached entry: deletes metadata then re-fetches from Numista API.
* Image caching is handled on-demand by the view modal.
* @param {string} catalogId
*/
const resyncCachedEntry = async (catalogId) => {
const item = (typeof inventory !== 'undefined' ? inventory : []).find(i => {
const resolved = window.BulkImageCache ? BulkImageCache.resolveCatalogId(i) : '';
return resolved === catalogId;
});
// Delete both images and metadata for a clean re-sync
await imageCache.deleteImages(catalogId);
await imageCache.deleteMetadata(catalogId);
// Fetch metadata + image URLs from Numista API
if (window.catalogAPI) {
logSyncActivity(`${catalogId}: Re-syncing metadata from Numista...`, 'info');
try {
const result = await catalogAPI.lookupItem(catalogId);
if (item) {
if (result?.imageUrl && !item.obverseImageUrl) item.obverseImageUrl = result.imageUrl;
if (result?.reverseImageUrl && !item.reverseImageUrl) item.reverseImageUrl = result.reverseImageUrl;
if (result?.tags && result.tags.length > 0 && typeof applyNumistaTags === 'function') {
const allItems = typeof inventory !== 'undefined' ? inventory : [];
allItems.forEach(invItem => {
const resolved = window.BulkImageCache ? BulkImageCache.resolveCatalogId(invItem) : '';
if (resolved === catalogId && invItem.uuid) {
applyNumistaTags(invItem.uuid, result.tags);
}
});
}
if (typeof saveInventory === 'function') saveInventory();
}
await imageCache.cacheMetadata(catalogId, result);
logSyncActivity(`${catalogId}: Metadata re-synced`, 'success');
} catch (err) {
logSyncActivity(`${catalogId}: API lookup failed: ${err.message}`, 'error');
}
} else {
logSyncActivity(`${catalogId}: Catalog API not available`, 'error');
}
};
// ---------------------------------------------------------------------------
// Activity log
// ---------------------------------------------------------------------------
/**
* Appends a timestamped line to the activity log and auto-scrolls.
* @param {string} message
* @param {'info'|'success'|'warn'|'error'} [type='info']
*/
const logSyncActivity = (message, type = 'info') => {
const logEl = document.getElementById('numistaSyncLog');
if (!logEl) return;
const line = document.createElement('div');
line.style.cssText = 'font-size:0.8rem;font-family:monospace;padding:0.1rem 0;';
const colorMap = { info: 'inherit', success: 'var(--success-color, green)', warn: 'var(--warning-color, orange)', error: 'var(--danger-color, red)' };
line.style.color = colorMap[type] || 'inherit';
const now = new Date();
const ts = typeof formatTimeOnly === 'function' ? formatTimeOnly(now) : now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
line.textContent = `[${ts}] ${message}`;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
};
/**
* Updates a status cell in the eligible items table for live feedback.
* @param {string} catalogId
* @param {string} text
* @param {string} color - CSS color value
*/
const updateStatusCell = (catalogId, text, color) => {
const cell = _statusCells.get(catalogId);
if (!cell) return;
cell.textContent = text;
cell.style.color = color;
};
// ---------------------------------------------------------------------------
// Bulk sync from inline UI
// ---------------------------------------------------------------------------
/**
* Starts the bulk sync operation from the inline Numista card.
* Updates per-row status cells in real time as items are processed.
*/
const startBulkSync = () => {
if (!window.BulkImageCache || BulkImageCache.isRunning()) return;
const startBtn = document.getElementById('numistaSyncStartBtn');
const cancelBtn = document.getElementById('numistaSyncCancelBtn');
const progressBar = document.getElementById('numistaSyncProgress');
if (startBtn) startBtn.disabled = true;
if (cancelBtn) cancelBtn.style.display = '';
if (progressBar) {
progressBar.style.display = '';
progressBar.value = 0;
progressBar.max = 0;
}
logSyncActivity('Starting metadata sync...', 'info');
const statusColorMap = {
'skip-cached': ['var(--success-color, green)', '\u2713 Synced'],
'api-lookup': ['var(--text-secondary, #888)', 'Syncing...'],
'metadata': ['var(--success-color, green)', '\u2713 Synced'],
'meta-failed': ['var(--warning-color, orange)', '\u26a0 Failed'],
};
BulkImageCache.cacheAll({
onProgress: ({ current, total }) => {
if (progressBar) {
progressBar.max = total;
progressBar.value = current;
}
},
onLog: ({ catalogId, status, message }) => {
// Update the table row status cell
const [color, label] = statusColorMap[status] || ['inherit', status];
updateStatusCell(catalogId, label, color);
// Log to activity log
const logTypeMap = {
'skip-cached': 'info', 'api-lookup': 'info',
'metadata': 'success', 'meta-failed': 'warn',
};
logSyncActivity(`${catalogId}: ${message}`, logTypeMap[status] || 'info');
},
onComplete: async ({ synced, skipped, failed, apiLookups, elapsed }) => {
if (startBtn) startBtn.disabled = false;
if (cancelBtn) cancelBtn.style.display = 'none';
if (progressBar) progressBar.style.display = 'none';
const secs = (elapsed / 1000).toFixed(1);
let msg = `Complete in ${secs}s: ${synced} synced, ${skipped} skipped, ${failed} failed`;
if (apiLookups > 0) msg += `, ${apiLookups} API calls`;
msg += '.';
logSyncActivity(msg, failed > 0 ? 'warn' : 'success');
// Refresh table and stats
await renderEligibleItemsTable();
await renderSyncStats();
// Refresh Numista usage bar + settings footer storage display
if (typeof renderNumistaUsageBar === 'function') renderNumistaUsageBar();
if (typeof updateSettingsFooter === 'function') updateSettingsFooter();
// Refresh filter chips so newly-applied tags appear immediately
if (typeof renderActiveFilters === 'function') renderActiveFilters();
}
});
};
/**
* Clears all cached images and metadata after confirmation.
*/
const clearAllCachedData = async () => {
if (!window.imageCache?.isAvailable()) return;
const usage = await imageCache.getStorageUsage();
if (usage.count === 0) {
logSyncActivity('No cached data to clear', 'info');
return;
}
// eslint-disable-next-line no-restricted-globals
if (!confirm(`Delete all ${usage.count} cached entries (images + metadata)? This cannot be undone.`)) return;
const ok = await imageCache.clearAll();
if (ok) {
logSyncActivity(`Cleared all ${usage.count} cached entries`, 'warn');
} else {
logSyncActivity('Failed to clear cache', 'error');
}
await renderEligibleItemsTable();
await renderSyncStats();
if (typeof updateSettingsFooter === 'function') updateSettingsFooter();
};
// ---------------------------------------------------------------------------
// Global exports
// ---------------------------------------------------------------------------
if (typeof window !== 'undefined') {
window.renderNumistaSyncUI = renderNumistaSyncUI;
window.startBulkSync = startBulkSync;
window.clearAllCachedData = clearAllCachedData;
}