Simpl-Resultat/src/hooks/useDataExport.ts
Le-King-Fu 87e8f26754 feat: add data export/import with optional AES-256-GCM encryption (#3)
Add export (JSON/CSV) and import (full replace) to the Settings page.
Export supports 3 modes (transactions+categories, transactions only,
categories only) with optional password encryption using Argon2id key
derivation. Import detects encrypted .sref files, prompts for password,
and shows a destructive confirmation modal before replacing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 11:40:28 +00:00

122 lines
3.5 KiB
TypeScript

import { useReducer, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import {
getExportCategories,
getExportSuppliers,
getExportKeywords,
getExportTransactions,
serializeToJson,
serializeTransactionsToCsv,
type ExportMode,
type ExportFormat,
} from "../services/dataExportService";
type ExportStatus = "idle" | "exporting" | "success" | "error";
interface ExportState {
status: ExportStatus;
error: string | null;
}
type ExportAction =
| { type: "EXPORT_START" }
| { type: "EXPORT_SUCCESS" }
| { type: "EXPORT_ERROR"; error: string }
| { type: "RESET" };
const initialState: ExportState = {
status: "idle",
error: null,
};
function reducer(_state: ExportState, action: ExportAction): ExportState {
switch (action.type) {
case "EXPORT_START":
return { status: "exporting", error: null };
case "EXPORT_SUCCESS":
return { status: "success", error: null };
case "EXPORT_ERROR":
return { status: "error", error: action.error };
case "RESET":
return initialState;
}
}
export function useDataExport() {
const [state, dispatch] = useReducer(reducer, initialState);
const performExport = useCallback(
async (mode: ExportMode, format: ExportFormat, password?: string) => {
dispatch({ type: "EXPORT_START" });
try {
const appVersion = await getVersion();
// Gather data based on mode
const data: Record<string, unknown> = {};
if (mode === "transactions_with_categories" || mode === "categories_only") {
data.categories = await getExportCategories();
data.suppliers = await getExportSuppliers();
data.keywords = await getExportKeywords();
}
if (mode === "transactions_with_categories" || mode === "transactions_only") {
data.transactions = await getExportTransactions();
}
// Serialize
let content: string;
let defaultExt: string;
if (format === "csv") {
content = serializeTransactionsToCsv(data.transactions as never[]);
defaultExt = "csv";
} else {
content = serializeToJson(mode, data, appVersion);
defaultExt = "json";
}
// Determine file extension and name
const isEncrypted = !!password && password.length > 0;
const ext = isEncrypted ? "sref" : defaultExt;
const timestamp = new Date().toISOString().slice(0, 10);
const defaultName = `simplresult_${mode}_${timestamp}.${ext}`;
// Build filters
const filters: [string, string[]][] = isEncrypted
? [["Simpl'Result Encrypted", ["sref"]]]
: format === "csv"
? [["CSV Files", ["csv"]]]
: [["JSON Files", ["json"]]];
// Pick save location
const filePath = await invoke<string | null>("pick_save_file", {
defaultName,
filters,
});
if (!filePath) {
dispatch({ type: "RESET" });
return; // User cancelled
}
// Write file
await invoke("write_export_file", {
filePath,
content,
password: isEncrypted ? password : null,
});
dispatch({ type: "EXPORT_SUCCESS" });
} catch (e) {
dispatch({
type: "EXPORT_ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
},
[]
);
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
return { state, performExport, reset };
}