import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; import type { PluginDataStore } from "@/application/ports/PluginDataStore"; import { normalizeStoredClozeMode, type ClozeMode } from "@/domain/manual-sync/entities/IndexedCard"; import { createEmptyPluginState, toNoteIdKey, type CardState, type FileState, type GroupBlockState, type PendingWriteBackState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository"; interface LegacyFileState extends Partial { cardIds?: string[]; } interface LegacyCardState extends Partial { cardId?: string; noteId?: number; } interface LegacyPendingWriteBackState extends Partial { cardId?: string; noteId?: number; } interface LegacyGroupBlockState extends Partial { groupId?: string; } interface LegacyPluginState { files?: Record; cards?: Record; groupBlocks?: Record; pendingWriteBack?: LegacyPendingWriteBackState[]; } export class DataJsonPluginStateRepository implements PluginStateRepository { constructor(private readonly pluginDataStore: PluginDataStore) {} async load(): Promise { const snapshot = (await this.pluginDataStore.load()) ?? {}; return migratePluginState(snapshot.pluginState); } async save(state: PluginState): Promise { const snapshot = (await this.pluginDataStore.load()) ?? {}; await this.pluginDataStore.save({ ...snapshot, pluginState: state, }); } } export function migratePluginState(pluginState?: PluginState | LegacyPluginState): PluginState { if (!pluginState) { return createEmptyPluginState(); } const rawCards = toLegacyCardRecord(pluginState.cards); const rawFiles = toLegacyFileRecord(pluginState.files); const rawGroupBlocks = toLegacyGroupBlockRecord(pluginState.groupBlocks); const cards: Record = {}; for (const rawCard of Object.values(rawCards)) { const noteId = sanitizeNoteId(rawCard.noteId); if (noteId === undefined) { continue; } const nextCard = migrateCardState(rawCard, noteId); if (!nextCard) { continue; } const noteKey = toNoteIdKey(noteId); const existingCard = cards[noteKey]; if (!existingCard || existingCard.lastSyncedAt <= nextCard.lastSyncedAt) { cards[noteKey] = nextCard; } } const files: Record = {}; for (const [filePath, rawFile] of Object.entries(rawFiles)) { files[filePath] = { filePath, fileHash: typeof rawFile.fileHash === "string" ? rawFile.fileHash : "", fileStamp: typeof rawFile.fileStamp === "string" ? rawFile.fileStamp : "", deckRulesFingerprint: typeof rawFile.deckRulesFingerprint === "string" ? rawFile.deckRulesFingerprint : undefined, lastIndexedAt: typeof rawFile.lastIndexedAt === "number" ? rawFile.lastIndexedAt : 0, noteIds: collectMigratedFileNoteIds(rawFile, rawCards, cards), groupIds: collectMigratedFileGroupIds(rawFile, rawGroupBlocks), }; } const groupBlocks: Record = {}; for (const [groupId, rawGroupBlock] of Object.entries(rawGroupBlocks)) { const nextGroupBlock = migrateGroupBlockState(rawGroupBlock, groupId); if (!nextGroupBlock) { continue; } const existingGroupBlock = groupBlocks[groupId]; if (!existingGroupBlock || existingGroupBlock.lastSyncedAt <= nextGroupBlock.lastSyncedAt) { groupBlocks[groupId] = nextGroupBlock; } } return { files, cards, groupBlocks, pendingWriteBack: migratePendingWriteBack(pluginState.pendingWriteBack), }; } function migrateCardState(rawCard: LegacyCardState, noteId: number): CardState | null { const cardType = sanitizeCardType(rawCard.cardType); if (!cardType) { return null; } return { noteId, filePath: typeof rawCard.filePath === "string" ? rawCard.filePath : "", heading: typeof rawCard.heading === "string" ? rawCard.heading : "", backlinkHeadingText: typeof rawCard.backlinkHeadingText === "string" ? rawCard.backlinkHeadingText : (typeof rawCard.heading === "string" ? rawCard.heading : ""), headingLevel: typeof rawCard.headingLevel === "number" ? rawCard.headingLevel : 1, bodyMarkdown: typeof rawCard.bodyMarkdown === "string" ? rawCard.bodyMarkdown : "", cardType, clozeMode: normalizeStoredClozeMode(cardType, sanitizeClozeMode(rawCard.clozeMode)), blockStartOffset: typeof rawCard.blockStartOffset === "number" ? rawCard.blockStartOffset : 0, blockEndOffset: typeof rawCard.blockEndOffset === "number" ? rawCard.blockEndOffset : 0, blockStartLine: typeof rawCard.blockStartLine === "number" ? rawCard.blockStartLine : 1, bodyStartLine: typeof rawCard.bodyStartLine === "number" ? rawCard.bodyStartLine : 1, blockEndLine: typeof rawCard.blockEndLine === "number" ? rawCard.blockEndLine : 1, contentEndLine: typeof rawCard.contentEndLine === "number" ? rawCard.contentEndLine : 1, markerLine: typeof rawCard.markerLine === "number" ? rawCard.markerLine : undefined, rawBlockText: typeof rawCard.rawBlockText === "string" ? rawCard.rawBlockText : "", rawBlockHash: typeof rawCard.rawBlockHash === "string" ? rawCard.rawBlockHash : "", renderConfigHash: typeof rawCard.renderConfigHash === "string" ? rawCard.renderConfigHash : "", deck: typeof rawCard.deck === "string" ? rawCard.deck : "", deckHint: typeof rawCard.deckHint === "string" ? rawCard.deckHint : undefined, deckHintSource: rawCard.deckHintSource === "frontmatter" || rawCard.deckHintSource === "body" ? rawCard.deckHintSource : undefined, deckWarnings: Array.isArray(rawCard.deckWarnings) ? [...rawCard.deckWarnings] : [], tagsHint: Array.isArray(rawCard.tagsHint) ? rawCard.tagsHint.filter((tag): tag is string => typeof tag === "string") : [], lastSyncedAt: typeof rawCard.lastSyncedAt === "number" ? rawCard.lastSyncedAt : 0, orphan: Boolean(rawCard.orphan), }; } function sanitizeCardType(value: unknown): CardState["cardType"] | undefined { if (value === "basic" || value === "cloze") { return value; } return undefined; } function sanitizeClozeMode(value: unknown): ClozeMode | undefined { if (value === "sequential" || value === "all") { return value; } return undefined; } function migrateGroupBlockState(rawGroupBlock: LegacyGroupBlockState, groupId: string): GroupBlockState | null { const noteId = sanitizeNoteId(rawGroupBlock.noteId); if (noteId === undefined || !groupId.trim()) { return null; } return { groupId, noteId, filePath: typeof rawGroupBlock.filePath === "string" ? rawGroupBlock.filePath : "", headingText: typeof rawGroupBlock.headingText === "string" ? rawGroupBlock.headingText : "", backlinkHeadingText: typeof rawGroupBlock.backlinkHeadingText === "string" ? rawGroupBlock.backlinkHeadingText : (typeof rawGroupBlock.headingText === "string" ? rawGroupBlock.headingText : ""), headingLevel: typeof rawGroupBlock.headingLevel === "number" ? rawGroupBlock.headingLevel : 1, stem: typeof rawGroupBlock.stem === "string" ? rawGroupBlock.stem : "", src: typeof rawGroupBlock.src === "string" ? rawGroupBlock.src : "", blockStartOffset: typeof rawGroupBlock.blockStartOffset === "number" ? rawGroupBlock.blockStartOffset : 0, blockEndOffset: typeof rawGroupBlock.blockEndOffset === "number" ? rawGroupBlock.blockEndOffset : 0, blockStartLine: typeof rawGroupBlock.blockStartLine === "number" ? rawGroupBlock.blockStartLine : 1, bodyStartLine: typeof rawGroupBlock.bodyStartLine === "number" ? rawGroupBlock.bodyStartLine : 1, blockEndLine: typeof rawGroupBlock.blockEndLine === "number" ? rawGroupBlock.blockEndLine : 1, contentEndLine: typeof rawGroupBlock.contentEndLine === "number" ? rawGroupBlock.contentEndLine : 1, markerLine: typeof rawGroupBlock.markerLine === "number" ? rawGroupBlock.markerLine : undefined, markerIndent: typeof rawGroupBlock.markerIndent === "string" ? rawGroupBlock.markerIndent : undefined, rawBlockText: typeof rawGroupBlock.rawBlockText === "string" ? rawGroupBlock.rawBlockText : "", rawBlockHash: typeof rawGroupBlock.rawBlockHash === "string" ? rawGroupBlock.rawBlockHash : "", deck: typeof rawGroupBlock.deck === "string" ? rawGroupBlock.deck : "", deckHint: typeof rawGroupBlock.deckHint === "string" ? rawGroupBlock.deckHint : undefined, deckHintSource: rawGroupBlock.deckHintSource === "frontmatter" || rawGroupBlock.deckHintSource === "body" ? rawGroupBlock.deckHintSource : undefined, deckWarnings: Array.isArray(rawGroupBlock.deckWarnings) ? [...rawGroupBlock.deckWarnings] : [], tagsHint: Array.isArray(rawGroupBlock.tagsHint) ? rawGroupBlock.tagsHint.filter((tag): tag is string => typeof tag === "string") : [], items: Array.isArray(rawGroupBlock.items) ? rawGroupBlock.items.flatMap((item) => { if (!item || typeof item !== "object") { return []; } const nextItem = item as unknown as Record; if (typeof nextItem.title !== "string" || typeof nextItem.answer !== "string" || typeof nextItem.ordinalInMarkdown !== "number") { return []; } return [{ itemId: typeof nextItem.itemId === "string" ? nextItem.itemId : undefined, title: nextItem.title, answer: nextItem.answer, slot: typeof nextItem.slot === "number" ? nextItem.slot : undefined, ordinalInMarkdown: nextItem.ordinalInMarkdown, }]; }) : [], freeSlots: Array.isArray(rawGroupBlock.freeSlots) ? rawGroupBlock.freeSlots.filter((slot): slot is number => typeof slot === "number" && Number.isInteger(slot) && slot > 0) : [], lastSyncedAt: typeof rawGroupBlock.lastSyncedAt === "number" ? rawGroupBlock.lastSyncedAt : 0, orphan: Boolean(rawGroupBlock.orphan), }; } function collectMigratedFileNoteIds( rawFile: LegacyFileState, rawCards: Record, migratedCards: Record, ): number[] { const noteIds = new Set(); if (Array.isArray(rawFile.noteIds)) { for (const noteId of rawFile.noteIds) { const sanitized = sanitizeNoteId(noteId); if (sanitized !== undefined && migratedCards[toNoteIdKey(sanitized)]) { noteIds.add(sanitized); } } } if (Array.isArray(rawFile.cardIds)) { for (const cardId of rawFile.cardIds) { const sanitized = sanitizeNoteId(rawCards[cardId]?.noteId); if (sanitized !== undefined && migratedCards[toNoteIdKey(sanitized)]) { noteIds.add(sanitized); } } } return Array.from(noteIds); } function migratePendingWriteBack(pendingWriteBack: LegacyPendingWriteBackState[] | undefined): PendingWriteBackState[] { if (!Array.isArray(pendingWriteBack)) { return []; } return pendingWriteBack.flatMap((rawPending) => { const nextPending = migratePendingWriteBackState(rawPending); return nextPending ? [nextPending] : []; }); } function migratePendingWriteBackState(rawPending: LegacyPendingWriteBackState): PendingWriteBackState | null { const filePath = sanitizeNonEmptyString(rawPending.filePath); const blockStartLine = sanitizePositiveInteger(rawPending.blockStartLine); const expectedFileHash = sanitizeNonEmptyString(rawPending.expectedFileHash); const targetMarker = sanitizeNonEmptyString(rawPending.targetMarker); const rawBlockHash = sanitizeNonEmptyString(rawPending.rawBlockHash); const targetNoteId = sanitizeNoteId(rawPending.targetNoteId); if (!filePath || !blockStartLine || !expectedFileHash || !targetMarker || !rawBlockHash || !targetNoteId) { return null; } const markerKind = sanitizeMarkerKind(rawPending.markerKind); if (rawPending.markerKind !== undefined && !markerKind) { return null; } const targetGroupId = rawPending.targetGroupId === undefined ? undefined : sanitizeNonEmptyString(rawPending.targetGroupId); if (rawPending.targetGroupId !== undefined && !targetGroupId) { return null; } return { filePath, blockStartLine, expectedFileHash, targetMarker, rawBlockHash, targetNoteId, markerKind, targetGroupId, }; } function collectMigratedFileGroupIds( rawFile: LegacyFileState, rawGroupBlocks: Record, ): string[] { if (!Array.isArray(rawFile.groupIds)) { return []; } return rawFile.groupIds.filter((groupId): groupId is string => typeof groupId === "string" && Boolean(rawGroupBlocks[groupId])); } function toLegacyCardRecord(cards: PluginState["cards"] | LegacyPluginState["cards"] | undefined): Record { const next: Record = {}; const source = cards ?? {}; for (const cardId in source) { const rawCard = source[cardId]; next[cardId] = rawCard; } return next; } function toLegacyFileRecord(files: PluginState["files"] | LegacyPluginState["files"] | undefined): Record { const next: Record = {}; const source = files ?? {}; for (const filePath in source) { const rawFile = source[filePath]; next[filePath] = rawFile; } return next; } function toLegacyGroupBlockRecord( groupBlocks: PluginState["groupBlocks"] | LegacyPluginState["groupBlocks"] | undefined, ): Record { const next: Record = {}; const source = groupBlocks ?? {}; for (const groupId in source) { const rawGroupBlock = source[groupId]; next[groupId] = rawGroupBlock; } return next; } function sanitizeNoteId(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } function sanitizePositiveInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } function sanitizeNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } function sanitizeMarkerKind(value: unknown): PendingWriteBackState["markerKind"] | undefined { return value === "card-id" || value === "group-gi" ? value : undefined; }