Surfaces the pre-migration SREF backup to the user so they can roll back a category migration without digging into the filesystem: - 90-day dismissable banner at the top of Settings > Categories pointing to the automatic backup (hidden once reverted, once dismissed, or past 90d). - Permanent "Restore a backup" entry in Settings > Categories, available as long as a migration journal exists (even past the 90-day window). - Confirmation modal with two-step consent, red Restore button, fallback file picker when the recorded path is missing, PIN prompt for encrypted SREF files, full-page reload on success. Internals: - New `categoryRestoreService` wrapping `read_import_file` + `importTransactionsWithCategories` with stable error codes (file_missing, read_failed, parse_failed, wrong_envelope_type, needs_password, wrong_password, import_failed). - New `file_exists` Tauri command for the pre-flight presence check. - On success: `categories_schema_version=v2` + merge `reverted_at` into `last_categories_migration`. - Pure `shouldShowBanner` / `isWithinBannerWindow` helpers with tests. - FR/EN i18n keys under `settings.categoriesCard.restore*`. - CHANGELOG entries in both locales. Closes #122
270 lines
9.9 KiB
TypeScript
270 lines
9.9 KiB
TypeScript
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<RestorableMigrationJournal>;
|
|
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<void> {
|
|
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<boolean> {
|
|
try {
|
|
return await invoke<boolean>("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<boolean> {
|
|
return invoke<boolean>("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<string | null> {
|
|
return invoke<string | null>("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<RestoreResult> {
|
|
// 1. Read the file — decryption happens inside read_import_file when the
|
|
// magic prefix is present.
|
|
let content: string;
|
|
try {
|
|
content = await invoke<string>("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 };
|
|
}
|