From 30748ddc0f9558cfd3d767a7951d0f0d20474edf Mon Sep 17 00:00:00 2001 From: wxs Date: Fri, 17 Jul 2026 11:54:39 +0800 Subject: [PATCH] feat: add local favorites repository --- src/content/market/favorites-store.ts | 411 ++++++++++++++++++++++++++ tests/favorites-store.test.ts | 282 ++++++++++++++++++ 2 files changed, 693 insertions(+) create mode 100644 src/content/market/favorites-store.ts create mode 100644 tests/favorites-store.test.ts diff --git a/src/content/market/favorites-store.ts b/src/content/market/favorites-store.ts new file mode 100644 index 0000000..4ba3c62 --- /dev/null +++ b/src/content/market/favorites-store.ts @@ -0,0 +1,411 @@ +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) => { + 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 : {}; + 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)}`; +} diff --git a/tests/favorites-store.test.ts b/tests/favorites-store.test.ts new file mode 100644 index 0000000..feba11f --- /dev/null +++ b/tests/favorites-store.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, test } from "vitest"; + +import { + FAVORITES_STORAGE_KEY, + createChromeFavoritesStorage, + createFavoritesRepository, + type FavoritesStateV1, + type FavoritesStorage +} from "../src/content/market/favorites-store"; + +function createMemoryStorage(initial: unknown = undefined): FavoritesStorage { + let value = initial; + + return { + async get() { + return value; + }, + async set(next) { + value = next; + } + }; +} + +function createRepository(storage: FavoritesStorage = createMemoryStorage()) { + let id = 0; + return { + repository: createFavoritesRepository({ + storage, + now: () => "2026-07-17T00:00:00.000Z", + createId: () => `folder-${++id}` + }), + storage + }; +} + +describe("favorites-store", () => { + test("returns an empty versioned state when storage is empty", async () => { + const { repository } = createRepository(); + + await expect(repository.read()).resolves.toEqual({ + creators: [], + folders: [], + memberships: [], + version: 1 + }); + }); + + test("trims folder names and rejects blank folder names", async () => { + const { repository } = createRepository(); + + await expect(repository.createFolder(" 工作收藏 ")).resolves.toMatchObject({ + id: "folder-1", + name: "工作收藏" + }); + await expect(repository.createFolder(" \n ")).rejects.toThrow("请输入收藏夹名称"); + await expect(repository.renameFolder("folder-1", "")).rejects.toThrow( + "请输入收藏夹名称" + ); + }); + + test("rejects unknown folders, blank creators, and unknown creator folder ids", async () => { + const { repository } = createRepository(); + + await expect(repository.renameFolder("missing", "合作")).rejects.toThrow( + "收藏夹不存在" + ); + await expect(repository.deleteFolder("missing")).rejects.toThrow("收藏夹不存在"); + await expect( + repository.setCreatorFolderIds({ authorId: " ", authorName: "Alice" }, []) + ).rejects.toThrow("无法收藏该达人"); + await expect( + repository.setCreatorFolderIds( + { authorId: "author-1", authorName: "Alice" }, + ["missing"] + ) + ).rejects.toThrow("收藏夹不存在"); + }); + + test("keeps multi-folder memberships unique and preserves savedAt on repeat saves", async () => { + const { repository } = createRepository(); + const firstFolder = await repository.createFolder("合作"); + const secondFolder = await repository.createFolder("待联系"); + + await repository.setCreatorFolderIds( + { authorId: "author-1", authorName: "Alice" }, + [firstFolder.id, firstFolder.id, secondFolder.id] + ); + const firstSave = await repository.read(); + + await repository.setCreatorFolderIds( + { + authorId: "author-1", + authorName: "Alice Updated", + coreUserId: "core-1" + }, + [secondFolder.id, firstFolder.id] + ); + + await expect(repository.read()).resolves.toMatchObject({ + creators: [ + { + authorId: "author-1", + authorName: "Alice Updated", + coreUserId: "core-1", + savedAt: firstSave.creators[0]?.savedAt + } + ], + memberships: [ + { authorId: "author-1", folderId: firstFolder.id }, + { authorId: "author-1", folderId: secondFolder.id } + ] + }); + }); + + test("deletes only one folder membership before pruning a final creator orphan", async () => { + const { repository } = createRepository(); + const firstFolder = await repository.createFolder("合作"); + const secondFolder = await repository.createFolder("待联系"); + await repository.setCreatorFolderIds( + { authorId: "author-1", authorName: "Alice" }, + [firstFolder.id, secondFolder.id] + ); + + await repository.deleteFolder(firstFolder.id); + await expect(repository.read()).resolves.toMatchObject({ + creators: [{ authorId: "author-1" }], + memberships: [{ authorId: "author-1", folderId: secondFolder.id }] + }); + + await repository.deleteFolder(secondFolder.id); + await expect(repository.read()).resolves.toEqual({ + creators: [], + folders: [], + memberships: [], + version: 1 + }); + }); + + test("recovers from malformed, duplicate, and dangling stored values", async () => { + const { repository } = createRepository( + createMemoryStorage({ + version: 42, + folders: [ + { + id: "folder-1", + name: " 重点 ", + createdAt: "created", + updatedAt: "updated" + }, + { + id: "folder-1", + name: "重复", + createdAt: "created-2", + updatedAt: "updated-2" + }, + { id: "", name: "无效", createdAt: "created", updatedAt: "updated" } + ], + creators: [ + { authorId: "author-1", authorName: "Alice", savedAt: "saved" }, + { + authorId: "author-1", + authorName: "", + coreUserId: "core-1", + savedAt: "newer" + }, + { authorId: "orphan", authorName: "Orphan", savedAt: "saved" } + ], + memberships: [ + { authorId: "author-1", folderId: "folder-1", addedAt: "added" }, + { authorId: "author-1", folderId: "folder-1", addedAt: "duplicate" }, + { authorId: "missing", folderId: "folder-1", addedAt: "dangling" }, + { authorId: "author-1", folderId: "missing", addedAt: "dangling" }, + null + ] + }) + ); + + await expect(repository.read()).resolves.toEqual({ + version: 1, + folders: [ + { + id: "folder-1", + name: "重点", + createdAt: "created", + updatedAt: "updated" + } + ], + creators: [ + { + authorId: "author-1", + authorName: "Alice", + coreUserId: "core-1", + savedAt: "saved" + } + ], + memberships: [ + { authorId: "author-1", folderId: "folder-1", addedAt: "added" } + ] + }); + }); + + test("serializes concurrent mutations so neither folder is overwritten", async () => { + const { repository } = createRepository(); + + await Promise.all([ + repository.createFolder("第一个"), + repository.createFolder("第二个") + ]); + + await expect(repository.read()).resolves.toMatchObject({ + folders: [ + { id: "folder-1", name: "第一个" }, + { id: "folder-2", name: "第二个" } + ] + }); + }); + + test("propagates rejected storage writes", async () => { + const storage: FavoritesStorage = { + async get() { + return undefined; + }, + async set() { + throw new Error("write failed"); + } + }; + const { repository } = createRepository(storage); + + await expect(repository.createFolder("合作")).rejects.toThrow("write failed"); + }); + + test("uses the exact Chrome storage key and reports unavailable storage", async () => { + const globalWithChrome = globalThis as typeof globalThis & { + chrome?: unknown; + }; + const originalChrome = globalWithChrome.chrome; + const storedState: FavoritesStateV1 = { + version: 1, + folders: [], + creators: [], + memberships: [] + }; + const getCalls: string[] = []; + const setCalls: Record[] = []; + + try { + globalWithChrome.chrome = { + storage: { + local: { + async get(key: string) { + getCalls.push(key); + return { [key]: storedState }; + }, + async set(items: Record) { + setCalls.push(items); + } + } + } + }; + + const storage = createChromeFavoritesStorage(); + await expect(storage.get()).resolves.toBe(storedState); + await storage.set(storedState); + expect(getCalls).toEqual([FAVORITES_STORAGE_KEY]); + expect(setCalls).toEqual([{ [FAVORITES_STORAGE_KEY]: storedState }]); + + delete globalWithChrome.chrome; + await expect(createChromeFavoritesStorage().get()).rejects.toThrow( + "收藏夹存储不可用" + ); + await expect(createChromeFavoritesStorage().set(storedState)).rejects.toThrow( + "收藏夹存储不可用" + ); + } finally { + if (originalChrome === undefined) { + delete globalWithChrome.chrome; + } else { + globalWithChrome.chrome = originalChrome; + } + } + }); +});