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:
Le-King-Fu 2026-02-15 11:40:28 +00:00
parent d6e6ce1136
commit 87e8f26754
13 changed files with 1555 additions and 0 deletions

137
src-tauri/Cargo.lock generated
View file

@ -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"

View file

@ -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"

View 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)
}

View file

@ -1,3 +1,5 @@
pub mod fs_commands;
pub mod export_import_commands;
pub use fs_commands::*;
pub use export_import_commands::*;

View file

@ -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");

View 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={() => {}}
/>
)}
</>
);
}

View 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
View 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
View 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 };
}

View file

@ -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",

View file

@ -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",

View file

@ -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" />

View 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,
]
);
}
}
}