feat(categories): add restore backup banner and permanent restore action (#122)
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
This commit is contained in:
parent
b9734acd93
commit
0132e6e164
11 changed files with 1034 additions and 7 deletions
|
|
@ -3,6 +3,7 @@
|
|||
## [Non publié]
|
||||
|
||||
### Ajouté
|
||||
- **Bannière (90 jours) et entrée permanente *Rétablir une sauvegarde* pour annuler une migration de catégories à partir de la sauvegarde automatique** (Paramètres → *Catégories*) : après une migration v2→v1, une bannière fermable (icône `ShieldCheck`) s'affiche désormais en haut de la carte Catégories pendant 90 jours et pointe vers la sauvegarde SREF automatique écrite par `categoryBackupService`. Une entrée dédiée *Rétablir une sauvegarde* reste accessible sous le lien de migration tant qu'une migration est enregistrée — même après les 90 jours — afin que le rétablissement ne soit jamais perdu. La fenêtre de confirmation lit le journal `last_categories_migration` pour récupérer son horodatage et son chemin de sauvegarde, impose une confirmation en deux étapes avec un bouton rouge *Rétablir*, bascule sur un sélecteur de fichier lorsque le chemin enregistré n'est plus sur disque, demande le NIP du profil lorsque le fichier SREF est chiffré, puis en cas de succès remet `categories_schema_version=v2`, inscrit `reverted_at` dans le journal et recharge l'application. La bannière se masque d'elle-même une fois la migration rétablie. Ajout de la commande Tauri `file_exists` pour la vérification préalable, nouveau service `categoryRestoreService` qui emballe `read_import_file` + `importTransactionsWithCategories` avec des codes d'erreur stables (#122)
|
||||
- **Page de migration des catégories en 3 étapes** (route `/settings/categories/migrate`, Paramètres → *Migrer vers la structure standard*) : les profils v2 peuvent désormais choisir de migrer vers la nouvelle taxonomie v1 IPC via un parcours guidé — *Découvrir* (arbre en lecture seule, réutilisé de la page Guide), *Simuler* (table 3 colonnes en dry-run avec badges de confiance haute / moyenne / basse / à réviser, panneau latéral cliquable montrant les 50 premières transactions impactées par ligne, sélecteur de cible en ligne pour les lignes non résolues, bouton *Suivant* bloqué tant qu'une ligne n'est pas résolue) et *Consentir* (case à cocher + champ NIP pour les profils protégés + loader 4 étapes). Au clic de confirmation, la page crée une sauvegarde SREF vérifiée via `categoryBackupService` (obligatoire, abort sur échec sans écriture BD) puis lance une transaction SQL atomique via le nouveau service `categoryMigrationService.applyMigration(plan, backup)` — BEGIN → INSERT de la taxonomie v1 → UPDATE des transactions / budgets / budget_templates / keywords / suppliers vers les nouveaux id v1 → replacement des catégories personnalisées sous un nouveau parent *Catégories personnalisées (migration)* → désactivation des catégories v2 → pose de `categories_schema_version='v1'` et journalisation dans `user_preferences.last_categories_migration` → COMMIT. Toute erreur déclenche un ROLLBACK, laissant le profil dans son état pré-migration. Les écrans de succès et d'échec affichent le chemin de la sauvegarde et, pour le succès, le nombre de lignes insérées / transactions, mots-clés et budgets migrés (#121)
|
||||
- **Bannière du tableau de bord invitant les profils v2 à découvrir la nouvelle taxonomie v1 IPC** : les profils existants (marqués `categories_schema_version='v2'`) voient désormais une bannière fermable en haut du tableau de bord qui pointe vers la nouvelle page Guide des catégories standard. La bannière est non destructive (CTA en lecture seule, aucun changement de catégories), ne s'affiche qu'aux profils v2 (les nouveaux profils semés en v1 ne la voient jamais), et sa fermeture est persistée dans `user_preferences` sous la clé `categories_v1_banner_dismissed` pour ne plus réapparaître (#118)
|
||||
- **Page Guide des catégories standard** (Paramètres → *Structure standard des catégories*, route `/settings/categories/standard`) : nouvelle page en lecture seule qui expose la taxonomie v1 IPC complète sous forme d'arbre navigable avec repli/expansion par racine, un compteur global en direct (racines · sous-catégories · feuilles · total), une recherche plein texte insensible aux accents sur les noms traduits, des info-bulles au survol affichant la clé `i18n_key`, le type et l'identifiant de chaque nœud, et un bouton *Exporter en PDF* qui ouvre la boîte d'impression du navigateur. Une règle `@media print` dédiée force l'affichage complet de toutes les branches à l'impression, peu importe l'état de repli à l'écran. Tous les libellés passent par `categoriesSeed.*` avec `name` en repli pour les futures lignes personnalisées. Aucune écriture en base, aucune action destructive (#117)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Settings banner (90-day) and permanent Restore action to roll back a category migration from the automatic pre-migration backup** (Settings → *Categories*): after a v2→v1 migration, a dismissable banner (`ShieldCheck` icon) now appears at the top of the Categories card for 90 days, pointing at the automatic SREF backup written by `categoryBackupService`. A dedicated *Restore a backup* entry stays available below the migrate link as long as a migration is recorded — even past the 90-day window — so the rollback is never lost. The confirm modal reads the `last_categories_migration` journal for its timestamp and backup path, enforces a two-step confirmation with a red *Restore* button, falls back to a file picker when the recorded path is missing on disk, prompts for the profile PIN when the SREF file is encrypted, and on success resets `categories_schema_version=v2` and stamps `reverted_at` on the journal before reloading the app. The banner hides automatically once the migration has been reverted. New Tauri command `file_exists` for the pre-flight presence check, new `categoryRestoreService` wrapping `read_import_file` + `importTransactionsWithCategories` with stable error codes (#122)
|
||||
- **3-step category migration page** (route `/settings/categories/migrate`, Settings → *Migrate to the standard structure*): legacy v2 profiles can now opt in to migrate to the new v1 IPC taxonomy through a guided flow — *Discover* (read-only tree reused from the guide page), *Simulate* (3-column dry-run table with high / medium / low / needs-review confidence badges, a clickable side panel showing the first 50 affected transactions per row, inline target picker for unresolved rows, next button blocked until every row is resolved), and *Consent* (checklist + optional PIN field for protected profiles + 4-step loader). On confirm, the page creates a verified SREF backup via `categoryBackupService` (mandatory, abort on failure with no DB write) and then runs an atomic SQL transaction via the new `categoryMigrationService.applyMigration(plan, backup)` — BEGIN → INSERT v1 taxonomy → UPDATE transactions / budgets / budget_templates / keywords / suppliers to the new v1 category ids → reparent custom categories under a new *Custom categories (migration)* parent → soft-deactivate the v2 seed categories → bump `categories_schema_version='v1'` and journal the run in `user_preferences.last_categories_migration` → COMMIT. Any thrown error triggers ROLLBACK so the profile stays in its pre-migration state. Success and error screens surface the backup path and (for success) the counts of rows inserted / transactions, keywords and budgets migrated (#121)
|
||||
- **Dashboard banner inviting v2 profiles to discover the new v1 IPC category taxonomy**: legacy profiles (tagged `categories_schema_version='v2'`) now see a dismissable banner at the top of the Dashboard pointing to the new standard categories guide page. The banner is non-destructive (read-only CTA, no category changes), only shown to v2 profiles (new v1-seeded profiles never see it), and its dismissal is persisted in `user_preferences` under `categories_v1_banner_dismissed` so it never reappears once closed (#118)
|
||||
- **Standard categories guide page** (Settings → *Standard category structure*, route `/settings/categories/standard`): new read-only page that exposes the full v1 IPC taxonomy as a navigable tree with expand/collapse per root, a live category counter (roots · subcategories · leaves · total), accent-insensitive full-text search over translated names, hover tooltips showing the `i18n_key` / type / ID of each node, and a *Export as PDF* button that triggers the browser print dialog. A dedicated `@media print` rule forces every branch to render fully expanded regardless of the on-screen collapse state. All labels resolve via `categoriesSeed.*` with `name` as fallback for future custom rows. No database writes, no destructive actions (#117)
|
||||
|
|
|
|||
|
|
@ -46,3 +46,18 @@ pub fn get_file_size(file_path: String) -> Result<u64, String> {
|
|||
fs::metadata(&file_path).map_err(|e| format!("Cannot stat file {}: {}", file_path, e))?;
|
||||
Ok(metadata.len())
|
||||
}
|
||||
|
||||
/// Return true when the given path points to an existing regular file. Used
|
||||
/// by the post-migration restore flow to detect that the recorded backup
|
||||
/// path is still reachable before opening the confirmation modal — when the
|
||||
/// file was moved or deleted, the UI falls back to a manual file picker.
|
||||
/// Never throws on a missing file (just returns `false`); only returns an
|
||||
/// error for unexpected I/O conditions other than "not found".
|
||||
#[tauri::command]
|
||||
pub fn file_exists(file_path: String) -> Result<bool, String> {
|
||||
match fs::metadata(&file_path) {
|
||||
Ok(meta) => Ok(meta.is_file()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(format!("Cannot stat file {}: {}", file_path, e)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ pub fn run() {
|
|||
commands::get_feedback_user_agent,
|
||||
commands::ensure_backup_dir,
|
||||
commands::get_file_size,
|
||||
commands::file_exists,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -1,31 +1,53 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderTree, ChevronRight, MoveRight } from "lucide-react";
|
||||
import { FolderTree, ChevronRight, MoveRight, RotateCcw } from "lucide-react";
|
||||
import { getPreference } from "../../services/userPreferenceService";
|
||||
import {
|
||||
readLastMigrationJournal,
|
||||
type RestorableMigrationJournal,
|
||||
} from "../../services/categoryRestoreService";
|
||||
import CategoriesMigrationBackupBanner from "./CategoriesMigrationBackupBanner";
|
||||
import CategoriesMigrationRestoreModal from "./CategoriesMigrationRestoreModal";
|
||||
|
||||
/**
|
||||
* Card that surfaces category-related entries in the Settings page.
|
||||
*
|
||||
* Two entries, depending on the profile's categories_schema_version:
|
||||
* Entries, decided by the profile's categories_schema_version and whether a
|
||||
* migration was previously recorded:
|
||||
* - "Standard categories guide" (always visible) — read-only tree (#117).
|
||||
* - "Migrate to the standard structure" (v2 only) — 3-step migration (#121).
|
||||
* - "Restore a backup" (when a migration journal exists, regardless of age)
|
||||
* — opens the same modal as the 90-day banner (#122).
|
||||
*
|
||||
* Profiles already on v1 never see the migrate entry (they're done).
|
||||
* Profiles already on v1 never see the migrate entry (they're done) but they
|
||||
* keep the Restore entry as long as a journal is present, so they can always
|
||||
* roll back even after the banner has expired.
|
||||
*/
|
||||
export default function CategoriesCard() {
|
||||
const { t } = useTranslation();
|
||||
const [showMigrate, setShowMigrate] = useState(false);
|
||||
const [journal, setJournal] = useState<RestorableMigrationJournal | null>(
|
||||
null,
|
||||
);
|
||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const version = await getPreference("categories_schema_version");
|
||||
const [version, loadedJournal] = await Promise.all([
|
||||
getPreference("categories_schema_version"),
|
||||
readLastMigrationJournal(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setShowMigrate(version === "v2");
|
||||
setJournal(loadedJournal);
|
||||
} catch {
|
||||
if (!cancelled) setShowMigrate(false);
|
||||
if (!cancelled) {
|
||||
setShowMigrate(false);
|
||||
setJournal(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
|
|
@ -33,8 +55,18 @@ export default function CategoriesCard() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// The permanent Restore entry is shown whenever a journal exists, even past
|
||||
// the 90-day banner window AND even after the user already reverted (a user
|
||||
// who wants to re-import the backup a second time is free to do so — the
|
||||
// SREF file is still valid as long as it is on disk).
|
||||
const showRestoreEntry = journal !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Post-migration 90-day banner — renders only within window and when
|
||||
un-dismissed and not-yet-reverted. */}
|
||||
<CategoriesMigrationBackupBanner />
|
||||
|
||||
<Link
|
||||
to="/settings/categories/standard"
|
||||
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
|
||||
|
|
@ -86,6 +118,41 @@ export default function CategoriesCard() {
|
|||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{showRestoreEntry && journal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRestoreModalOpen(true)}
|
||||
className="block w-full text-left bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
|
||||
<RotateCcw size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.categoriesCard.restoreEntry.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("settings.categoriesCard.restoreEntry.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRestoreModalOpen && journal && (
|
||||
<CategoriesMigrationRestoreModal
|
||||
journal={journal}
|
||||
onClose={() => setIsRestoreModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
133
src/components/settings/CategoriesMigrationBackupBanner.tsx
Normal file
133
src/components/settings/CategoriesMigrationBackupBanner.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldCheck, X, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY,
|
||||
readLastMigrationJournal,
|
||||
shouldShowBanner,
|
||||
type RestorableMigrationJournal,
|
||||
} from "../../services/categoryRestoreService";
|
||||
import { getPreference, setPreference } from "../../services/userPreferenceService";
|
||||
import CategoriesMigrationRestoreModal from "./CategoriesMigrationRestoreModal";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Post-migration backup banner (shown in Settings > Categories for 90 days).
|
||||
//
|
||||
// Decides visibility client-side using the `shouldShowBanner` pure helper and
|
||||
// three prefs:
|
||||
// - `last_categories_migration` JSON (written by categoryMigrationService).
|
||||
// - `categories_migration_banner_dismissed` (flag, "1" = dismissed).
|
||||
// - `reverted_at` (merged into the journal once a restore succeeds).
|
||||
//
|
||||
// The banner never surfaces the restore action directly — it opens a confirm
|
||||
// modal that enforces a two-step consent before the destructive wipe.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type Visibility = "loading" | "visible" | "hidden";
|
||||
|
||||
export default function CategoriesMigrationBackupBanner() {
|
||||
const { t } = useTranslation();
|
||||
const [visibility, setVisibility] = useState<Visibility>("loading");
|
||||
const [journal, setJournal] = useState<RestorableMigrationJournal | null>(
|
||||
null,
|
||||
);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [loadedJournal, dismissed] = await Promise.all([
|
||||
readLastMigrationJournal(),
|
||||
getPreference(CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setJournal(loadedJournal);
|
||||
setVisibility(
|
||||
shouldShowBanner(loadedJournal, dismissed) ? "visible" : "hidden",
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setVisibility("hidden");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function dismiss() {
|
||||
// Optimistically hide, then persist. Matches the pattern used in
|
||||
// CategoriesV1DiscoveryBanner — a failed write just means the banner will
|
||||
// reappear on next launch, which is an acceptable degradation.
|
||||
setVisibility("hidden");
|
||||
try {
|
||||
await setPreference(CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY, "1");
|
||||
} catch {
|
||||
// Swallow — see comment above.
|
||||
}
|
||||
}
|
||||
|
||||
if (visibility !== "visible" || !journal) return null;
|
||||
|
||||
// Compute the 90-day deadline for display. Falls back to the raw timestamp
|
||||
// if the journal date cannot be parsed (the banner is still useful in that
|
||||
// edge case — the CTA does not rely on this value).
|
||||
const expiryLabel = formatExpiry(journal.timestamp);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-3 rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 p-4"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 rounded-lg bg-[var(--primary)]/10 p-2 text-[var(--primary)]">
|
||||
<ShieldCheck size={18} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
<p className="font-semibold text-[var(--foreground)]">
|
||||
{t("settings.categoriesCard.restoreBanner.title")}
|
||||
</p>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
{t("settings.categoriesCard.restoreBanner.description", {
|
||||
expiry: expiryLabel,
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 font-medium text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
{t("settings.categoriesCard.restoreBanner.cta")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label={t("settings.categoriesCard.restoreBanner.dismiss")}
|
||||
className="shrink-0 rounded-md p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<CategoriesMigrationRestoreModal
|
||||
journal={journal}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce an end-of-window label: backup timestamp + 90 days, formatted in
|
||||
* the browser locale. Returns the raw journal timestamp on parse failure.
|
||||
*/
|
||||
function formatExpiry(journalTimestamp: string): string {
|
||||
const ts = Date.parse(journalTimestamp);
|
||||
if (Number.isNaN(ts)) return journalTimestamp;
|
||||
const deadline = new Date(ts + 90 * 24 * 60 * 60 * 1000);
|
||||
return deadline.toLocaleDateString();
|
||||
}
|
||||
360
src/components/settings/CategoriesMigrationRestoreModal.tsx
Normal file
360
src/components/settings/CategoriesMigrationRestoreModal.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, FolderOpen, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
backupFileExists,
|
||||
isFileEncrypted,
|
||||
pickBackupFile,
|
||||
restoreFromBackup,
|
||||
RestoreError,
|
||||
type RestorableMigrationJournal,
|
||||
} from "../../services/categoryRestoreService";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Confirmation modal used by both the in-window banner and the permanent
|
||||
// Settings entry to roll back a category migration. Mounted via a portal on
|
||||
// document.body so it escapes any ancestor stacking context.
|
||||
//
|
||||
// High-level flow:
|
||||
// 1. Mount with the recorded `journal` — show the date + path and two CTAs.
|
||||
// 2. On "Restore" click: verify the recorded file still exists on disk. If
|
||||
// not, flip to the "missing file" state and let the user pick a file.
|
||||
// 3. If the file is SREF-encrypted, prompt the PIN; otherwise proceed.
|
||||
// 4. Run `restoreFromBackup`, reload the app on success so every page sees
|
||||
// the restored state.
|
||||
//
|
||||
// The UX intentionally keeps destructive action behind an explicit "Restore"
|
||||
// button — a single click does not restore; the user must read the warning
|
||||
// and click again. The banner and the permanent Settings entry both route
|
||||
// to this same component.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type Phase =
|
||||
| "confirm" // initial screen with the data/path + Restore/Cancel buttons
|
||||
| "missing" // recorded path no longer on disk — user must pick a file
|
||||
| "password" // detected SREF encryption — ask for the PIN
|
||||
| "restoring" // in-flight restore call
|
||||
| "error"; // failed — surface the error and allow retry
|
||||
|
||||
interface CategoriesMigrationRestoreModalProps {
|
||||
journal: RestorableMigrationJournal;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Called after the restore finishes successfully. By default, the app is
|
||||
* reloaded to refresh all data-bound views. Consumers may override this
|
||||
* (e.g. in tests) but the default is the safe path.
|
||||
*/
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
export default function CategoriesMigrationRestoreModal({
|
||||
journal,
|
||||
onClose,
|
||||
onRestored,
|
||||
}: CategoriesMigrationRestoreModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<Phase>("confirm");
|
||||
const [effectivePath, setEffectivePath] = useState<string>(journal.backupPath);
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEncrypted, setIsEncrypted] = useState<boolean>(false);
|
||||
|
||||
// Close on Escape (disabled while restoring to avoid interrupting the wipe).
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && phase !== "restoring") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose, phase]);
|
||||
|
||||
const formattedDate = formatJournalDate(journal.timestamp);
|
||||
|
||||
// Centralised error mapping — keeps the rendered messages i18n-driven.
|
||||
function messageForError(e: unknown): string {
|
||||
if (e instanceof RestoreError) {
|
||||
return t(
|
||||
`settings.categoriesCard.restoreModal.errors.${e.code}`,
|
||||
t("settings.categoriesCard.restoreModal.errors.read_failed"),
|
||||
);
|
||||
}
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
async function beginRestore(pathToUse: string) {
|
||||
setError(null);
|
||||
|
||||
// 1. Verify the file is still on disk.
|
||||
const exists = await backupFileExists(pathToUse);
|
||||
if (!exists) {
|
||||
setPhase("missing");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If it is SREF-encrypted, branch to the password prompt.
|
||||
let encrypted = false;
|
||||
try {
|
||||
encrypted = await isFileEncrypted(pathToUse);
|
||||
} catch {
|
||||
// If we cannot even inspect the file, treat it as unreadable.
|
||||
setPhase("error");
|
||||
setError(t("settings.categoriesCard.restoreModal.errors.read_failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEncrypted(encrypted);
|
||||
setEffectivePath(pathToUse);
|
||||
|
||||
if (encrypted) {
|
||||
setPhase("password");
|
||||
return;
|
||||
}
|
||||
|
||||
await runRestore(pathToUse, null);
|
||||
}
|
||||
|
||||
async function runRestore(pathToUse: string, pw: string | null) {
|
||||
setPhase("restoring");
|
||||
setError(null);
|
||||
try {
|
||||
await restoreFromBackup(pathToUse, pw);
|
||||
if (onRestored) {
|
||||
onRestored();
|
||||
} else {
|
||||
// Full reload so every page (dashboard, transactions, etc.) reflects
|
||||
// the restored v2 state instead of stale v1 derived data.
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(messageForError(e));
|
||||
setPhase("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePickFile() {
|
||||
setError(null);
|
||||
try {
|
||||
const picked = await pickBackupFile();
|
||||
if (!picked) {
|
||||
// User cancelled; stay in the "missing" state so they can retry.
|
||||
return;
|
||||
}
|
||||
// Reset password between attempts on different files.
|
||||
setPassword("");
|
||||
await beginRestore(picked);
|
||||
} catch (e) {
|
||||
setError(messageForError(e));
|
||||
setPhase("error");
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
void runRestore(effectivePath, password.length > 0 ? password : null);
|
||||
}
|
||||
|
||||
const isBusy = phase === "restoring";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !isBusy) onClose();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="restore-modal-title"
|
||||
>
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 py-4 border-b border-[var(--border)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 shrink-0 rounded-lg bg-[var(--negative)]/10 p-2 text-[var(--negative)]">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="restore-modal-title" className="text-lg font-semibold">
|
||||
{t("settings.categoriesCard.restoreModal.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-0.5">
|
||||
{t("settings.categoriesCard.restoreModal.subtitle", {
|
||||
date: formattedDate,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
aria-label={t("settings.categoriesCard.restoreModal.close")}
|
||||
className="shrink-0 rounded-md p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* Destructive action warning — kept visible across every phase */}
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
{t("settings.categoriesCard.restoreModal.warning")}
|
||||
</p>
|
||||
|
||||
{phase === "confirm" && (
|
||||
<div className="rounded-lg bg-[var(--muted)] px-3 py-2 text-xs text-[var(--muted-foreground)] break-all">
|
||||
<p className="font-medium text-[var(--foreground)] mb-1">
|
||||
{t("settings.categoriesCard.restoreModal.filePath")}
|
||||
</p>
|
||||
<code>{journal.backupPath}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "missing" && (
|
||||
<div className="rounded-lg border border-[var(--negative)]/30 bg-[var(--negative)]/5 px-3 py-3 text-sm text-[var(--foreground)] space-y-2">
|
||||
<p className="font-medium text-[var(--negative)]">
|
||||
{t("settings.categoriesCard.restoreModal.fileMissingTitle")}
|
||||
</p>
|
||||
<p className="text-[var(--muted-foreground)] text-xs break-all">
|
||||
{journal.backupPath}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{t("settings.categoriesCard.restoreModal.fileMissingHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "password" && (
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-3">
|
||||
<label className="block text-sm font-medium">
|
||||
{t("settings.categoriesCard.restoreModal.passwordLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:border-[var(--primary)] focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{t("settings.categoriesCard.restoreModal.passwordHelp")}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{phase === "restoring" && (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t("settings.categoriesCard.restoreModal.restoring")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "error" && error && (
|
||||
<div className="rounded-lg border border-[var(--negative)]/30 bg-[var(--negative)]/5 px-3 py-3 text-sm text-[var(--negative)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
|
||||
{phase === "confirm" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("settings.categoriesCard.restoreModal.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginRestore(journal.backupPath)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("settings.categoriesCard.restoreModal.confirm")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "missing" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("settings.categoriesCard.restoreModal.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
{t("settings.categoriesCard.restoreModal.pickFile")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "password" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("settings.categoriesCard.restoreModal.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
runRestore(effectivePath, password.length > 0 ? password : null)
|
||||
}
|
||||
disabled={password.length === 0}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("settings.categoriesCard.restoreModal.confirm")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === "error" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{t("settings.categoriesCard.restoreModal.close")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
isEncrypted
|
||||
? setPhase("password")
|
||||
: void beginRestore(effectivePath)
|
||||
}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("settings.categoriesCard.restoreModal.retry")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the backup ISO timestamp as a human-readable local date+time.
|
||||
* Falls back to the raw string on invalid input.
|
||||
*/
|
||||
function formatJournalDate(iso: string): string {
|
||||
const ts = Date.parse(iso);
|
||||
if (Number.isNaN(ts)) return iso;
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
|
@ -576,7 +576,43 @@
|
|||
"standardGuideTitle": "Standard category structure",
|
||||
"standardGuideDescription": "Browse the CPI taxonomy (read-only)",
|
||||
"migrateTitle": "Migrate to the standard structure",
|
||||
"migrateDescription": "Preview, back up and migrate in 3 steps"
|
||||
"migrateDescription": "Preview, back up and migrate in 3 steps",
|
||||
"restoreEntry": {
|
||||
"title": "Restore a backup",
|
||||
"description": "Roll back a previous category migration from its automatic backup"
|
||||
},
|
||||
"restoreBanner": {
|
||||
"title": "Backup available",
|
||||
"description": "Your previous category structure is backed up until {{expiry}}. Restore it if the new structure does not suit you.",
|
||||
"cta": "Restore the backup",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"restoreModal": {
|
||||
"title": "Confirm restore",
|
||||
"subtitle": "Backup from {{date}}",
|
||||
"warning": "This action will replace ALL your current data with the backup content. This action is irreversible.",
|
||||
"filePath": "Backup file",
|
||||
"fileMissingTitle": "Backup file not found",
|
||||
"fileMissingHint": "The original backup file is no longer at its recorded location. Pick it manually to continue — it is usually in your Documents folder under Simpl-Resultat/backups/.",
|
||||
"pickFile": "Pick a backup file",
|
||||
"passwordLabel": "Profile PIN",
|
||||
"passwordHelp": "This backup is encrypted. Enter the PIN that was active at the time of the migration to decrypt it.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Restore",
|
||||
"close": "Close",
|
||||
"retry": "Try again",
|
||||
"restoring": "Restoring your profile — do not close the application...",
|
||||
"errors": {
|
||||
"file_missing": "The backup file was not found at the recorded location.",
|
||||
"read_failed": "The backup file could not be read.",
|
||||
"parse_failed": "The backup file could not be parsed. It may be corrupted.",
|
||||
"wrong_envelope_type": "This file is not a full pre-migration backup.",
|
||||
"no_recorded_migration": "No migration has been recorded on this profile.",
|
||||
"needs_password": "This backup is encrypted — a PIN is required.",
|
||||
"wrong_password": "Incorrect PIN. The backup could not be decrypted.",
|
||||
"import_failed": "The restore failed while rewriting your profile. Please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs",
|
||||
|
|
|
|||
|
|
@ -576,7 +576,43 @@
|
|||
"standardGuideTitle": "Structure standard des catégories",
|
||||
"standardGuideDescription": "Explorer la taxonomie IPC (lecture seule)",
|
||||
"migrateTitle": "Migrer vers la structure standard",
|
||||
"migrateDescription": "Aperçu, sauvegarde et migration en 3 étapes"
|
||||
"migrateDescription": "Aperçu, sauvegarde et migration en 3 étapes",
|
||||
"restoreEntry": {
|
||||
"title": "Rétablir une sauvegarde",
|
||||
"description": "Annuler une migration de catégories en restaurant sa sauvegarde automatique"
|
||||
},
|
||||
"restoreBanner": {
|
||||
"title": "Sauvegarde disponible",
|
||||
"description": "Votre ancienne structure de catégories est sauvegardée jusqu'au {{expiry}}. Rétablissez-la si la nouvelle structure ne vous convient pas.",
|
||||
"cta": "Rétablir la sauvegarde",
|
||||
"dismiss": "Fermer"
|
||||
},
|
||||
"restoreModal": {
|
||||
"title": "Confirmer le rétablissement",
|
||||
"subtitle": "Sauvegarde du {{date}}",
|
||||
"warning": "Cette action va remplacer TOUTES vos données actuelles par le contenu de la sauvegarde. Cette action est irréversible.",
|
||||
"filePath": "Fichier de sauvegarde",
|
||||
"fileMissingTitle": "Fichier de sauvegarde introuvable",
|
||||
"fileMissingHint": "Le fichier de sauvegarde n'est plus à son emplacement d'origine. Choisissez-le manuellement pour continuer — il se trouve habituellement dans le dossier Documents sous Simpl-Resultat/backups/.",
|
||||
"pickFile": "Choisir un fichier de sauvegarde",
|
||||
"passwordLabel": "NIP du profil",
|
||||
"passwordHelp": "Cette sauvegarde est chiffrée. Saisissez le NIP actif lors de la migration pour la déchiffrer.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Rétablir",
|
||||
"close": "Fermer",
|
||||
"retry": "Réessayer",
|
||||
"restoring": "Rétablissement en cours — ne fermez pas l'application...",
|
||||
"errors": {
|
||||
"file_missing": "Le fichier de sauvegarde est introuvable à l'emplacement enregistré.",
|
||||
"read_failed": "Le fichier de sauvegarde n'a pas pu être lu.",
|
||||
"parse_failed": "Le fichier de sauvegarde n'a pas pu être lu. Il est peut-être corrompu.",
|
||||
"wrong_envelope_type": "Ce fichier n'est pas une sauvegarde complète avant migration.",
|
||||
"no_recorded_migration": "Aucune migration n'a été enregistrée sur ce profil.",
|
||||
"needs_password": "Cette sauvegarde est chiffrée — un NIP est requis.",
|
||||
"wrong_password": "NIP incorrect. La sauvegarde n'a pas pu être déchiffrée.",
|
||||
"import_failed": "Le rétablissement a échoué lors de la réécriture de votre profil. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Journaux",
|
||||
|
|
|
|||
107
src/services/categoryRestoreService.test.ts
Normal file
107
src/services/categoryRestoreService.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isWithinBannerWindow,
|
||||
shouldShowBanner,
|
||||
type RestorableMigrationJournal,
|
||||
} from "./categoryRestoreService";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("isWithinBannerWindow", () => {
|
||||
it("returns true for a migration just done", () => {
|
||||
const now = Date.parse("2026-04-20T00:00:00Z");
|
||||
const ts = "2026-04-19T00:00:00Z";
|
||||
expect(isWithinBannerWindow(ts, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true on the 90-day boundary", () => {
|
||||
const now = Date.parse("2026-04-20T00:00:00Z");
|
||||
const ts = new Date(now - 90 * DAY).toISOString();
|
||||
expect(isWithinBannerWindow(ts, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false past 90 days", () => {
|
||||
const now = Date.parse("2026-04-20T00:00:00Z");
|
||||
const ts = new Date(now - 91 * DAY).toISOString();
|
||||
expect(isWithinBannerWindow(ts, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a timestamp in the future", () => {
|
||||
const now = Date.parse("2026-04-20T00:00:00Z");
|
||||
const ts = new Date(now + DAY).toISOString();
|
||||
expect(isWithinBannerWindow(ts, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an unparseable timestamp", () => {
|
||||
const now = Date.parse("2026-04-20T00:00:00Z");
|
||||
expect(isWithinBannerWindow("not-a-date", now)).toBe(false);
|
||||
});
|
||||
|
||||
it("respects a custom window length", () => {
|
||||
const now = Date.parse("2026-04-20T00:00:00Z");
|
||||
const ts = new Date(now - 31 * DAY).toISOString();
|
||||
expect(isWithinBannerWindow(ts, now, 30)).toBe(false);
|
||||
expect(isWithinBannerWindow(ts, now, 60)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowBanner", () => {
|
||||
const now = Date.parse("2026-04-20T00:00:00Z");
|
||||
|
||||
function journal(
|
||||
overrides: Partial<RestorableMigrationJournal> = {},
|
||||
): RestorableMigrationJournal {
|
||||
return {
|
||||
timestamp: "2026-04-15T10:00:00Z",
|
||||
backupPath: "/tmp/profile_avant-migration.sref",
|
||||
outcome: {
|
||||
insertedV1Count: 1,
|
||||
updatedTransactionsCount: 2,
|
||||
updatedBudgetsCount: 0,
|
||||
updatedKeywordsCount: 0,
|
||||
deletedV2Count: 0,
|
||||
customPreservedCount: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("hides the banner when no migration has run", () => {
|
||||
expect(shouldShowBanner(null, null, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the banner for a recent un-dismissed un-reverted migration", () => {
|
||||
expect(shouldShowBanner(journal(), null, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the banner when the user already dismissed it", () => {
|
||||
expect(shouldShowBanner(journal(), "1", now)).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the banner when the migration was already reverted", () => {
|
||||
expect(
|
||||
shouldShowBanner(
|
||||
journal({ reverted_at: "2026-04-19T12:00:00Z" }),
|
||||
null,
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the banner past the 90-day window", () => {
|
||||
expect(
|
||||
shouldShowBanner(
|
||||
journal({ timestamp: "2026-01-01T00:00:00Z" }),
|
||||
null,
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores arbitrary dismissal-pref values that are not '1'", () => {
|
||||
// Defensive: we treat anything other than the literal "1" as "not
|
||||
// dismissed" so an accidental write of "0" or "" still shows the banner.
|
||||
expect(shouldShowBanner(journal(), "0", now)).toBe(true);
|
||||
expect(shouldShowBanner(journal(), "", now)).toBe(true);
|
||||
});
|
||||
});
|
||||
270
src/services/categoryRestoreService.ts
Normal file
270
src/services/categoryRestoreService.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
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 };
|
||||
}
|
||||
Loading…
Reference in a new issue