Create import_sources + imported_files tracking records when importing transactions from Settings > Data Management, so imports appear in the Import History panel and can be deleted like CSV imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
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"],
|
|
filename: string
|
|
): 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 import_sources");
|
|
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]
|
|
);
|
|
}
|
|
}
|
|
|
|
// Create tracking records for import history
|
|
const sourceResult = await db.execute(
|
|
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
["Data Import", "Imported from settings", "%Y-%m-%d", ",", "utf-8", "{}", 0]
|
|
);
|
|
const sourceId = sourceResult.lastInsertId;
|
|
|
|
const txCount = data.transactions?.length ?? 0;
|
|
const fileResult = await db.execute(
|
|
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[sourceId, filename, `data-import-${Date.now()}`, txCount, "completed"]
|
|
);
|
|
const fileId = fileResult.lastInsertId;
|
|
|
|
// Re-insert transactions linked to the import
|
|
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, source_id, file_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
[
|
|
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,
|
|
sourceId,
|
|
fileId,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function importTransactionsOnly(
|
|
data: ExportEnvelope["data"],
|
|
filename: string
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
|
|
// Wipe transactions and import history
|
|
await db.execute("DELETE FROM transactions");
|
|
await db.execute("DELETE FROM imported_files");
|
|
await db.execute("DELETE FROM import_sources");
|
|
|
|
// Create tracking records for import history
|
|
const sourceResult = await db.execute(
|
|
`INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
["Data Import", "Imported from settings", "%Y-%m-%d", ",", "utf-8", "{}", 0]
|
|
);
|
|
const sourceId = sourceResult.lastInsertId;
|
|
|
|
const txCount = data.transactions?.length ?? 0;
|
|
const fileResult = await db.execute(
|
|
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[sourceId, filename, `data-import-${Date.now()}`, txCount, "completed"]
|
|
);
|
|
const fileId = fileResult.lastInsertId;
|
|
|
|
// Re-insert transactions linked to the import
|
|
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, source_id, file_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
[
|
|
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,
|
|
sourceId,
|
|
fileId,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|