123 lines
4.3 KiB
TypeScript
123 lines
4.3 KiB
TypeScript
import { STORAGE_KEY } from '../constants';
|
|
import { normalizeMediaItem } from '../lib/media';
|
|
import type { Album, MediaItem } from '../types';
|
|
import type { PersistedState, StorageAdapter } from './types';
|
|
|
|
const CURRENT_VERSION = 1;
|
|
|
|
/**
|
|
* Default zero-config adapter. Persists library metadata to localStorage and
|
|
* (optionally) binary blobs to IndexedDB so imported files survive reloads.
|
|
*
|
|
* Safe under SSR: all browser APIs are guarded with `typeof window` checks.
|
|
*/
|
|
export function createLocalStorageAdapter(key: string = STORAGE_KEY): StorageAdapter {
|
|
const hasWindow = typeof window !== 'undefined';
|
|
|
|
return {
|
|
name: 'localStorage',
|
|
|
|
async load(): Promise<PersistedState | null> {
|
|
if (!hasWindow) return null;
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as PersistedState;
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
// Field-level validation — never trust persisted data blindly. Every media
|
|
// item is re-normalized (URL scheme allow-list, string coercion) so a
|
|
// tampered localStorage record cannot inject unsafe values into the UI.
|
|
const media: MediaItem[] = Array.isArray(parsed.media)
|
|
? parsed.media
|
|
.map((m) => normalizeMediaItem(m))
|
|
.filter((m): m is MediaItem => m !== null)
|
|
: [];
|
|
const albums: Album[] = Array.isArray(parsed.albums)
|
|
? parsed.albums
|
|
.filter((a): a is Album => Boolean(a) && typeof a === 'object')
|
|
.map((a) => ({
|
|
...a,
|
|
name: typeof a.name === 'string' ? a.name : 'Album',
|
|
mediaIds: Array.isArray(a.mediaIds)
|
|
? a.mediaIds.filter((id): id is string => typeof id === 'string')
|
|
: [],
|
|
}))
|
|
: [];
|
|
// Keep only string→string entries — never trust persisted data blindly.
|
|
const labelAliases: Record<string, string> =
|
|
parsed.labelAliases && typeof parsed.labelAliases === 'object'
|
|
? Object.fromEntries(
|
|
Object.entries(parsed.labelAliases).filter(
|
|
([k, v]) => typeof k === 'string' && typeof v === 'string',
|
|
),
|
|
)
|
|
: {};
|
|
const deletedLabels: string[] = Array.isArray(parsed.deletedLabels)
|
|
? (parsed.deletedLabels as unknown[]).filter((l): l is string => typeof l === 'string')
|
|
: [];
|
|
return {
|
|
media,
|
|
albums,
|
|
people: Array.isArray(parsed.people) ? parsed.people : [],
|
|
labelAliases,
|
|
deletedLabels,
|
|
version: typeof parsed.version === 'number' ? parsed.version : CURRENT_VERSION,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async save(state: PersistedState): Promise<void> {
|
|
if (!hasWindow) return;
|
|
try {
|
|
window.localStorage.setItem(
|
|
key,
|
|
JSON.stringify({ ...state, version: CURRENT_VERSION }),
|
|
);
|
|
} catch {
|
|
// Quota exceeded or storage disabled — fail silently, app still works in-memory.
|
|
}
|
|
},
|
|
|
|
async putBlob(id: string, blob: Blob): Promise<string> {
|
|
if (!hasWindow) return '';
|
|
const db = await openBlobDb();
|
|
if (!db) return URL.createObjectURL(blob);
|
|
await new Promise<void>((resolve, reject) => {
|
|
const tx = db.transaction('blobs', 'readwrite');
|
|
tx.objectStore('blobs').put(blob, id);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
return URL.createObjectURL(blob);
|
|
},
|
|
|
|
async clear(): Promise<void> {
|
|
if (!hasWindow) return;
|
|
try {
|
|
window.localStorage.removeItem(key);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
let dbPromise: Promise<IDBDatabase | null> | null = null;
|
|
|
|
function openBlobDb(): Promise<IDBDatabase | null> {
|
|
if (typeof indexedDB === 'undefined') return Promise.resolve(null);
|
|
if (dbPromise) return dbPromise;
|
|
dbPromise = new Promise((resolve) => {
|
|
const req = indexedDB.open('photo-gallery-sdk-blobs', 1);
|
|
req.onupgradeneeded = () => {
|
|
const db = req.result;
|
|
if (!db.objectStoreNames.contains('blobs')) db.createObjectStore('blobs');
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => resolve(null);
|
|
});
|
|
return dbPromise;
|
|
}
|