export const FAVORITES_STORAGE_KEY = "sces:favorites:v1"; export interface FavoriteFolder { createdAt: string; id: string; name: string; updatedAt: string; } export interface FavoriteCreator { authorId: string; authorName: string; coreUserId?: string; savedAt: string; } export interface FavoriteMembership { addedAt: string; authorId: string; folderId: string; } export interface FavoritesStateV1 { creators: FavoriteCreator[]; folders: FavoriteFolder[]; memberships: FavoriteMembership[]; version: 1; } export interface FavoriteCreatorInput { authorId: string; authorName: string; coreUserId?: string; } export interface FavoritesStorage { get(): Promise; set(value: FavoritesStateV1): Promise; } export interface FavoritesRepository { createFolder(name: string): Promise; deleteFolder(folderId: string): Promise; read(): Promise; removeCreatorFromFolder(authorId: string, folderId: string): Promise; renameFolder(folderId: string, name: string): Promise; setCreatorFolderIds( creator: FavoriteCreatorInput, folderIds: string[] ): Promise; } interface ChromeStorageLocalArea { get(key: string): Promise>; set(items: Record): Promise; } interface ChromeApi { storage?: { local?: ChromeStorageLocalArea; }; } declare const chrome: ChromeApi; export function createFavoritesRepository(options: { storage: FavoritesStorage; now?: () => string; createId?: () => string; }): FavoritesRepository { const now = options.now ?? (() => new Date().toISOString()); const createId = options.createId ?? createDefaultId; let mutationTail: Promise = Promise.resolve(); return { async createFolder(name) { const folderName = normalizeFolderName(name); return mutate((state) => { const timestamp = now(); const folder: FavoriteFolder = { createdAt: timestamp, id: createId(), name: folderName, updatedAt: timestamp }; state.folders.push(folder); return { ...folder }; }); }, async deleteFolder(folderId) { await mutate((state) => { const folderIndex = state.folders.findIndex((folder) => folder.id === folderId); if (folderIndex === -1) { throw new Error(`收藏夹不存在: ${folderId}`); } state.folders.splice(folderIndex, 1); state.memberships = state.memberships.filter( (membership) => membership.folderId !== folderId ); pruneOrphanCreators(state); }); }, async read() { await mutationTail; return normalizeState(await options.storage.get(), now); }, async removeCreatorFromFolder(authorId, folderId) { await mutate((state) => { const folder = state.folders.find((item) => item.id === folderId); if (!folder) { throw new Error(`收藏夹不存在: ${folderId}`); } state.memberships = state.memberships.filter( (membership) => membership.authorId !== authorId || membership.folderId !== folderId ); pruneOrphanCreators(state); }); }, async renameFolder(folderId, name) { const folderName = normalizeFolderName(name); await mutate((state) => { const folder = state.folders.find((item) => item.id === folderId); if (!folder) { throw new Error(`收藏夹不存在: ${folderId}`); } folder.name = folderName; folder.updatedAt = now(); }); }, async setCreatorFolderIds(creator, folderIds) { const authorId = normalizeText(creator.authorId); if (!authorId) { throw new Error("无法收藏该达人"); } const uniqueFolderIds = Array.from(new Set(folderIds)); await mutate((state) => { const knownFolderIds = new Set(state.folders.map((folder) => folder.id)); const unknownFolderId = uniqueFolderIds.find( (folderId) => !knownFolderIds.has(folderId) ); if (unknownFolderId !== undefined) { throw new Error(`收藏夹不存在: ${unknownFolderId}`); } const timestamp = now(); const existingCreator = state.creators.find( (item) => item.authorId === authorId ); const currentMemberships = state.memberships.filter( (membership) => membership.authorId === authorId ); const existingMemberships = new Map( currentMemberships.map( (membership) => [membership.folderId, membership] as const ) ); state.memberships = state.memberships.filter( (membership) => membership.authorId !== authorId ); if (uniqueFolderIds.length === 0) { pruneOrphanCreators(state); return; } const authorName = normalizeText(creator.authorName); const coreUserId = normalizeText(creator.coreUserId); if (existingCreator) { if (authorName) { existingCreator.authorName = authorName; } if (coreUserId) { existingCreator.coreUserId = coreUserId; } } else { const nextCreator: FavoriteCreator = { authorId, authorName: authorName ?? authorId, savedAt: timestamp }; if (coreUserId) { nextCreator.coreUserId = coreUserId; } state.creators.push(nextCreator); } const requestedFolderIds = new Set(uniqueFolderIds); const retainedMemberships = currentMemberships.filter((membership) => requestedFolderIds.has(membership.folderId) ); state.memberships.push(...retainedMemberships); uniqueFolderIds.forEach((folderId) => { if (existingMemberships.has(folderId)) { return; } state.memberships.push({ addedAt: timestamp, authorId, folderId }); }); }); } }; function mutate(mutator: (state: FavoritesStateV1) => T): Promise { const operation = mutationTail.then(async () => { const state = normalizeState(await options.storage.get(), now); const result = mutator(state); await options.storage.set(state); return result; }); mutationTail = operation.then( () => undefined, () => undefined ); return operation; } } export function createChromeFavoritesStorage(): FavoritesStorage { return { async get() { const local = getChromeLocalStorage(); const result = await local.get(FAVORITES_STORAGE_KEY); return result[FAVORITES_STORAGE_KEY]; }, async set(value) { const local = getChromeLocalStorage(); await local.set({ [FAVORITES_STORAGE_KEY]: value }); } }; } function getChromeLocalStorage(): ChromeStorageLocalArea { const local = typeof chrome === "undefined" ? undefined : chrome.storage?.local; if (!local) { throw new Error("收藏夹存储不可用"); } return local; } function normalizeState(value: unknown, now: () => string): FavoritesStateV1 { const source = isRecord(value) ? value : {}; if (source.version !== 1) { return { creators: [], folders: [], memberships: [], version: 1 }; } const folders = normalizeFolders(source.folders, now); const creators = normalizeCreators(source.creators, now); const knownFolderIds = new Set(folders.map((folder) => folder.id)); const knownCreatorIds = new Set(creators.map((creator) => creator.authorId)); const memberships = normalizeMemberships( source.memberships, knownFolderIds, knownCreatorIds, now ); const memberAuthorIds = new Set(memberships.map((membership) => membership.authorId)); return { creators: creators.filter((creator) => memberAuthorIds.has(creator.authorId)), folders, memberships, version: 1 }; } function normalizeFolders(value: unknown, now: () => string): FavoriteFolder[] { if (!Array.isArray(value)) { return []; } const ids = new Set(); const folders: FavoriteFolder[] = []; value.forEach((item) => { if (!isRecord(item)) { return; } const id = normalizeText(item.id); const name = normalizeText(item.name); if (!id || !name || ids.has(id)) { return; } ids.add(id); folders.push({ createdAt: normalizeText(item.createdAt) ?? now(), id, name, updatedAt: normalizeText(item.updatedAt) ?? now() }); }); return folders; } function normalizeCreators(value: unknown, now: () => string): FavoriteCreator[] { if (!Array.isArray(value)) { return []; } const creatorsByAuthorId = new Map(); value.forEach((item) => { if (!isRecord(item)) { return; } const authorId = normalizeText(item.authorId); if (!authorId) { return; } const authorName = normalizeText(item.authorName); const coreUserId = normalizeText(item.coreUserId); const existingCreator = creatorsByAuthorId.get(authorId); if (existingCreator) { if (coreUserId && !existingCreator.coreUserId) { existingCreator.coreUserId = coreUserId; } if (authorName && existingCreator.authorName === authorId) { existingCreator.authorName = authorName; } return; } const creator: FavoriteCreator = { authorId, authorName: authorName ?? authorId, savedAt: normalizeText(item.savedAt) ?? now() }; if (coreUserId) { creator.coreUserId = coreUserId; } creatorsByAuthorId.set(authorId, creator); }); return Array.from(creatorsByAuthorId.values()); } function normalizeMemberships( value: unknown, knownFolderIds: Set, knownCreatorIds: Set, now: () => string ): FavoriteMembership[] { if (!Array.isArray(value)) { return []; } const membershipKeys = new Set(); const memberships: FavoriteMembership[] = []; value.forEach((item) => { if (!isRecord(item)) { return; } const authorId = normalizeText(item.authorId); const folderId = normalizeText(item.folderId); if ( !authorId || !folderId || !knownCreatorIds.has(authorId) || !knownFolderIds.has(folderId) ) { return; } const key = `${folderId}\u0000${authorId}`; if (membershipKeys.has(key)) { return; } membershipKeys.add(key); memberships.push({ addedAt: normalizeText(item.addedAt) ?? now(), authorId, folderId }); }); return memberships; } function pruneOrphanCreators(state: FavoritesStateV1): void { const memberAuthorIds = new Set( state.memberships.map((membership) => membership.authorId) ); state.creators = state.creators.filter((creator) => memberAuthorIds.has(creator.authorId) ); } function normalizeFolderName(name: string): string { const normalized = name.trim(); if (!normalized) { throw new Error("请输入收藏夹名称"); } return normalized; } function normalizeText(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const normalized = value.trim(); return normalized || undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function createDefaultId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(36).slice(2)}`; }