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>
This commit is contained in:
parent
d6e6ce1136
commit
87e8f26754
13 changed files with 1555 additions and 0 deletions
137
src-tauri/Cargo.lock
generated
137
src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
143
src-tauri/src/commands/export_import_commands.rs
Normal file
143
src-tauri/src/commands/export_import_commands.rs
Normal file
|
|
@ -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<Vec<u8>, 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<Vec<u8>, 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<String>)>,
|
||||
) -> Result<Option<String>, 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<String>)>,
|
||||
) -> Result<Option<String>, 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<String>,
|
||||
) -> 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<String>) -> Result<String, String> {
|
||||
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<bool, String> {
|
||||
let bytes = fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
Ok(bytes.len() >= 4 && &bytes[0..4] == MAGIC)
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
pub mod fs_commands;
|
||||
pub mod export_import_commands;
|
||||
|
||||
pub use fs_commands::*;
|
||||
pub use export_import_commands::*;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
330
src/components/settings/DataManagementCard.tsx
Normal file
330
src/components/settings/DataManagementCard.tsx
Normal file
|
|
@ -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<ExportMode>(
|
||||
"transactions_with_categories"
|
||||
);
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>("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 (
|
||||
<>
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Database size={18} />
|
||||
{t("settings.dataManagement.title")}
|
||||
</h2>
|
||||
|
||||
{/* === EXPORT SECTION === */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{t("settings.dataManagement.export.title")}
|
||||
</h3>
|
||||
|
||||
{/* Export mode */}
|
||||
<div>
|
||||
<label className="text-sm block mb-1">
|
||||
{t("settings.dataManagement.export.modeLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={exportMode}
|
||||
onChange={(e) => setExportMode(e.target.value as ExportMode)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
|
||||
>
|
||||
<option value="transactions_with_categories">
|
||||
{t("settings.dataManagement.export.modeTransactionsWithCategories")}
|
||||
</option>
|
||||
<option value="transactions_only">
|
||||
{t("settings.dataManagement.export.modeTransactionsOnly")}
|
||||
</option>
|
||||
<option value="categories_only">
|
||||
{t("settings.dataManagement.export.modeCategoriesOnly")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Export format */}
|
||||
<div>
|
||||
<label className="text-sm block mb-1">
|
||||
{t("settings.dataManagement.export.formatLabel")}
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="exportFormat"
|
||||
value="json"
|
||||
checked={exportFormat === "json"}
|
||||
onChange={() => setExportFormat("json")}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
JSON
|
||||
</label>
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${
|
||||
csvDisabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="exportFormat"
|
||||
value="csv"
|
||||
checked={exportFormat === "csv"}
|
||||
onChange={() => setExportFormat("csv")}
|
||||
disabled={csvDisabled}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
CSV
|
||||
{csvDisabled && (
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
({t("settings.dataManagement.export.csvDisabledNote")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Encryption */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={encryptExport}
|
||||
onChange={(e) => {
|
||||
setEncryptExport(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setExportPassword("");
|
||||
setExportPasswordConfirm("");
|
||||
}
|
||||
}}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
<Lock size={14} />
|
||||
{t("settings.dataManagement.export.encryptLabel")}
|
||||
</label>
|
||||
|
||||
{encryptExport && (
|
||||
<div className="space-y-2 ml-6">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("settings.dataManagement.export.passwordPlaceholder")}
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("settings.dataManagement.export.passwordConfirmPlaceholder")}
|
||||
value={exportPasswordConfirm}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="text-xs text-[var(--negative)]">
|
||||
{t("settings.dataManagement.export.passwordTooShort")}
|
||||
</p>
|
||||
)}
|
||||
{exportPassword.length >= 8 &&
|
||||
exportPasswordConfirm.length > 0 &&
|
||||
!passwordsMatch && (
|
||||
<p className="text-xs text-[var(--negative)]">
|
||||
{t("settings.dataManagement.export.passwordMismatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={
|
||||
exportHook.state.status === "exporting" || !passwordValid
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{exportHook.state.status === "exporting" ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={16} />
|
||||
)}
|
||||
{t("settings.dataManagement.export.button")}
|
||||
</button>
|
||||
|
||||
{/* Export feedback */}
|
||||
{exportHook.state.status === "success" && (
|
||||
<div className="flex items-center gap-2 text-[var(--positive)] text-sm">
|
||||
<CheckCircle size={14} />
|
||||
{t("settings.dataManagement.export.success")}
|
||||
</div>
|
||||
)}
|
||||
{exportHook.state.status === "error" && (
|
||||
<div className="flex items-center gap-2 text-[var(--negative)] text-sm">
|
||||
<AlertCircle size={14} />
|
||||
{exportHook.state.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-[var(--border)]" />
|
||||
|
||||
{/* === IMPORT SECTION === */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
{t("settings.dataManagement.import.title")}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("settings.dataManagement.import.description")}
|
||||
</p>
|
||||
|
||||
{/* Import button */}
|
||||
<button
|
||||
onClick={importHook.pickAndRead}
|
||||
disabled={
|
||||
importHook.state.status === "reading" ||
|
||||
importHook.state.status === "importing"
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{importHook.state.status === "reading" ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Upload size={16} />
|
||||
)}
|
||||
{t("settings.dataManagement.import.button")}
|
||||
</button>
|
||||
|
||||
{/* Password prompt for encrypted files */}
|
||||
{importHook.state.status === "needsPassword" && (
|
||||
<div className="space-y-2 p-3 border border-[var(--border)] rounded-lg">
|
||||
<p className="text-sm">
|
||||
<Lock size={14} className="inline mr-1" />
|
||||
{t("settings.dataManagement.import.passwordRequired")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("settings.dataManagement.import.passwordPlaceholder")}
|
||||
value={importPassword}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
onClick={handleImportPasswordSubmit}
|
||||
disabled={!importPassword}
|
||||
className="px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 text-sm"
|
||||
>
|
||||
{t("settings.dataManagement.import.decrypt")}
|
||||
</button>
|
||||
<button
|
||||
onClick={importHook.reset}
|
||||
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import feedback */}
|
||||
{importHook.state.status === "success" && (
|
||||
<div className="flex items-center gap-2 text-[var(--positive)] text-sm">
|
||||
<CheckCircle size={14} />
|
||||
{t("settings.dataManagement.import.success")}
|
||||
</div>
|
||||
)}
|
||||
{importHook.state.status === "error" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-[var(--negative)] text-sm">
|
||||
<AlertCircle size={14} />
|
||||
{importHook.state.error}
|
||||
</div>
|
||||
<button
|
||||
onClick={importHook.reset}
|
||||
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
{t("settings.dataManagement.import.tryAgain")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import confirmation modal */}
|
||||
{importHook.state.status === "confirming" &&
|
||||
importHook.state.summary &&
|
||||
importHook.state.importType && (
|
||||
<ImportConfirmModal
|
||||
summary={importHook.state.summary}
|
||||
importType={importHook.state.importType}
|
||||
isImporting={false}
|
||||
onConfirm={importHook.executeImport}
|
||||
onCancel={importHook.reset}
|
||||
/>
|
||||
)}
|
||||
{importHook.state.status === "importing" && importHook.state.summary && importHook.state.importType && (
|
||||
<ImportConfirmModal
|
||||
summary={importHook.state.summary}
|
||||
importType={importHook.state.importType}
|
||||
isImporting={true}
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
src/components/settings/ImportConfirmModal.tsx
Normal file
168
src/components/settings/ImportConfirmModal.tsx
Normal file
|
|
@ -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(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-md mx-4 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 text-[var(--negative)]">
|
||||
<AlertTriangle size={20} />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.dataManagement.import.confirmTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isImporting}
|
||||
className="p-1 rounded hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* What will be deleted */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--negative)] mb-1">
|
||||
{t("settings.dataManagement.import.willDeleteLabel")}
|
||||
</p>
|
||||
<ul className="text-sm text-[var(--muted-foreground)] list-disc list-inside">
|
||||
{willDelete.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* What will be imported */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">
|
||||
{t("settings.dataManagement.import.willImportLabel")}
|
||||
</p>
|
||||
<ul className="text-sm text-[var(--muted-foreground)] list-disc list-inside">
|
||||
{willImport.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-[var(--negative)]/10 border border-[var(--negative)]/30 rounded-lg p-3">
|
||||
<p className="text-sm text-[var(--negative)]">
|
||||
{t("settings.dataManagement.import.irreversibleWarning")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirmation input */}
|
||||
<div>
|
||||
<label className="text-sm text-[var(--muted-foreground)] block mb-1">
|
||||
{t("settings.dataManagement.import.typeToConfirm", {
|
||||
word: confirmWord,
|
||||
})}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={!canConfirm}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isImporting && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("settings.dataManagement.import.replaceButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
122
src/hooks/useDataExport.ts
Normal file
122
src/hooks/useDataExport.ts
Normal file
|
|
@ -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<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 };
|
||||
}
|
||||
193
src/hooks/useDataImport.ts
Normal file
193
src/hooks/useDataImport.ts
Normal file
|
|
@ -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<string | null>("pick_import_file", {
|
||||
filters: [["Simpl'Result Files", ["json", "csv", "sref"]]],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
dispatch({ type: "RESET" });
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = await invoke<boolean>("is_file_encrypted", { filePath });
|
||||
|
||||
if (encrypted) {
|
||||
dispatch({ type: "NEEDS_PASSWORD", filePath });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await invoke<string>("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<string>("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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Data management */}
|
||||
<DataManagementCard />
|
||||
|
||||
{/* Data safety notice */}
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
||||
|
|
|
|||
362
src/services/dataExportService.ts
Normal file
362
src/services/dataExportService.ts
Normal file
|
|
@ -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<Category[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Category[]>("SELECT * FROM categories ORDER BY id");
|
||||
}
|
||||
|
||||
export async function getExportSuppliers(): Promise<Supplier[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Supplier[]>("SELECT * FROM suppliers ORDER BY id");
|
||||
}
|
||||
|
||||
export async function getExportKeywords(): Promise<Keyword[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Keyword[]>("SELECT * FROM keywords ORDER BY id");
|
||||
}
|
||||
|
||||
export async function getExportTransactions(): Promise<ExportTransaction[]> {
|
||||
const db = await getDb();
|
||||
return db.select<ExportTransaction[]>(
|
||||
`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<Record<string, string>>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue