import type { Asset } from "@/stores/use-asset-store"; import { defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category"; export type AssetStorageDocument = { state: { assets: Asset[] }; version: number; storageRevision: number; tombstones: { assets: Record }; }; export function normalizeAssetRecord(asset: Asset): Asset { const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)); const folderId = typeof asset.folderId === "string" ? asset.folderId.trim() : ""; const normalizedData = normalizeAssetData(asset); if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category && (asset.folderId || "") === folderId && normalizedData === asset.data) return asset; return { ...asset, tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [], folderId: folderId || undefined, category, data: normalizedData, } as Asset; } function normalizeAssetData(asset: Asset): Asset["data"] { const raw = asset.data && typeof asset.data === "object" ? asset.data as Record : {}; const numberValue = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; if (asset.kind === "text") return typeof raw.content === "string" ? asset.data : { content: "" }; if (asset.kind === "image") { if (typeof raw.dataUrl === "string" && typeof raw.width === "number" && typeof raw.height === "number") return asset.data; return { dataUrl: typeof raw.dataUrl === "string" ? raw.dataUrl : asset.coverUrl || "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, width: numberValue(raw.width), height: numberValue(raw.height), bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "image/*" }; } if (asset.kind === "video") { if (typeof raw.url === "string" && typeof raw.width === "number" && typeof raw.height === "number") return asset.data; return { url: typeof raw.url === "string" ? raw.url : asset.coverUrl || "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, width: numberValue(raw.width), height: numberValue(raw.height), durationMs: numberValue(raw.durationMs), hasAudio: raw.hasAudio === true, bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "video/*" }; } if (asset.kind === "audio") { if (typeof raw.url === "string") return asset.data; return { url: "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, durationMs: numberValue(raw.durationMs), bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "audio/*" }; } if (asset.kind === "model") { if (typeof raw.url === "string" && typeof raw.fileName === "string") return asset.data; return { url: "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "model/*", fileName: typeof raw.fileName === "string" ? raw.fileName : asset.title || "未命名模型" }; } return asset.data; } function normalizeAssets(assets: Asset[]) { return assets.map(normalizeAssetRecord); } export function parseAssetStorageDocument(value: string | null, fallback: Asset[] = []): AssetStorageDocument { if (!value) { return { state: { assets: normalizeAssets(fallback) }, version: 0, storageRevision: 0, tombstones: { assets: {} }, }; } const parsed = JSON.parse(value) as { state?: { assets?: unknown }; version?: unknown; storageRevision?: unknown; tombstones?: { assets?: unknown }; }; if (!Array.isArray(parsed.state?.assets)) throw new Error("素材持久状态无效"); const rawTombstones = parsed.tombstones?.assets; const tombstones = rawTombstones && typeof rawTombstones === "object" && !Array.isArray(rawTombstones) ? Object.fromEntries(Object.entries(rawTombstones).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))) : {}; return { state: { assets: normalizeAssets(parsed.state.assets as Asset[]) }, version: typeof parsed.version === "number" ? parsed.version : 0, storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0, tombstones: { assets: tombstones }, }; } export function serializeAssetStorageDocument(document: AssetStorageDocument) { return JSON.stringify(document); } function deepEqual(left: unknown, right: unknown) { if (Object.is(left, right)) return true; return JSON.stringify(left) === JSON.stringify(right); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function mergeValue(base: unknown, local: unknown, durable: unknown): unknown { if (deepEqual(local, base)) return durable; if (deepEqual(durable, base)) return local; if (isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord(base, local, durable); if (!isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord({}, local, durable); return durable; } function mergeRecord(base: Record, local: Record, durable: Record) { const merged: Record = {}; for (const key of new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(durable)])) { const baseHas = Object.prototype.hasOwnProperty.call(base, key); const localHas = Object.prototype.hasOwnProperty.call(local, key); const durableHas = Object.prototype.hasOwnProperty.call(durable, key); if (!localHas && baseHas) { if (durableHas && !deepEqual(durable[key], base[key])) merged[key] = durable[key]; continue; } if (!localHas) { if (durableHas) merged[key] = durable[key]; continue; } if (!baseHas) { merged[key] = durableHas ? mergeValue(undefined, local[key], durable[key]) : local[key]; continue; } if (!durableHas) { if (!deepEqual(local[key], base[key])) merged[key] = local[key]; continue; } merged[key] = mergeValue(base[key], local[key], durable[key]); } return merged; } export function rebaseAssetSnapshot(input: { document: AssetStorageDocument; baseAssets: Asset[]; localAssets: Asset[]; baseRevision: number }) { const nextRevision = input.document.storageRevision + 1; const tombstones = { assets: { ...input.document.tombstones.assets } }; const baseById = new Map(input.baseAssets.map((asset) => [asset.id, asset])); const localById = new Map(input.localAssets.map((asset) => [asset.id, asset])); const durableById = new Map(input.document.state.assets.map((asset) => [asset.id, asset])); const assets = [...input.document.state.assets]; const positions = new Map(assets.map((asset, index) => [asset.id, index])); const remove = (id: string) => { const index = positions.get(id); if (index === undefined) return; assets.splice(index, 1); positions.clear(); assets.forEach((asset, position) => positions.set(asset.id, position)); }; const set = (asset: Asset) => { const index = positions.get(asset.id); if (index === undefined) { positions.set(asset.id, assets.length); assets.push(asset); } else { assets[index] = asset; } }; for (const id of new Set([...baseById.keys(), ...localById.keys()])) { const base = baseById.get(id); const local = localById.get(id); const durable = durableById.get(id); if (base && !local) { remove(id); tombstones.assets[id] = nextRevision; continue; } if (!local || (base && deepEqual(base, local))) continue; if (!durable) { if (base || (tombstones.assets[id] ?? 0) > input.baseRevision) continue; delete tombstones.assets[id]; set(local); continue; } delete tombstones.assets[id]; set(mergeRecord((base || {}) as unknown as Record, local as unknown as Record, durable as unknown as Record) as unknown as Asset); } return { state: { assets }, version: input.document.version, storageRevision: nextRevision, tombstones, } satisfies AssetStorageDocument; }