diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 53aac93..0839086 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -62,6 +97,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -270,6 +317,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -471,6 +527,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -616,6 +682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -656,6 +723,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.21.3" @@ -1357,6 +1433,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1873,6 +1959,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2580,6 +2675,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -2682,6 +2783,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -2927,6 +3039,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3772,7 +3896,10 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" name = "simpl-result" version = "0.2.7" dependencies = [ + "aes-gcm", + "argon2", "encoding_rs", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -5097,6 +5224,16 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d2864ef..4c38b04 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,4 +29,7 @@ serde_json = "1" sha2 = "0.10" encoding_rs = "0.8" walkdir = "2" +aes-gcm = "0.10" +argon2 = "0.5" +rand = "0.8" diff --git a/src-tauri/src/commands/export_import_commands.rs b/src-tauri/src/commands/export_import_commands.rs new file mode 100644 index 0000000..613581a --- /dev/null +++ b/src-tauri/src/commands/export_import_commands.rs @@ -0,0 +1,143 @@ +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Nonce}; +use argon2::Argon2; +use rand::RngCore; +use std::fs; +use tauri_plugin_dialog::DialogExt; + +const MAGIC: &[u8; 4] = b"SREF"; +const VERSION: u8 = 0x01; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // 33 bytes + +fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32], String> { + let params = argon2::Params::new(65536, 3, 1, Some(32)) + .map_err(|e| format!("Argon2 params error: {}", e))?; + let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let mut key = [0u8; 32]; + argon2 + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| format!("Key derivation error: {}", e))?; + Ok(key) +} + +fn encrypt_data(plaintext: &[u8], password: &str) -> Result, String> { + let mut salt = [0u8; SALT_LEN]; + OsRng.fill_bytes(&mut salt); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + + let key = derive_key(password, &salt)?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| format!("Cipher init error: {}", e))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| format!("Encryption error: {}", e))?; + + let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + output.extend_from_slice(MAGIC); + output.push(VERSION); + output.extend_from_slice(&salt); + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&ciphertext); + + Ok(output) +} + +fn decrypt_data(data: &[u8], password: &str) -> Result, String> { + if data.len() < HEADER_LEN + 16 { + return Err("File is too small to be a valid encrypted file".to_string()); + } + if &data[0..4] != MAGIC { + return Err("Not a valid SREF encrypted file".to_string()); + } + if data[4] != VERSION { + return Err(format!("Unsupported SREF version: {}", data[4])); + } + + let salt = &data[5..5 + SALT_LEN]; + let nonce_bytes = &data[5 + SALT_LEN..HEADER_LEN]; + let ciphertext = &data[HEADER_LEN..]; + + let key = derive_key(password, salt)?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| format!("Cipher init error: {}", e))?; + let nonce = Nonce::from_slice(nonce_bytes); + + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| "Decryption failed — wrong password or corrupted file".to_string()) +} + +#[tauri::command] +pub async fn pick_save_file( + app: tauri::AppHandle, + default_name: String, + filters: Vec<(String, Vec)>, +) -> Result, String> { + let mut dialog = app.dialog().file().set_file_name(&default_name); + + for (name, extensions) in &filters { + let ext_refs: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter(name, &ext_refs); + } + + let path = dialog.blocking_save_file(); + Ok(path.map(|p| p.to_string())) +} + +#[tauri::command] +pub async fn pick_import_file( + app: tauri::AppHandle, + filters: Vec<(String, Vec)>, +) -> Result, String> { + let mut dialog = app.dialog().file(); + + for (name, extensions) in &filters { + let ext_refs: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter(name, &ext_refs); + } + + let path = dialog.blocking_pick_file(); + Ok(path.map(|p| p.to_string())) +} + +#[tauri::command] +pub fn write_export_file( + file_path: String, + content: String, + password: Option, +) -> Result<(), String> { + let bytes = match password { + Some(ref pw) if !pw.is_empty() => encrypt_data(content.as_bytes(), pw)?, + _ => content.into_bytes(), + }; + + fs::write(&file_path, bytes).map_err(|e| format!("Failed to write file: {}", e)) +} + +#[tauri::command] +pub fn read_import_file(file_path: String, password: Option) -> Result { + let bytes = fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?; + + let plaintext = if bytes.len() >= 4 && &bytes[0..4] == MAGIC { + let pw = password + .filter(|p| !p.is_empty()) + .ok_or_else(|| "This file is encrypted — a password is required".to_string())?; + decrypt_data(&bytes, &pw)? + } else { + bytes + }; + + String::from_utf8(plaintext).map_err(|e| format!("File content is not valid UTF-8: {}", e)) +} + +#[tauri::command] +pub fn is_file_encrypted(file_path: String) -> Result { + let bytes = fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?; + Ok(bytes.len() >= 4 && &bytes[0..4] == MAGIC) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7d7886c..d2fee51 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,5 @@ pub mod fs_commands; +pub mod export_import_commands; pub use fs_commands::*; +pub use export_import_commands::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 699ae8c..ee9b790 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -71,6 +71,11 @@ pub fn run() { commands::detect_encoding, commands::get_file_preview, commands::pick_folder, + commands::pick_save_file, + commands::pick_import_file, + commands::write_export_file, + commands::read_import_file, + commands::is_file_encrypted, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/settings/DataManagementCard.tsx b/src/components/settings/DataManagementCard.tsx new file mode 100644 index 0000000..b6fc721 --- /dev/null +++ b/src/components/settings/DataManagementCard.tsx @@ -0,0 +1,330 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Database, + Download, + Upload, + Lock, + CheckCircle, + AlertCircle, + Loader2, +} from "lucide-react"; +import { useDataExport } from "../../hooks/useDataExport"; +import { useDataImport } from "../../hooks/useDataImport"; +import type { ExportMode, ExportFormat } from "../../services/dataExportService"; +import ImportConfirmModal from "./ImportConfirmModal"; + +export default function DataManagementCard() { + const { t } = useTranslation(); + const exportHook = useDataExport(); + const importHook = useDataImport(); + + // Export form state + const [exportMode, setExportMode] = useState( + "transactions_with_categories" + ); + const [exportFormat, setExportFormat] = useState("json"); + const [encryptExport, setEncryptExport] = useState(false); + const [exportPassword, setExportPassword] = useState(""); + const [exportPasswordConfirm, setExportPasswordConfirm] = useState(""); + + // Import password state + const [importPassword, setImportPassword] = useState(""); + + // CSV is only valid for transaction modes + const csvDisabled = exportMode === "categories_only"; + if (csvDisabled && exportFormat === "csv") { + setExportFormat("json"); + } + + const passwordsMatch = exportPassword === exportPasswordConfirm; + const passwordValid = !encryptExport || (exportPassword.length >= 8 && passwordsMatch); + + const handleExport = () => { + exportHook.performExport( + exportMode, + exportFormat, + encryptExport ? exportPassword : undefined + ); + }; + + const handleImportPasswordSubmit = () => { + importHook.readWithPassword(importPassword); + setImportPassword(""); + }; + + return ( + <> +
+

