"use client"; import localforage from "localforage"; import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage"; import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage"; import { downloadWebdavFile, uploadWebdavFile, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; import type { Asset } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store"; import type { WebdavSyncConfig } from "@/stores/use-config-store"; import type { CanvasProject } from "@/app/(user)/canvas/stores/use-canvas-store"; import { useCanvasStore } from "@/app/(user)/canvas/stores/use-canvas-store"; type StoredLog = Record & { id?: string }; export type AppSyncDomainKey = "canvas" | "assets" | "image-workbench" | "video-workbench"; type DomainKey = AppSyncDomainKey; type CanvasDomainData = { projects: CanvasProject[] }; type AssetDomainData = { assets: Asset[] }; type LogDomainData = { logs: StoredLog[] }; type AppSyncFile = { storageKey: string; path: string; mimeType: string; bytes: number; }; type DomainManifest = { app: "infinite-canvas"; version: 1; domain: DomainKey; exportedAt: string; data: T; files: AppSyncFile[]; }; type SyncDomainOptions = { key: DomainKey; label: string; localData: () => Promise; emptyData: T; mergeData: (local: T, remote: T) => T; applyData?: (data: T) => Promise; }; type SyncDomainResult = { data: T; mergedRemote: boolean; files: number; manifestBytes: number; uploadedFiles: number; uploadedBytes: number; }; export type AppSyncResult = { syncedAt: string; mergedRemote: boolean; projects: number; assets: number; imageLogs: number; videoLogs: number; files: number; manifestBytes: number; uploadedFiles: number; uploadedBytes: number; }; export type AppSyncProgressEvent = { domain?: AppSyncDomainKey; label?: string; stage: string; current?: number; total?: number; status?: "active" | "success" | "exception"; }; export type AppSyncProgress = (event: AppSyncProgressEvent) => void; const FILE_CONCURRENCY = 3; const imageLogStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" }); const videoLogStore = localforage.createInstance({ name: "infinite-canvas", storeName: "video_generation_logs" }); type LogStore = typeof imageLogStore; const storageKeyPattern = /^(image|video|audio|file|video-reference|audio-reference):/; export async function syncAppDataToWebdav(config: WebdavSyncConfig, onProgress?: AppSyncProgress): Promise { emitProgress(onProgress, { stage: "等待本地数据加载" }); await Promise.all([waitForHydration(useCanvasStore), waitForHydration(useAssetStore)]); const [canvas, assets, imageLogs, videoLogs] = await Promise.all([ syncDomain(config, onProgress, { key: "canvas", label: "画布", emptyData: { projects: [] }, localData: async () => ({ projects: useCanvasStore.getState().projects }), mergeData: (local, remote) => ({ projects: mergeById(local.projects, remote.projects, "updatedAt") }), applyData: async (data) => useCanvasStore.getState().replaceProjects(data.projects), }), syncDomain(config, onProgress, { key: "assets", label: "我的素材", emptyData: { assets: [] }, localData: async () => ({ assets: useAssetStore.getState().assets }), mergeData: (local, remote) => ({ assets: mergeById(local.assets, remote.assets, "updatedAt") }), applyData: async (data) => useAssetStore.getState().replaceAssets(await Promise.all(data.assets.map(hydrateAsset))), }), syncDomain(config, onProgress, { key: "image-workbench", label: "生图工作台", emptyData: { logs: [] }, localData: async () => ({ logs: await readStoredLogs(imageLogStore) }), mergeData: (local, remote) => ({ logs: mergeById(local.logs, remote.logs, "createdAt") }), applyData: async (data) => replaceStoredLogs(imageLogStore, data.logs), }), syncDomain(config, onProgress, { key: "video-workbench", label: "视频创作台", emptyData: { logs: [] }, localData: async () => ({ logs: await readStoredLogs(videoLogStore) }), mergeData: (local, remote) => ({ logs: mergeById(local.logs, remote.logs, "createdAt") }), applyData: async (data) => replaceStoredLogs(videoLogStore, data.logs), }), ]); const result = { syncedAt: new Date().toISOString(), mergedRemote: [canvas, assets, imageLogs, videoLogs].some((item) => item.mergedRemote), projects: canvas.data.projects.length, assets: assets.data.assets.length, imageLogs: imageLogs.data.logs.length, videoLogs: videoLogs.data.logs.length, files: canvas.files + assets.files + imageLogs.files + videoLogs.files, manifestBytes: canvas.manifestBytes + assets.manifestBytes + imageLogs.manifestBytes + videoLogs.manifestBytes, uploadedFiles: canvas.uploadedFiles + assets.uploadedFiles + imageLogs.uploadedFiles + videoLogs.uploadedFiles, uploadedBytes: canvas.uploadedBytes + assets.uploadedBytes + imageLogs.uploadedBytes + videoLogs.uploadedBytes, }; emitProgress(onProgress, { stage: "同步完成", status: "success" }); return result; } async function syncDomain(config: WebdavSyncConfig, onProgress: AppSyncProgress | undefined, options: SyncDomainOptions): Promise> { try { emitProgress(onProgress, { domain: options.key, label: options.label, stage: "读取远端清单", status: "active" }); const remoteManifest = await readDomainManifest(config, options.key, options.emptyData); emitProgress(onProgress, { domain: options.key, label: options.label, stage: "读取本地数据", status: "active" }); const localData = await options.localData(); const mergedData = remoteManifest ? options.mergeData(localData, remoteManifest.data) : localData; if (remoteManifest) { emitProgress(onProgress, { domain: options.key, label: options.label, stage: "下载缺失媒体", status: "active" }); await downloadMissingFiles(config, options.key, mergedData, remoteManifest.files, onProgress); emitProgress(onProgress, { domain: options.key, label: options.label, stage: "写入本地合并结果", status: "active" }); await options.applyData?.(mergedData); } emitProgress(onProgress, { domain: options.key, label: options.label, stage: "上传新增媒体", status: "active" }); const uploaded = await uploadChangedFiles(config, options.key, mergedData, remoteManifest?.files || [], onProgress); const manifest: DomainManifest = { app: "infinite-canvas", version: 1, domain: options.key, exportedAt: new Date().toISOString(), data: mergedData, files: uploaded.files }; const manifestFile = new Blob([JSON.stringify(manifest, null, 2)], { type: "application/json" }); emitProgress(onProgress, { domain: options.key, label: options.label, stage: `上传清单 ${formatBytes(manifestFile.size)}`, status: "active" }); await uploadWebdavFile(config, domainPath(options.key, WEBDAV_MANIFEST_FILE_NAME), manifestFile, "application/json"); emitProgress(onProgress, { domain: options.key, label: options.label, stage: "完成", current: 1, total: 1, status: "success" }); return { data: mergedData, mergedRemote: Boolean(remoteManifest), files: uploaded.files.length, manifestBytes: manifestFile.size, uploadedFiles: uploaded.uploadedFiles, uploadedBytes: uploaded.uploadedBytes, }; } catch (error) { emitProgress(onProgress, { domain: options.key, label: options.label, stage: error instanceof Error ? error.message : "同步失败", status: "exception" }); throw error; } } async function readDomainManifest(config: WebdavSyncConfig, domain: DomainKey, emptyData: T): Promise | null> { const file = await downloadWebdavFile(config, domainPath(domain, WEBDAV_MANIFEST_FILE_NAME)); if (!file) return null; const data = JSON.parse(await file.text()) as DomainManifest; if (data.app !== "infinite-canvas" || data.domain !== domain) throw new Error(`${domain} 同步清单不是当前应用的数据`); return { app: "infinite-canvas", version: 1, domain, exportedAt: data.exportedAt || new Date().toISOString(), data: data.data || emptyData, files: Array.isArray(data.files) ? data.files : [], }; } async function downloadMissingFiles(config: WebdavSyncConfig, domain: DomainKey, data: T, remoteFiles: AppSyncFile[], onProgress?: AppSyncProgress) { const remoteFileMap = new Map(remoteFiles.map((item) => [item.storageKey, item])); const tasks: AppSyncFile[] = []; const storageKeys = collectStorageKeys(data); let scanned = 0; for (const storageKey of storageKeys) { const localBlob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey); scanned += 1; if (localBlob) { emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查缺失媒体", current: scanned, total: storageKeys.length, status: "active" }); continue; } const remoteFile = remoteFileMap.get(storageKey); if (remoteFile) tasks.push(remoteFile); emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查缺失媒体", current: scanned, total: storageKeys.length, status: "active" }); } if (!tasks.length) { emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "媒体已齐全", current: 1, total: 1, status: "active" }); return; } let downloaded = 0; await runWithConcurrency(tasks, FILE_CONCURRENCY, async (remoteFile) => { const blob = await downloadWebdavFile(config, remoteFile.path); if (!blob) return; const typedBlob = blob.type ? blob : blob.slice(0, blob.size, remoteFile.mimeType); await (remoteFile.storageKey.startsWith("image:") ? setImageBlob(remoteFile.storageKey, typedBlob) : setMediaBlob(remoteFile.storageKey, typedBlob)); downloaded += 1; emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "下载媒体", current: downloaded, total: tasks.length, status: "active" }); }); } async function uploadChangedFiles(config: WebdavSyncConfig, domain: DomainKey, data: T, remoteFiles: AppSyncFile[], onProgress?: AppSyncProgress) { const remoteFileMap = new Map(remoteFiles.map((item) => [item.storageKey, item])); const files: AppSyncFile[] = []; const tasks: Array<{ item: AppSyncFile; blob: Blob }> = []; let uploadedFiles = 0; let uploadedBytes = 0; const storageKeys = collectStorageKeys(data); let scanned = 0; for (const storageKey of storageKeys) { const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey); const remoteFile = remoteFileMap.get(storageKey); if (!blob) { if (remoteFile) files.push(remoteFile); scanned += 1; emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查本地媒体", current: scanned, total: storageKeys.length, status: "active" }); continue; } const item: AppSyncFile = { storageKey, path: remoteFile?.path || domainPath(domain, `files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`), mimeType: blob.type || remoteFile?.mimeType || "application/octet-stream", bytes: blob.size, }; files.push(item); if (!remoteFile || remoteFile.bytes !== blob.size) tasks.push({ item, blob }); scanned += 1; emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查本地媒体", current: scanned, total: storageKeys.length, status: "active" }); } if (!tasks.length) { emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "媒体无需上传", current: 1, total: 1, status: "active" }); return { files, uploadedFiles, uploadedBytes }; } await runWithConcurrency(tasks, FILE_CONCURRENCY, async ({ item, blob }) => { await uploadWebdavFile(config, item.path, blob, item.mimeType); uploadedFiles += 1; uploadedBytes += blob.size; emitProgress(onProgress, { domain, label: domainLabel(domain), stage: `上传媒体 ${formatBytes(blob.size)}`, current: uploadedFiles, total: tasks.length, status: "active" }); }); return { files, uploadedFiles, uploadedBytes }; } async function hydrateAsset(asset: Asset): Promise { if (asset.kind === "image" && asset.data.storageKey) { const dataUrl = await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl); return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? dataUrl : asset.coverUrl, data: { ...asset.data, dataUrl } }; } if (asset.kind === "video" && asset.data.storageKey) { const url = await resolveMediaUrl(asset.data.storageKey, asset.data.url); return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? url : asset.coverUrl, data: { ...asset.data, url } }; } return asset; } async function readStoredLogs(store: LogStore) { const logs: StoredLog[] = []; await store.iterate((value) => { if (value && typeof value === "object") logs.push(value); }); return logs; } async function replaceStoredLogs(store: LogStore, logs: StoredLog[]) { await store.clear(); await runWithConcurrency(logs, FILE_CONCURRENCY, async (log) => { const id = getStringField(log, "id"); if (id) await store.setItem(id, log); }); } function mergeById(local: T[], remote: T[], timeKey: string) { const items = new Map(); remote.forEach((item) => { const id = item.id || ""; if (id) items.set(id, item); }); local.forEach((item) => { const id = item.id || ""; if (!id) return; const current = items.get(id); if (!current || getTime(item as Record, timeKey) >= getTime(current as Record, timeKey)) items.set(id, item); }); return Array.from(items.values()).sort((a, b) => getTime(b as Record, timeKey) - getTime(a as Record, timeKey)); } function collectStorageKeys(value: unknown, keys = new Set()) { if (typeof value === "string") { if (storageKeyPattern.test(value)) keys.add(value); return [...keys]; } if (!value || typeof value !== "object") return [...keys]; if ("storageKey" in value && typeof value.storageKey === "string" && storageKeyPattern.test(value.storageKey)) keys.add(value.storageKey); Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys))); return [...keys]; } function domainPath(domain: DomainKey, path: string) { return `${domain}/${path}`; } function domainLabel(domain: DomainKey) { if (domain === "canvas") return "画布"; if (domain === "assets") return "我的素材"; if (domain === "image-workbench") return "生图工作台"; return "视频创作台"; } function emitProgress(onProgress: AppSyncProgress | undefined, event: AppSyncProgressEvent) { onProgress?.(event); } function getStringField(item: Record, key: string) { const value = item[key]; return typeof value === "string" ? value : ""; } function getTime(item: Record, key: string) { const value = item[key]; if (typeof value === "number") return value; if (typeof value === "string") return Date.parse(value) || 0; return 0; } function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, "_"); } function fileExtension(mimeType: string, storageKey: string) { if (mimeType.includes("png")) return "png"; if (mimeType.includes("jpeg")) return "jpg"; if (mimeType.includes("webp")) return "webp"; if (mimeType.includes("gif")) return "gif"; if (mimeType.includes("mp4")) return "mp4"; if (mimeType.includes("webm")) return "webm"; if (mimeType.includes("wav")) return "wav"; if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3"; return storageKey.startsWith("image:") ? "png" : "bin"; } function waitForHydration(store: { getState: () => T; subscribe: (listener: (state: T) => void) => () => void }) { if (store.getState().hydrated) return Promise.resolve(); return new Promise((resolve) => { const unsubscribe = store.subscribe((state) => { if (!state.hydrated) return; unsubscribe(); resolve(); }); }); } async function runWithConcurrency(items: T[], limit: number, worker: (item: T, index: number) => Promise) { const results = new Array(items.length); let nextIndex = 0; await Promise.all( Array.from({ length: Math.min(limit, items.length) }, async () => { while (nextIndex < items.length) { const index = nextIndex++; results[index] = await worker(items[index], index); } }), ); return results; } function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${(bytes / 1024 / 1024).toFixed(1)}MB`; }