feat(state): support full export and preferences restoration
This commit is contained in:
1 parent
0d1ccff6b2
commit
b36047d218
4 files changed
+402
-8
No files matched your search
@@ -0,0 +1,121 @@
|
||||
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<string, unknown>;
|
||||
daily: DailyLogMap;
|
||||
ignored: Record<string, { at: string }>;
|
||||
customGames: CustomGames;
|
||||
customEvents: CustomEvents;
|
||||
prefs?: Prefs | undefined;
|
||||
}
|
||||
|
||||
export interface ParsedImport {
|
||||
progress: ProgressMap | null;
|
||||
daily: DailyLogMap | null;
|
||||
ignored: Record<string, { at: string }> | null;
|
||||
customGames: unknown;
|
||||
customEvents: 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<string, unknown>,
|
||||
daily: DailyLogMap,
|
||||
ignored: Record<string, { at: string }>,
|
||||
own: { games: CustomGames; events: CustomEvents },
|
||||
prefs?: Prefs | undefined,
|
||||
): ExportData {
|
||||
return {
|
||||
format: "gacha-tracker-export",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
progress,
|
||||
daily,
|
||||
ignored,
|
||||
customGames: own.games,
|
||||
customEvents: own.events,
|
||||
...(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;
|
||||
prefs?: unknown;
|
||||
};
|
||||
if (data.format !== "gacha-tracker-export") return null;
|
||||
|
||||
const asRecord = (v: unknown) =>
|
||||
typeof v === "object" && v !== null
|
||||
? (v as Record<string, unknown>)
|
||||
: null;
|
||||
const asMarks = (v: unknown) =>
|
||||
typeof v === "object" && v !== null
|
||||
? (v as Record<string, { at: string }>)
|
||||
: null;
|
||||
|
||||
let progress: ProgressMap | null = asRecord(data.progress) as ProgressMap | null;
|
||||
const legacy = asRecord(data.completions) as Record<string, { at: string }> | 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,
|
||||
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);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { SortMode } from "./sort.ts";
|
||||
import { KEYS, readJson, writeJson } from "./storage.ts";
|
||||
import type { TimelineGroup } from "./lanes.ts";
|
||||
import { DEFAULT_THEME_CHOICE, type ThemeChoice } from "./theme.ts";
|
||||
import { DEFAULT_DAY_WIDTH } from "./zoom.ts";
|
||||
import { DEFAULT_DAY_WIDTH, snapDayWidth } from "./zoom.ts";
|
||||
|
||||
/**
|
||||
* Which of the two views the reader is looking at.
|
||||
@@ -62,11 +62,11 @@ export interface Prefs {
|
||||
* `JSON.stringify` drops it on the way to storage.
|
||||
*
|
||||
* Not a key space and not a migration: one more field in the single `prefs`
|
||||
* blob, like `timelineGroup`. It rides the export for free, though note that
|
||||
* `importProgress` restores progress, dailies, ignores and the reader's own
|
||||
* games — never prefs — so an imported file does not bring an order back.
|
||||
* That asymmetry predates this field and applies to region, theme and view
|
||||
* alike.
|
||||
* blob, like `timelineGroup`. It rides the export for free. Standard
|
||||
* progress import restores progress, dailies, ignores and the reader's own
|
||||
* games — leaving local prefs intact — while "Import all" restores preferences
|
||||
* alongside them, bringing game order, region, theme and active games back
|
||||
* when migrating hosts.
|
||||
*/
|
||||
gameOrder?: LaneId[] | undefined;
|
||||
/**
|
||||
@@ -269,6 +269,95 @@ export function adoptRenamed(
|
||||
return { ...rest, showUpcoming: timelineUpcoming };
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes incoming preferences (e.g. from an imported backup file) and restores a
|
||||
* clean, type-safe Prefs object, falling back to defaults for any missing or
|
||||
* invalid values.
|
||||
*/
|
||||
export function restorePrefsValue(incoming: unknown): Prefs {
|
||||
const base = defaults();
|
||||
if (typeof incoming !== "object" || incoming === null) {
|
||||
return base;
|
||||
}
|
||||
const raw = adoptRenamed(incoming as Partial<Prefs> & { timelineUpcoming?: boolean });
|
||||
const result: Prefs = {
|
||||
...base,
|
||||
...raw,
|
||||
};
|
||||
|
||||
// Region
|
||||
if (result.region !== "america" && result.region !== "europe" && result.region !== "asia") {
|
||||
result.region = base.region;
|
||||
}
|
||||
|
||||
// Games arrays
|
||||
if (!Array.isArray(result.hiddenGames)) {
|
||||
result.hiddenGames = base.hiddenGames;
|
||||
} else {
|
||||
result.hiddenGames = result.hiddenGames.filter((g): g is LaneId => typeof g === "string");
|
||||
}
|
||||
|
||||
if (result.knownGames !== undefined) {
|
||||
if (!Array.isArray(result.knownGames)) {
|
||||
delete result.knownGames;
|
||||
} else {
|
||||
result.knownGames = result.knownGames.filter((g): g is LaneId => typeof g === "string");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.gameOrder !== undefined) {
|
||||
if (!Array.isArray(result.gameOrder)) {
|
||||
result.gameOrder = undefined;
|
||||
} else {
|
||||
result.gameOrder = result.gameOrder.filter((g): g is LaneId => typeof g === "string");
|
||||
}
|
||||
}
|
||||
|
||||
// focusGame
|
||||
if (result.focusGame !== null && typeof result.focusGame !== "string") {
|
||||
result.focusGame = null;
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (result.sort !== "ending" && result.sort !== "doing") {
|
||||
result.sort = base.sort;
|
||||
}
|
||||
|
||||
// View
|
||||
if (result.view !== "soon" && result.view !== "timeline") {
|
||||
result.view = base.view;
|
||||
}
|
||||
|
||||
// Zoom
|
||||
if (typeof result.timelineDayWidth === "number") {
|
||||
result.timelineDayWidth = snapDayWidth(result.timelineDayWidth);
|
||||
} else {
|
||||
result.timelineDayWidth = base.timelineDayWidth;
|
||||
}
|
||||
|
||||
// Timeline grouping
|
||||
if (result.timelineGroup !== "game" && result.timelineGroup !== "ending") {
|
||||
result.timelineGroup = base.timelineGroup;
|
||||
}
|
||||
|
||||
// Theme
|
||||
if (result.theme !== "dark" && result.theme !== "light" && result.theme !== "system") {
|
||||
result.theme = base.theme;
|
||||
}
|
||||
|
||||
// Booleans
|
||||
result.showUpcoming = typeof result.showUpcoming === "boolean" ? result.showUpcoming : base.showUpcoming;
|
||||
result.timelineSplitUpcoming = typeof result.timelineSplitUpcoming === "boolean" ? result.timelineSplitUpcoming : base.timelineSplitUpcoming;
|
||||
result.detectDaily = typeof result.detectDaily === "boolean" ? result.detectDaily : base.detectDaily;
|
||||
result.showChores = typeof result.showChores === "boolean" ? result.showChores : base.showChores;
|
||||
result.showCompleted = typeof result.showCompleted === "boolean" ? result.showCompleted : base.showCompleted;
|
||||
result.showIgnored = typeof result.showIgnored === "boolean" ? result.showIgnored : base.showIgnored;
|
||||
result.regionConfirmed = typeof result.regionConfirmed === "boolean" ? result.regionConfirmed : base.regionConfirmed;
|
||||
result.onboarded = typeof result.onboarded === "boolean" ? result.onboarded : base.onboarded;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function usePrefs() {
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => ({
|
||||
...defaults(),
|
||||
@@ -283,6 +372,10 @@ export function usePrefs() {
|
||||
setPrefs((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const restorePrefs = useCallback((incoming: unknown) => {
|
||||
setPrefs(restorePrefsValue(incoming));
|
||||
}, []);
|
||||
|
||||
const toggleGame = useCallback((game: LaneId) => {
|
||||
setPrefs((prev) => ({
|
||||
...prev,
|
||||
@@ -292,5 +385,6 @@ export function usePrefs() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return { prefs, update, toggleGame };
|
||||
return { prefs, update, toggleGame, restorePrefs };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user