// CATALOG MANAGER
/**
* CatalogManager class for managing catalog mappings between item serials and catalog IDs
*
* This is an enhanced implementation that replaces the basic catalogMap object with
* a robust class providing better data integrity, validation, and future extensibility.
*
* Key features:
* - Data validation and integrity checking
* - Synchronization between item.numistaId and mapping data
* - Storage optimization to reduce localStorage footprint
* - Provider-agnostic architecture for future catalog support
* - Full backward compatibility with existing data
*/
class CatalogManager {
/**
* Removes mappings for serials that no longer exist in inventory.
* @param {Array} currentInventory - inventory array (objects with serial)
* @returns {number} count removed
*/
removeOrphanedMappings(currentInventory = null) {
try {
if (!currentInventory && window.inventory) currentInventory = window.inventory;
const validSerials = new Set((currentInventory || []).map(it => it.serial));
const before = Object.keys(this._mappings).length;
for (const serial of Object.keys(this._mappings)) {
if (!validSerials.has(serial)) delete this._mappings[serial];
}
const removed = before - Object.keys(this._mappings).length;
if (removed) this._save();
return removed;
} catch (e) {
console.warn('CatalogManager.removeOrphanedMappings error', e);
return 0;
}
}
/**
* Deduplicates identical provider mappings across serials (noop-safe).
* Returns number of entries simplified (kept first occurrence).
*/
deduplicateMappings() {
try {
const seen = new Map();
let reduced = 0;
for (const [serial, map] of Object.entries(this._mappings)) {
const key = JSON.stringify(map);
if (seen.has(key)) {
// keep mapping but no special action; placeholder for future cross-ref
continue;
}
seen.set(key, serial);
}
// Nothing to change structurally; method kept for API compatibility and metrics
return reduced;
} catch (e) {
console.warn('CatalogManager.deduplicateMappings error', e);
return 0;
}
}
/**
* Returns basic storage stats for the catalog mapping blob.
*/
getStorageStats() {
try {
const raw = localStorage.getItem(this.storageKey) || "";
const bytes = new Blob([raw]).size;
return { key: this.storageKey, bytes, kb: +(bytes/1024).toFixed(2), entries: Object.keys(this._mappings).length };
} catch (e) {
return { key: this.storageKey, bytes: 0, kb: 0, entries: 0 };
}
}
/**
* Creates a new CatalogManager instance
*
* @param {Object} options - Configuration options
* @param {string} options.storageKey - LocalStorage key for catalog data
* @param {Function} options.saveCallback - Optional callback when data is saved
* @param {boolean} options.debug - Enable debug logging
*/
constructor(options = {}) {
// Configuration
this.storageKey = options.storageKey || CATALOG_MAP_KEY;
this.saveCallback = options.saveCallback || null;
this.debug = options.debug || DEBUG;
// Internal state
this._mappings = {}; // Serial to catalog ID mappings
this._providers = {
numista: { prefix: 'n', name: 'Numista' }
};
this._initialized = false;
// Initialize data from storage
this._load();
}
/**
* Load mappings from localStorage
*
* @private
*/
_load() {
try {
// Load legacy data
const legacyData = loadDataSync(this.storageKey, {});
// Initialize with legacy format for backward compatibility
this._mappings = { ...legacyData };
this._initialized = true;
if (this.debug) {
console.log(`CatalogManager: Loaded ${Object.keys(this._mappings).length} mappings`);
}
} catch (error) {
console.error('CatalogManager: Error loading data', error);
this._mappings = {};
this._initialized = true;
}
}
/**
* Save mappings to localStorage
*
* @private
*/
_save() {
try {
// Save in legacy format for backward compatibility
saveData(this.storageKey, this._mappings);
if (this.saveCallback && typeof this.saveCallback === 'function') {
this.saveCallback();
}
if (this.debug) {
console.log(`CatalogManager: Saved ${Object.keys(this._mappings).length} mappings`);
}
} catch (error) {
console.error('CatalogManager: Error saving data', error);
}
}
/**
* Validates a catalog ID format
*
* @param {string} catalogId - Catalog ID to validate
* @param {string} [provider='numista'] - Provider key
* @returns {boolean} True if valid
*/
validateCatalogId(catalogId, provider = 'numista') {
if (!catalogId) return false;
// Numista IDs are numeric
if (provider === 'numista') {
return /^\d+$/.test(catalogId);
}
// Generic validation for other providers
return typeof catalogId === 'string' && catalogId.trim().length > 0;
}
/**
* Gets catalog ID for an item serial
*
* @param {number|string} serial - Item serial number
* @param {string} [provider='numista'] - Provider key
* @returns {string|null} Catalog ID or null if not found
*/
getCatalogId(serial, provider = 'numista') {
if (!serial) return null;
// For backward compatibility, directly return the mapping
const serialKey = String(serial);
return this._mappings[serialKey] || null;
}
/**
* Sets catalog ID for an item serial
*
* @param {number|string} serial - Item serial number
* @param {string} catalogId - Catalog ID to set
* @param {string} [provider='numista'] - Provider key
* @returns {boolean} True if successful
*/
setCatalogId(serial, catalogId, provider = 'numista') {
if (!serial) return false;
const serialKey = String(serial);
// Handle removal case
if (!catalogId || catalogId === '') {
delete this._mappings[serialKey];
this._save();
return true;
}
// Validate the catalog ID
if (!this.validateCatalogId(catalogId, provider)) {
console.warn(`CatalogManager: Invalid catalog ID format for ${provider}: ${catalogId}`);
return false;
}
// Store mapping
this._mappings[serialKey] = catalogId;
this._save();
return true;
}
/**
* Gets item serials associated with a catalog ID
*
* @param {string} catalogId - Catalog ID to look up
* @param {string} [provider='numista'] - Provider key
* @returns {Array<string>} Array of serials (may be empty)
*/
getSerialsByCatalogId(catalogId, provider = 'numista') {
if (!catalogId) return [];
return Object.entries(this._mappings)
.filter(([_, value]) => value === catalogId)
.map(([serial, _]) => serial);
}
/**
* Synchronizes an inventory item with the catalog mapping
*
* @param {Object} item - Inventory item
* @returns {Object} Updated item
*/
syncItem(item) {
if (!item) return item;
// If item has no serial, can't sync
if (!item.serial) return item;
const serialKey = String(item.serial);
// Case 1: Item has numistaId but no mapping exists
if (item.numistaId && !this._mappings[serialKey]) {
this.setCatalogId(serialKey, item.numistaId);
}
// Case 2: Item has no numistaId but mapping exists
else if (!item.numistaId && this._mappings[serialKey]) {
item.numistaId = this._mappings[serialKey];
}
// Case 3: Both exist but different - prioritize item.numistaId
else if (item.numistaId && this._mappings[serialKey] &&
item.numistaId !== this._mappings[serialKey]) {
this.setCatalogId(serialKey, item.numistaId);
}
return item;
}
/**
* Synchronizes all inventory items with catalog mappings
*
* @param {Array<Object>} items - Array of inventory items
* @returns {Array<Object>} Updated items
*/
syncInventory(items) {
if (!Array.isArray(items)) return items;
return items.map(item => this.syncItem(item));
}
/**
* Removes orphaned mappings that don't correspond to inventory items
*
* @param {Array<Object>} inventory - Current inventory items
* @returns {number} Number of mappings removed
*/
cleanupOrphans(inventory) {
if (!Array.isArray(inventory)) return 0;
const validSerials = new Set(inventory.map(item => String(item.serial)));
const orphanedSerials = Object.keys(this._mappings)
.filter(serial => !validSerials.has(serial));
orphanedSerials.forEach(serial => {
delete this._mappings[serial];
});
if (orphanedSerials.length > 0) {
this._save();
}
return orphanedSerials.length;
}
/**
* Gets serialized export of all mappings for backup
*
* @returns {Object} Mappings data
*/
exportMappings() {
return { ...this._mappings };
}
/**
* Imports mappings from backup data
*
* @param {Object} mappings - Mappings data
* @param {boolean} [merge=false] - Merge with existing data instead of replacing
* @returns {number} Number of mappings imported
*/
importMappings(mappings, merge = false) {
if (!mappings || typeof mappings !== 'object') return 0;
if (merge) {
// Merge with existing mappings
this._mappings = { ...this._mappings, ...mappings };
} else {
// Replace all mappings
this._mappings = { ...mappings };
}
this._save();
return Object.keys(mappings).length;
}
/**
* Gets summary statistics about mappings
*
* @returns {Object} Statistics object
*/
getStats() {
const totalMappings = Object.keys(this._mappings).length;
// Count by provider (for future use)
const providerCounts = { numista: totalMappings };
return {
totalMappings,
providerCounts,
storageSize: JSON.stringify(this._mappings).length
};
}
}
// Initialize global CatalogManager instance to replace the global catalogMap
const catalogManager = new CatalogManager({
storageKey: CATALOG_MAP_KEY,
debug: DEBUG,
saveCallback: () => {
if (typeof updateStorageStats === 'function') {
updateStorageStats();
}
}
});
// Make accessible globally
window.catalogManager = catalogManager;