import type { CustomEvents, CustomGames } from "../../shared/custom.ts"; import type { DailyLogMap } from "./useDailyLog.ts"; import type { Prefs } from "./usePrefs.ts"; import type { ProgressMap } from "./useProgress.ts"; export interface ExportData { format: "gacha-tracker-export"; version: 1; exportedAt: string; progress: Record; daily: DailyLogMap; ignored: Record; customGames: CustomGames; customEvents: CustomEvents; /** Weekly and monthly tasks, as stored — readable to this build or not. */ tasks?: Record | undefined; /** Numbers entered against targets, per item per period. */ amounts?: Record> | undefined; prefs?: Prefs | undefined; } export interface ParsedImport { progress: ProgressMap | null; daily: DailyLogMap | null; ignored: Record | null; customGames: unknown; customEvents: unknown; /** Validated by the task and amount stores' own merges, not here. */ tasks: unknown; amounts: unknown; prefs: unknown | null; } /** * Builds the serializable JSON export object. * When `prefs` is provided, includes preferences for moving hosts ("Export all"). * When omitted, exports progress and custom data only ("Export"). */ export function buildExportData( progress: Record, daily: DailyLogMap, ignored: Record, own: { games: CustomGames; events: CustomEvents; tasks?: Record | undefined; amounts?: Record> | undefined; }, prefs?: Prefs | undefined, ): ExportData { return { format: "gacha-tracker-export", version: 1, exportedAt: new Date().toISOString(), progress, daily, ignored, customGames: own.games, customEvents: own.events, // Additive keys, like customGames was: a file without them is simply from // a device that had none. ...(own.tasks !== undefined ? { tasks: own.tasks } : {}), ...(own.amounts !== undefined ? { amounts: own.amounts } : {}), ...(prefs !== undefined ? { prefs } : {}), }; } /** * Validates and parses raw JSON from an import file. * Returns null if the file is not a valid Event Clock export. */ export function parseImportData(parsed: unknown): ParsedImport | null { if (typeof parsed !== "object" || parsed === null) return null; const data = parsed as { format?: string; progress?: unknown; completions?: unknown; daily?: unknown; ignored?: unknown; customGames?: unknown; customEvents?: unknown; tasks?: unknown; amounts?: unknown; prefs?: unknown; }; if (data.format !== "gacha-tracker-export") return null; const asRecord = (v: unknown) => typeof v === "object" && v !== null ? (v as Record) : null; const asMarks = (v: unknown) => typeof v === "object" && v !== null ? (v as Record) : null; let progress: ProgressMap | null = asRecord(data.progress) as ProgressMap | null; const legacy = asRecord(data.completions) as Record | null; if (progress === null && legacy !== null) { progress = Object.fromEntries( Object.entries(legacy).map(([id, m]) => [id, { ...m, status: "done" }]), ); } const daily = typeof data.daily === "object" && data.daily !== null ? (data.daily as DailyLogMap) : null; const ignored = asMarks(data.ignored); const prefs = typeof data.prefs === "object" && data.prefs !== null ? data.prefs : null; return { progress, daily, ignored, customGames: data.customGames, customEvents: data.customEvents, tasks: data.tasks, amounts: data.amounts, prefs, }; } /** * Initiates a browser download of a JSON object as a file. */ export function triggerDownload(data: unknown, filename: string): void { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.style.display = "none"; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); }