All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
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>
129 lines
3.9 KiB
TypeScript
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 };
|
|
}
|