import { invoke } from "@tauri-apps/api/core"; import { parseImportedJson, importTransactionsWithCategories, } from "./dataExportService"; import { getPreference, setPreference } from "./userPreferenceService"; import type { LastMigrationJournal } from "./categoryMigrationService"; // ----------------------------------------------------------------------------- // Category restore service — rolls back the v2 → v1 categories migration by // importing the pre-migration SREF backup created by categoryBackupService. // // The service is destructive by design: it wipes and re-inserts the entire // profile from the backup payload (same semantics as the full-profile // "transactions_with_categories" import). Calling it is the user's explicit // choice, gated by a confirmation modal in the UI layer. // // Flow: // 1. Resolve the backup file path — either the one recorded in // `last_categories_migration.backupPath`, or a user-picked file when the // recorded path no longer exists on disk. // 2. Read it via `read_import_file` (Rust transparently handles the SREF // AES-256-GCM decryption when the file starts with the magic bytes). // 3. Parse it, assert `export_type === "transactions_with_categories"`. // 4. Call `importTransactionsWithCategories` to wipe + restore everything. // 5. Reset `categories_schema_version` back to `v2` and merge a `reverted_at` // timestamp into `last_categories_migration`. // // The service never swallows errors — the caller (modal) is responsible for // surfacing them via i18n-mapped messages. // ----------------------------------------------------------------------------- export const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version"; export const LAST_CATEGORIES_MIGRATION_KEY = "last_categories_migration"; export const CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY = "categories_migration_banner_dismissed"; /** Extended journal shape: the base written by applyMigration plus the * optional `reverted_at` stamped by a successful restore. */ export interface RestorableMigrationJournal extends LastMigrationJournal { reverted_at?: string; } export interface RestoreResult { /** Path that was actually restored from (may differ from the recorded one * when the user picked a fallback file). */ filePath: string; /** ISO-8601 timestamp recorded on `last_categories_migration.reverted_at`. */ revertedAt: string; } export type RestoreErrorCode = | "file_missing" | "read_failed" | "parse_failed" | "wrong_envelope_type" | "no_recorded_migration" | "needs_password" | "wrong_password" | "import_failed"; export class RestoreError extends Error { public readonly code: RestoreErrorCode; public readonly detail: string; constructor(code: RestoreErrorCode, detail: string) { super(`restore_failed:${code}:${detail}`); this.name = "RestoreError"; this.code = code; this.detail = detail; } } // ----------------------------------------------------------------------------- // Journal helpers — safe JSON parsing with best-effort typing // ----------------------------------------------------------------------------- /** * Read and parse the journal written by applyMigration. Returns `null` when * no migration has ever been run, or when the stored value is not valid JSON * (we never throw on that — a corrupted journal is equivalent to "no record" * for UI purposes). Shape is validated loosely: we only require `timestamp` * and `backupPath` strings, which are the fields the restore flow actually * consumes. */ export async function readLastMigrationJournal(): Promise< RestorableMigrationJournal | null > { const raw = await getPreference(LAST_CATEGORIES_MIGRATION_KEY); if (!raw) return null; try { const parsed = JSON.parse(raw) as Partial; if ( typeof parsed.timestamp !== "string" || typeof parsed.backupPath !== "string" ) { return null; } return parsed as RestorableMigrationJournal; } catch { return null; } } /** * Compute whether a migration is within the 90-day banner window. Pure * function — exported for tests. */ export function isWithinBannerWindow( journalTimestamp: string, nowMs: number = Date.now(), windowDays: number = 90, ): boolean { const ts = Date.parse(journalTimestamp); if (Number.isNaN(ts)) return false; const ageMs = nowMs - ts; const windowMs = windowDays * 24 * 60 * 60 * 1000; return ageMs >= 0 && ageMs <= windowMs; } /** * Check whether the banner should be shown given the three inputs we read * from user_preferences: * - journal: the parsed `last_categories_migration` JSON (null ⇒ never); * - dismissedPref: the raw value of `categories_migration_banner_dismissed`; * - now: the current clock (injectable for tests). * * Pure function — exported so the UI and tests share the same rules. */ export function shouldShowBanner( journal: RestorableMigrationJournal | null, dismissedPref: string | null, now: number = Date.now(), ): boolean { if (!journal) return false; if (journal.reverted_at) return false; if (dismissedPref === "1") return false; return isWithinBannerWindow(journal.timestamp, now); } /** * Merge a `reverted_at` stamp into the existing journal and persist it. * Leaves the rest of the object intact. No-op when there is no recorded * journal — the caller must have one (the restore flow is only reachable * when we successfully read one upstream). */ export async function markMigrationReverted( revertedAt: string = new Date().toISOString(), ): Promise { const journal = await readLastMigrationJournal(); if (!journal) return; const updated: RestorableMigrationJournal = { ...journal, reverted_at: revertedAt, }; await setPreference(LAST_CATEGORIES_MIGRATION_KEY, JSON.stringify(updated)); } // ----------------------------------------------------------------------------- // Tauri wrappers // ----------------------------------------------------------------------------- /** Check whether a file is still on disk. Returns false when the path was * moved or removed. */ export async function backupFileExists(path: string): Promise { try { return await invoke("file_exists", { filePath: path }); } catch { // If even the stat call fails we treat the file as missing so the UI // falls back to the manual file picker rather than hanging. return false; } } /** Detect whether a file is SREF-encrypted (has the `SREF` magic prefix). */ export async function isFileEncrypted(path: string): Promise { return invoke("is_file_encrypted", { filePath: path }); } /** Open the native file picker filtered to SREF/JSON. Returns null when the * user cancels. */ export async function pickBackupFile(): Promise { return invoke("pick_import_file", { filters: [["Simpl'Result Backup", ["sref", "json"]]], }); } // ----------------------------------------------------------------------------- // Core restore flow — wipes the profile and re-imports the SREF payload // ----------------------------------------------------------------------------- /** * Restore a profile from a pre-migration SREF backup and mark the migration * as reverted. This is the destructive write performed after confirmation. * * @param filePath Absolute path to the backup SREF/JSON file. * @param password Clear-text PIN — required when the file is encrypted * (SREF magic detected). Pass `null` for plaintext JSON * backups or when the profile had no PIN. * * Throws a `RestoreError` on any failure; on success updates * `categories_schema_version=v2` and stamps `reverted_at` on the journal. */ export async function restoreFromBackup( filePath: string, password: string | null, ): Promise { // 1. Read the file — decryption happens inside read_import_file when the // magic prefix is present. let content: string; try { content = await invoke("read_import_file", { filePath, password, }); } catch (e) { const message = e instanceof Error ? e.message : String(e); const lower = message.toLowerCase(); if (lower.includes("password is required")) { throw new RestoreError("needs_password", message); } if ( lower.includes("decryption failed") || lower.includes("wrong password") ) { throw new RestoreError("wrong_password", message); } throw new RestoreError("read_failed", message); } // 2. Parse the envelope. The export_type MUST be // `transactions_with_categories` — anything else would be a user error // (they picked the wrong file). let envelope; try { envelope = parseImportedJson(content).envelope; } catch (e) { const message = e instanceof Error ? e.message : String(e); throw new RestoreError("parse_failed", message); } if (envelope.export_type !== "transactions_with_categories") { throw new RestoreError( "wrong_envelope_type", `got ${envelope.export_type}, expected transactions_with_categories`, ); } // 3. Perform the destructive re-import. importTransactionsWithCategories // wipes transactions/imported_files/import_sources/keywords/suppliers/ // categories and re-inserts them from the envelope. const filename = filePath.split(/[/\\]/).pop() ?? "backup.sref"; try { await importTransactionsWithCategories(envelope.data, filename); } catch (e) { const message = e instanceof Error ? e.message : String(e); throw new RestoreError("import_failed", message); } // 4. Reset schema version and stamp the journal. Both are best-effort in the // sense that the DB restore has already succeeded by this point — we // still surface a preference write failure because stale prefs would // make the banner misleading. const revertedAt = new Date().toISOString(); await setPreference(CATEGORIES_SCHEMA_VERSION_KEY, "v2"); await markMigrationReverted(revertedAt); return { filePath, revertedAt }; }