Simpl-Resultat/src/hooks/useDataExport.ts
le king fu f377d760af
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
fix(export): preserve import sources and templates across data export/import
Exporting then re-importing data destroyed every import configuration:
dataExportService serialised only categories, suppliers, keywords and
transactions, then ran DELETE FROM import_sources on restore and replaced
them with a synthetic 'Data Import' source. After restoring a backup, every
source had to be reconfigured by hand.

- Serialise import_sources and import_config_templates into the envelope,
  with an explicit format_version; a file without one is the earlier format
  and its missing arrays are treated as empty.
- Wrap wipe + restore in withTransaction, which the service had nowhere:
  a constraint violation mid-restore used to destroy financial history with
  no rollback.
- Restore templates BEFORE sources (template_id is a foreign key), upserting
  by name and remapping template_id through the resolved ids, so restoring
  into a profile that already has templates no longer hits UNIQUE(name).
- Whitelist amount_mode and sign_convention at the import boundary with a
  readable message rather than an SQLite constraint error.

Resolves #331

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:58:52 -04:00

129 lines
3.9 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,
getExportImportSources,
getExportImportTemplates,
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();
// The import configurations travel with the transaction modes — the
// two the restore wipes `import_sources` for. A categories-only
// backup neither carries them nor touches them (#331).
data.import_sources = await getExportImportSources();
data.import_config_templates = await getExportImportTemplates();
}
// 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 };
}