195 lines
5.6 KiB
JavaScript
195 lines
5.6 KiB
JavaScript
/**
|
|
* Persistent Metadata Cache (LRU + localStorage)
|
|
* Caches image metadata by image path and persists between sessions.
|
|
*
|
|
* Usage:
|
|
* import { MetadataCache } from './metadataCache.js';
|
|
* const meta = MetadataCache.get(path);
|
|
* await MetadataCache.set(path, metadata);
|
|
* const stats = MetadataCache.getStats();
|
|
*/
|
|
|
|
// Configuration constants
|
|
const DEFAULT_MAX_ENTRIES = 300; // limit to avoid exceeding localStorage quota
|
|
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
const STORAGE_KEY = 'sageutils:metadataCache:v1';
|
|
|
|
class MetadataCacheClass {
|
|
constructor() {
|
|
this.maxEntries = DEFAULT_MAX_ENTRIES;
|
|
this.ttlMs = DEFAULT_TTL_MS;
|
|
this.memory = new Map(); // key -> { metadata, ts }
|
|
this.lru = []; // most recent at end
|
|
this.loaded = false;
|
|
this.metrics = { hits: 0, misses: 0, stores: 0, evictions: 0, loads: 0, errors: 0 };
|
|
}
|
|
|
|
/** Load cache from localStorage once */
|
|
load() {
|
|
if (this.loaded) return;
|
|
this.loaded = true;
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return;
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
parsed.entries.forEach(e => {
|
|
if (e && typeof e.key === 'string' && e.value && typeof e.value.ts === 'number') {
|
|
this.memory.set(e.key, e.value);
|
|
}
|
|
});
|
|
// Rebuild LRU order (cap to maxEntries)
|
|
this.lru = Array.isArray(parsed.lru) ? parsed.lru.filter(k => this.memory.has(k)) : Array.from(this.memory.keys());
|
|
if (this.lru.length > this.maxEntries) {
|
|
this.lru = this.lru.slice(-this.maxEntries);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
this.metrics.errors++;
|
|
// If corrupted, clear only the stored blob to avoid repeated JSON.parse failures
|
|
try { window.localStorage.removeItem(STORAGE_KEY); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
/** Persist current cache to localStorage */
|
|
persist() {
|
|
try {
|
|
const entries = [];
|
|
// Persist only those still referenced in LRU to bound size
|
|
const allowed = new Set(this.lru);
|
|
this.memory.forEach((value, key) => {
|
|
if (allowed.has(key)) {
|
|
entries.push({ key, value });
|
|
}
|
|
});
|
|
const payload = JSON.stringify({ entries, lru: this.lru });
|
|
window.localStorage.setItem(STORAGE_KEY, payload);
|
|
} catch (err) {
|
|
this.metrics.errors++;
|
|
// Quota exceeded: evict oldest 10% and retry once
|
|
if (this.lru.length > 0) {
|
|
const toEvict = Math.max(1, Math.floor(this.lru.length * 0.1));
|
|
for (let i = 0; i < toEvict; i++) {
|
|
const k = this.lru.shift();
|
|
if (k != null) {
|
|
this.memory.delete(k);
|
|
this.metrics.evictions++;
|
|
}
|
|
}
|
|
try {
|
|
const entries = [];
|
|
const allowed = new Set(this.lru);
|
|
this.memory.forEach((value, key) => {
|
|
if (allowed.has(key)) {
|
|
entries.push({ key, value });
|
|
}
|
|
});
|
|
const payload = JSON.stringify({ entries, lru: this.lru });
|
|
window.localStorage.setItem(STORAGE_KEY, payload);
|
|
} catch (_) {
|
|
// Give up silently; in-memory cache will still function
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Normalize key from image input */
|
|
keyFrom(imageInput) {
|
|
if (!imageInput) return null;
|
|
if (typeof imageInput === 'string') return imageInput;
|
|
return imageInput.path || imageInput.relative_path || imageInput.name || null;
|
|
}
|
|
|
|
/** Get cached metadata or null */
|
|
get(imageInput) {
|
|
this.load();
|
|
const key = this.keyFrom(imageInput);
|
|
if (!key) return null;
|
|
const rec = this.memory.get(key);
|
|
if (!rec) {
|
|
this.metrics.misses++;
|
|
return null;
|
|
}
|
|
// TTL check (stale entries are returned but marked for revalidation by caller)
|
|
this.touch(key);
|
|
this.metrics.hits++;
|
|
return rec.metadata;
|
|
}
|
|
|
|
/** True if an entry exists */
|
|
has(imageInput) {
|
|
this.load();
|
|
const key = this.keyFrom(imageInput);
|
|
return !!key && this.memory.has(key);
|
|
}
|
|
|
|
/** Whether cached entry is stale by TTL */
|
|
isStale(imageInput) {
|
|
this.load();
|
|
const key = this.keyFrom(imageInput);
|
|
if (!key) return true;
|
|
const rec = this.memory.get(key);
|
|
if (!rec) return true;
|
|
const age = Date.now() - rec.ts;
|
|
return age > this.ttlMs;
|
|
}
|
|
|
|
/** Store metadata */
|
|
set(imageInput, metadata) {
|
|
this.load();
|
|
const key = this.keyFrom(imageInput);
|
|
if (!key) return;
|
|
this.memory.set(key, { metadata, ts: Date.now() });
|
|
this.touch(key);
|
|
// Enforce max entries (LRU eviction)
|
|
while (this.lru.length > this.maxEntries) {
|
|
const k = this.lru.shift();
|
|
if (k != null) {
|
|
this.memory.delete(k);
|
|
this.metrics.evictions++;
|
|
}
|
|
}
|
|
this.metrics.stores++;
|
|
this.persist();
|
|
}
|
|
|
|
/** Remove an entry */
|
|
remove(imageInput) {
|
|
this.load();
|
|
const key = this.keyFrom(imageInput);
|
|
if (!key) return;
|
|
this.memory.delete(key);
|
|
const idx = this.lru.indexOf(key);
|
|
if (idx >= 0) this.lru.splice(idx, 1);
|
|
this.persist();
|
|
}
|
|
|
|
/** Clear all */
|
|
clear() {
|
|
this.memory.clear();
|
|
this.lru = [];
|
|
try { window.localStorage.removeItem(STORAGE_KEY); } catch (_) {}
|
|
}
|
|
|
|
/** Update LRU order */
|
|
touch(key) {
|
|
const idx = this.lru.indexOf(key);
|
|
if (idx >= 0) this.lru.splice(idx, 1);
|
|
this.lru.push(key);
|
|
}
|
|
|
|
/** Stats */
|
|
getStats() {
|
|
return {
|
|
size: this.memory.size,
|
|
lruSize: this.lru.length,
|
|
maxEntries: this.maxEntries,
|
|
ttlMs: this.ttlMs,
|
|
...this.metrics
|
|
};
|
|
}
|
|
}
|
|
|
|
export const MetadataCache = new MetadataCacheClass();
|
|
export default MetadataCache;
|