+ + {t("settings.dataManagement.title")} +

+ + {/* === EXPORT SECTION === */} +
+

+ {t("settings.dataManagement.export.title")} +

+ + {/* Export mode */} +
+ + +
+ + {/* Export format */} +
+ +
+ + +
+
+ + {/* Encryption */} +
+ + + {encryptExport && ( +
+ setExportPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm" + /> + setExportPasswordConfirm(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm" + /> + {exportPassword.length > 0 && exportPassword.length < 8 && ( +

+ {t("settings.dataManagement.export.passwordTooShort")} +

+ )} + {exportPassword.length >= 8 && + exportPasswordConfirm.length > 0 && + !passwordsMatch && ( +

+ {t("settings.dataManagement.export.passwordMismatch")} +

+ )} +
+ )} +
+ + {/* Export button */} + + + {/* Export feedback */} + {exportHook.state.status === "success" && ( +
+ + {t("settings.dataManagement.export.success")} +
+ )} + {exportHook.state.status === "error" && ( +
+ + {exportHook.state.error} +
+ )} +
+ + {/* Divider */} +
+ + {/* === IMPORT SECTION === */} +
+

+ {t("settings.dataManagement.import.title")} +

+ +

+ {t("settings.dataManagement.import.description")} +

+ + {/* Import button */} + + + {/* Password prompt for encrypted files */} + {importHook.state.status === "needsPassword" && ( +
+

+ + {t("settings.dataManagement.import.passwordRequired")} +

+
+ setImportPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && importPassword) handleImportPasswordSubmit(); + }} + className="flex-1 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm" + autoFocus + /> + + +
+
+ )} + + {/* Import feedback */} + {importHook.state.status === "success" && ( +
+ + {t("settings.dataManagement.import.success")} +
+ )} + {importHook.state.status === "error" && ( +
+
+ + {importHook.state.error} +
+ +
+ )} +
+
+ + {/* Import confirmation modal */} + {importHook.state.status === "confirming" && + importHook.state.summary && + importHook.state.importType && ( + + )} + {importHook.state.status === "importing" && importHook.state.summary && importHook.state.importType && ( + {}} + onCancel={() => {}} + /> + )} + + ); +} diff --git a/src/components/settings/ImportConfirmModal.tsx b/src/components/settings/ImportConfirmModal.tsx new file mode 100644 index 0000000..2e4391e --- /dev/null +++ b/src/components/settings/ImportConfirmModal.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { AlertTriangle, X, Loader2 } from "lucide-react"; +import type { ImportSummary, ExportMode } from "../../services/dataExportService"; + +interface ImportConfirmModalProps { + summary: ImportSummary; + importType: ExportMode; + isImporting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export default function ImportConfirmModal({ + summary, + importType, + isImporting, + onConfirm, + onCancel, +}: ImportConfirmModalProps) { + const { t } = useTranslation(); + const [confirmText, setConfirmText] = useState(""); + + const willDelete: string[] = []; + const willImport: string[] = []; + + if (importType === "categories_only") { + willDelete.push(t("settings.dataManagement.import.willDeleteCategories")); + if (summary.categoriesCount > 0) + willImport.push( + t("settings.dataManagement.import.countCategories", { + count: summary.categoriesCount, + }) + ); + if (summary.suppliersCount > 0) + willImport.push( + t("settings.dataManagement.import.countSuppliers", { + count: summary.suppliersCount, + }) + ); + if (summary.keywordsCount > 0) + willImport.push( + t("settings.dataManagement.import.countKeywords", { + count: summary.keywordsCount, + }) + ); + } else if (importType === "transactions_with_categories") { + willDelete.push(t("settings.dataManagement.import.willDeleteAll")); + if (summary.categoriesCount > 0) + willImport.push( + t("settings.dataManagement.import.countCategories", { + count: summary.categoriesCount, + }) + ); + if (summary.transactionsCount > 0) + willImport.push( + t("settings.dataManagement.import.countTransactions", { + count: summary.transactionsCount, + }) + ); + } else { + willDelete.push(t("settings.dataManagement.import.willDeleteTransactions")); + if (summary.transactionsCount > 0) + willImport.push( + t("settings.dataManagement.import.countTransactions", { + count: summary.transactionsCount, + }) + ); + } + + const confirmWord = t("settings.dataManagement.import.confirmWord"); + const canConfirm = confirmText === confirmWord && !isImporting; + + return createPortal( +
+
+ {/* Header */} +
+
+ +

+ {t("settings.dataManagement.import.confirmTitle")} +

+
+ +
+ + {/* Body */} +
+ {/* What will be deleted */} +
+

+ {t("settings.dataManagement.import.willDeleteLabel")} +

+
    + {willDelete.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ + {/* What will be imported */} +
+

+ {t("settings.dataManagement.import.willImportLabel")} +

+
    + {willImport.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ + {/* Warning */} +
+

+ {t("settings.dataManagement.import.irreversibleWarning")} +

+
+ + {/* Confirmation input */} +
+ + setConfirmText(e.target.value)} + disabled={isImporting} + className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm" + autoFocus + /> +
+
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ); +} diff --git a/src/hooks/useDataExport.ts b/src/hooks/useDataExport.ts new file mode 100644 index 0000000..3a4b3b9 --- /dev/null +++ b/src/hooks/useDataExport.ts @@ -0,0 +1,122 @@ +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 = {}; + 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("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 }; +} diff --git a/src/hooks/useDataImport.ts b/src/hooks/useDataImport.ts new file mode 100644 index 0000000..d7db07c --- /dev/null +++ b/src/hooks/useDataImport.ts @@ -0,0 +1,193 @@ +import { useReducer, useCallback } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { + parseImportedJson, + parseImportedCsv, + importCategoriesOnly, + importTransactionsWithCategories, + importTransactionsOnly, + type ExportEnvelope, + type ImportSummary, +} from "../services/dataExportService"; + +type ImportStatus = + | "idle" + | "reading" + | "needsPassword" + | "confirming" + | "importing" + | "success" + | "error"; + +interface ImportState { + status: ImportStatus; + filePath: string | null; + summary: ImportSummary | null; + parsedData: ExportEnvelope["data"] | null; + importType: ExportEnvelope["export_type"] | null; + error: string | null; +} + +type ImportAction = + | { type: "READ_START" } + | { type: "NEEDS_PASSWORD"; filePath: string } + | { + type: "CONFIRMING"; + summary: ImportSummary; + data: ExportEnvelope["data"]; + importType: ExportEnvelope["export_type"]; + } + | { type: "IMPORT_START" } + | { type: "IMPORT_SUCCESS" } + | { type: "IMPORT_ERROR"; error: string } + | { type: "RESET" }; + +const initialState: ImportState = { + status: "idle", + filePath: null, + summary: null, + parsedData: null, + importType: null, + error: null, +}; + +function reducer(state: ImportState, action: ImportAction): ImportState { + switch (action.type) { + case "READ_START": + return { ...initialState, status: "reading" }; + case "NEEDS_PASSWORD": + return { ...initialState, status: "needsPassword", filePath: action.filePath }; + case "CONFIRMING": + return { + ...state, + status: "confirming", + summary: action.summary, + parsedData: action.data, + importType: action.importType, + error: null, + }; + case "IMPORT_START": + return { ...state, status: "importing", error: null }; + case "IMPORT_SUCCESS": + return { ...state, status: "success", error: null }; + case "IMPORT_ERROR": + return { ...state, status: "error", error: action.error }; + case "RESET": + return initialState; + } +} + +function parseContent( + content: string, + filePath: string +): { summary: ImportSummary; data: ExportEnvelope["data"]; importType: ExportEnvelope["export_type"] } { + const isCsv = + filePath.toLowerCase().endsWith(".csv") || + (!filePath.toLowerCase().endsWith(".json") && + !filePath.toLowerCase().endsWith(".sref") && + content.trimStart().charAt(0) !== "{"); + + if (isCsv) { + const { transactions, summary } = parseImportedCsv(content); + return { + summary, + data: { transactions }, + importType: "transactions_only", + }; + } + + const { envelope, summary } = parseImportedJson(content); + return { + summary, + data: envelope.data, + importType: envelope.export_type, + }; +} + +export function useDataImport() { + const [state, dispatch] = useReducer(reducer, initialState); + + const pickAndRead = useCallback(async () => { + dispatch({ type: "READ_START" }); + try { + const filePath = await invoke("pick_import_file", { + filters: [["Simpl'Result Files", ["json", "csv", "sref"]]], + }); + + if (!filePath) { + dispatch({ type: "RESET" }); + return; + } + + const encrypted = await invoke("is_file_encrypted", { filePath }); + + if (encrypted) { + dispatch({ type: "NEEDS_PASSWORD", filePath }); + return; + } + + const content = await invoke("read_import_file", { + filePath, + password: null, + }); + + const { summary, data, importType } = parseContent(content, filePath); + dispatch({ type: "CONFIRMING", summary, data, importType }); + } catch (e) { + dispatch({ + type: "IMPORT_ERROR", + error: e instanceof Error ? e.message : String(e), + }); + } + }, []); + + const readWithPassword = useCallback( + async (password: string) => { + if (!state.filePath) return; + dispatch({ type: "READ_START" }); + try { + const content = await invoke("read_import_file", { + filePath: state.filePath, + password, + }); + + const { summary, data, importType } = parseContent(content, state.filePath); + dispatch({ type: "CONFIRMING", summary, data, importType }); + } catch (e) { + dispatch({ + type: "IMPORT_ERROR", + error: e instanceof Error ? e.message : String(e), + }); + } + }, + [state.filePath] + ); + + const executeImport = useCallback(async () => { + if (!state.parsedData || !state.importType) return; + dispatch({ type: "IMPORT_START" }); + try { + switch (state.importType) { + case "categories_only": + await importCategoriesOnly(state.parsedData); + break; + case "transactions_with_categories": + await importTransactionsWithCategories(state.parsedData); + break; + case "transactions_only": + await importTransactionsOnly(state.parsedData); + break; + } + dispatch({ type: "IMPORT_SUCCESS" }); + } catch (e) { + dispatch({ + type: "IMPORT_ERROR", + error: e instanceof Error ? e.message : String(e), + }); + } + }, [state.parsedData, state.importType]); + + const reset = useCallback(() => dispatch({ type: "RESET" }), []); + + return { state, pickAndRead, readWithPassword, executeImport, reset }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f19f51c..a38044a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -356,6 +356,49 @@ "error": "Update failed", "retryButton": "Retry" }, + "dataManagement": { + "title": "Data Management", + "export": { + "title": "Export", + "modeLabel": "What to export", + "modeTransactionsWithCategories": "Transactions with categories", + "modeTransactionsOnly": "Transactions only", + "modeCategoriesOnly": "Categories only", + "formatLabel": "Format", + "csvDisabledNote": "transactions only", + "encryptLabel": "Encrypt with password", + "passwordPlaceholder": "Password (min 8 characters)", + "passwordConfirmPlaceholder": "Confirm password", + "passwordTooShort": "Password must be at least 8 characters", + "passwordMismatch": "Passwords do not match", + "button": "Export", + "success": "Export completed successfully" + }, + "import": { + "title": "Import", + "description": "Import data from a previously exported file. This will replace existing data.", + "button": "Import from file", + "passwordRequired": "This file is encrypted. Enter the password to decrypt it.", + "passwordPlaceholder": "Password", + "decrypt": "Decrypt", + "confirmTitle": "Replace Data", + "willDeleteLabel": "The following data will be deleted:", + "willDeleteCategories": "All categories, suppliers, and keywords", + "willDeleteTransactions": "All transactions and import history", + "willDeleteAll": "All transactions, categories, suppliers, keywords, and import history", + "willImportLabel": "The following data will be imported:", + "countCategories": "{{count}} category(ies)", + "countSuppliers": "{{count}} supplier(s)", + "countKeywords": "{{count}} keyword(s)", + "countTransactions": "{{count}} transaction(s)", + "irreversibleWarning": "This action is irreversible. All existing data of the selected type will be permanently deleted and replaced.", + "typeToConfirm": "Type \"{{word}}\" to confirm:", + "confirmWord": "REPLACE", + "replaceButton": "Replace Data", + "success": "Import completed successfully", + "tryAgain": "Try again" + } + }, "dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified.", "help": { "title": "About Settings", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index d5d74f7..28e58bf 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -356,6 +356,49 @@ "error": "Erreur lors de la mise à jour", "retryButton": "Réessayer" }, + "dataManagement": { + "title": "Gestion des données", + "export": { + "title": "Exporter", + "modeLabel": "Que exporter", + "modeTransactionsWithCategories": "Transactions avec catégories", + "modeTransactionsOnly": "Transactions uniquement", + "modeCategoriesOnly": "Catégories uniquement", + "formatLabel": "Format", + "csvDisabledNote": "transactions uniquement", + "encryptLabel": "Chiffrer avec un mot de passe", + "passwordPlaceholder": "Mot de passe (min 8 caractères)", + "passwordConfirmPlaceholder": "Confirmer le mot de passe", + "passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères", + "passwordMismatch": "Les mots de passe ne correspondent pas", + "button": "Exporter", + "success": "Export terminé avec succès" + }, + "import": { + "title": "Importer", + "description": "Importer des données depuis un fichier exporté précédemment. Les données existantes seront remplacées.", + "button": "Importer depuis un fichier", + "passwordRequired": "Ce fichier est chiffré. Entrez le mot de passe pour le déchiffrer.", + "passwordPlaceholder": "Mot de passe", + "decrypt": "Déchiffrer", + "confirmTitle": "Remplacer les données", + "willDeleteLabel": "Les données suivantes seront supprimées :", + "willDeleteCategories": "Toutes les catégories, fournisseurs et mots-clés", + "willDeleteTransactions": "Toutes les transactions et l'historique d'import", + "willDeleteAll": "Toutes les transactions, catégories, fournisseurs, mots-clés et l'historique d'import", + "willImportLabel": "Les données suivantes seront importées :", + "countCategories": "{{count}} catégorie(s)", + "countSuppliers": "{{count}} fournisseur(s)", + "countKeywords": "{{count}} mot(s)-clé(s)", + "countTransactions": "{{count}} transaction(s)", + "irreversibleWarning": "Cette action est irréversible. Toutes les données existantes du type sélectionné seront définitivement supprimées et remplacées.", + "typeToConfirm": "Tapez « {{word}} » pour confirmer :", + "confirmWord": "REMPLACER", + "replaceButton": "Remplacer les données", + "success": "Import terminé avec succès", + "tryAgain": "Réessayer" + } + }, "dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée.", "help": { "title": "À propos des Paramètres", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6a89641..e16457c 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -14,6 +14,7 @@ import { getVersion } from "@tauri-apps/api/app"; import { useUpdater } from "../hooks/useUpdater"; import { APP_NAME } from "../shared/constants"; import { PageHelp } from "../components/shared/PageHelp"; +import DataManagementCard from "../components/settings/DataManagementCard"; export default function SettingsPage() { const { t } = useTranslation(); @@ -170,6 +171,9 @@ export default function SettingsPage() { )} + {/* Data management */} + + {/* Data safety notice */}
diff --git a/src/services/dataExportService.ts b/src/services/dataExportService.ts new file mode 100644 index 0000000..70b9c00 --- /dev/null +++ b/src/services/dataExportService.ts @@ -0,0 +1,362 @@ +import { getDb } from "./db"; +import Papa from "papaparse"; +import type { Category, Supplier, Keyword } from "../shared/types"; + +// --- Export types --- + +export type ExportMode = + | "transactions_with_categories" + | "transactions_only" + | "categories_only"; + +export type ExportFormat = "json" | "csv"; + +export interface ExportEnvelope { + export_type: ExportMode; + app_version: string; + exported_at: string; + data: { + categories?: Category[]; + suppliers?: Supplier[]; + keywords?: Keyword[]; + transactions?: ExportTransaction[]; + }; +} + +export interface ExportTransaction { + id: number; + date: string; + description: string; + amount: number; + category_id: number | null; + category_name: string | null; + original_description: string | null; + notes: string | null; + is_manually_categorized: number; + is_split: number; + parent_transaction_id: number | null; +} + +// --- Import types --- + +export interface ImportSummary { + type: ExportMode; + categoriesCount: number; + suppliersCount: number; + keywordsCount: number; + transactionsCount: number; +} + +// --- Data gathering --- + +export async function getExportCategories(): Promise { + const db = await getDb(); + return db.select("SELECT * FROM categories ORDER BY id"); +} + +export async function getExportSuppliers(): Promise { + const db = await getDb(); + return db.select("SELECT * FROM suppliers ORDER BY id"); +} + +export async function getExportKeywords(): Promise { + const db = await getDb(); + return db.select("SELECT * FROM keywords ORDER BY id"); +} + +export async function getExportTransactions(): Promise { + const db = await getDb(); + return db.select( + `SELECT t.id, t.date, t.description, t.amount, t.category_id, + c.name AS category_name, t.original_description, t.notes, + t.is_manually_categorized, t.is_split, t.parent_transaction_id + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + ORDER BY t.date, t.id` + ); +} + +// --- Serialization --- + +export function serializeToJson( + exportType: ExportMode, + data: ExportEnvelope["data"], + appVersion: string +): string { + const envelope: ExportEnvelope = { + export_type: exportType, + app_version: appVersion, + exported_at: new Date().toISOString(), + data, + }; + return JSON.stringify(envelope, null, 2); +} + +export function serializeTransactionsToCsv( + transactions: ExportTransaction[] +): string { + return Papa.unparse( + transactions.map((t) => ({ + date: t.date, + description: t.description, + amount: t.amount, + category_name: t.category_name ?? "", + category_id: t.category_id ?? "", + original_description: t.original_description ?? "", + notes: t.notes ?? "", + is_manually_categorized: t.is_manually_categorized, + is_split: t.is_split, + parent_transaction_id: t.parent_transaction_id ?? "", + })) + ); +} + +// --- Import parsing --- + +export function parseImportedJson(content: string): { + envelope: ExportEnvelope; + summary: ImportSummary; +} { + let envelope: ExportEnvelope; + try { + envelope = JSON.parse(content); + } catch { + throw new Error("Invalid JSON file"); + } + + if ( + !envelope.export_type || + !envelope.data || + typeof envelope.data !== "object" + ) { + throw new Error("Invalid export file format — missing required fields"); + } + + const validTypes: ExportMode[] = [ + "transactions_with_categories", + "transactions_only", + "categories_only", + ]; + if (!validTypes.includes(envelope.export_type)) { + throw new Error(`Unknown export type: ${envelope.export_type}`); + } + + return { + envelope, + summary: { + type: envelope.export_type, + categoriesCount: envelope.data.categories?.length ?? 0, + suppliersCount: envelope.data.suppliers?.length ?? 0, + keywordsCount: envelope.data.keywords?.length ?? 0, + transactionsCount: envelope.data.transactions?.length ?? 0, + }, + }; +} + +export function parseImportedCsv(content: string): { + transactions: ExportTransaction[]; + summary: ImportSummary; +} { + const result = Papa.parse>(content, { + header: true, + skipEmptyLines: true, + }); + + if (result.errors.length > 0 && result.data.length === 0) { + throw new Error(`CSV parse error: ${result.errors[0].message}`); + } + + const transactions: ExportTransaction[] = result.data.map((row, i) => ({ + id: i, + date: row.date ?? "", + description: row.description ?? "", + amount: parseFloat(row.amount) || 0, + category_id: row.category_id ? parseInt(row.category_id) : null, + category_name: row.category_name || null, + original_description: row.original_description || null, + notes: row.notes || null, + is_manually_categorized: parseInt(row.is_manually_categorized) || 0, + is_split: parseInt(row.is_split) || 0, + parent_transaction_id: row.parent_transaction_id + ? parseInt(row.parent_transaction_id) + : null, + })); + + return { + transactions, + summary: { + type: "transactions_only", + categoriesCount: 0, + suppliersCount: 0, + keywordsCount: 0, + transactionsCount: transactions.length, + }, + }; +} + +// --- Import execution --- + +export async function importCategoriesOnly(data: ExportEnvelope["data"]): Promise { + const db = await getDb(); + + // Wipe keywords, suppliers, categories + await db.execute("DELETE FROM keywords"); + await db.execute("DELETE FROM suppliers"); + await db.execute("DELETE FROM categories"); + + // Nullify category/supplier references on transactions + await db.execute( + "UPDATE transactions SET category_id = NULL, supplier_id = NULL, is_manually_categorized = 0" + ); + + // Re-insert categories + if (data.categories) { + for (const cat of data.categories) { + await db.execute( + `INSERT INTO categories (id, name, parent_id, color, icon, type, is_active, is_inputable, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + cat.id, + cat.name, + cat.parent_id ?? null, + cat.color ?? null, + cat.icon ?? null, + cat.type, + cat.is_active ? 1 : 0, + cat.is_inputable ? 1 : 0, + cat.sort_order, + ] + ); + } + } + + // Re-insert suppliers + if (data.suppliers) { + for (const sup of data.suppliers) { + await db.execute( + `INSERT INTO suppliers (id, name, normalized_name, category_id, is_active) + VALUES ($1, $2, $3, $4, $5)`, + [sup.id, sup.name, sup.normalized_name, sup.category_id ?? null, sup.is_active ? 1 : 0] + ); + } + } + + // Re-insert keywords + if (data.keywords) { + for (const kw of data.keywords) { + await db.execute( + `INSERT INTO keywords (id, keyword, category_id, supplier_id, priority, is_active) + VALUES ($1, $2, $3, $4, $5, $6)`, + [kw.id, kw.keyword, kw.category_id, kw.supplier_id ?? null, kw.priority, kw.is_active ? 1 : 0] + ); + } + } +} + +export async function importTransactionsWithCategories( + data: ExportEnvelope["data"] +): Promise { + const db = await getDb(); + + // Wipe everything + await db.execute("DELETE FROM transactions"); + await db.execute("DELETE FROM imported_files"); + await db.execute("DELETE FROM keywords"); + await db.execute("DELETE FROM suppliers"); + await db.execute("DELETE FROM categories"); + + // Re-insert categories + if (data.categories) { + for (const cat of data.categories) { + await db.execute( + `INSERT INTO categories (id, name, parent_id, color, icon, type, is_active, is_inputable, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + cat.id, + cat.name, + cat.parent_id ?? null, + cat.color ?? null, + cat.icon ?? null, + cat.type, + cat.is_active ? 1 : 0, + cat.is_inputable ? 1 : 0, + cat.sort_order, + ] + ); + } + } + + // Re-insert suppliers + if (data.suppliers) { + for (const sup of data.suppliers) { + await db.execute( + `INSERT INTO suppliers (id, name, normalized_name, category_id, is_active) + VALUES ($1, $2, $3, $4, $5)`, + [sup.id, sup.name, sup.normalized_name, sup.category_id ?? null, sup.is_active ? 1 : 0] + ); + } + } + + // Re-insert keywords + if (data.keywords) { + for (const kw of data.keywords) { + await db.execute( + `INSERT INTO keywords (id, keyword, category_id, supplier_id, priority, is_active) + VALUES ($1, $2, $3, $4, $5, $6)`, + [kw.id, kw.keyword, kw.category_id, kw.supplier_id ?? null, kw.priority, kw.is_active ? 1 : 0] + ); + } + } + + // Re-insert transactions + if (data.transactions) { + for (const tx of data.transactions) { + await db.execute( + `INSERT INTO transactions (date, description, amount, category_id, original_description, notes, is_manually_categorized, is_split, parent_transaction_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + tx.date, + tx.description, + tx.amount, + tx.category_id, + tx.original_description, + tx.notes, + tx.is_manually_categorized, + tx.is_split, + tx.parent_transaction_id, + ] + ); + } + } +} + +export async function importTransactionsOnly( + data: ExportEnvelope["data"] +): Promise { + const db = await getDb(); + + // Wipe transactions and import history + await db.execute("DELETE FROM transactions"); + await db.execute("DELETE FROM imported_files"); + + // Re-insert transactions + if (data.transactions) { + for (const tx of data.transactions) { + await db.execute( + `INSERT INTO transactions (date, description, amount, category_id, original_description, notes, is_manually_categorized, is_split, parent_transaction_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + tx.date, + tx.description, + tx.amount, + tx.category_id, + tx.original_description, + tx.notes, + tx.is_manually_categorized, + tx.is_split, + tx.parent_transaction_id, + ] + ); + } + } +}