Add support for 2-stage import with web server

This commit is contained in:
2025-05-06 16:10:13 +02:00
parent 23b92aa90d
commit 08dba9c51f
7 changed files with 688 additions and 121 deletions

View File

@@ -2,7 +2,7 @@ import fs from "fs";
import { program } from "commander";
import { AddOptions, ImportOptions } from "@/types/cli";
import { loadConfig } from "./config";
import { submitTransactions } from "@/runner";
import { loadTransactions } from "@/runner";
import { serve } from "@/server";
import { SubmitOptions } from "@/backend";
@@ -23,7 +23,7 @@ function doSubmit<O extends ImportOptions | AddOptions>(parseOpts: (o: O) => Sub
const opts: SubmitOptions = options.dryRun ? { mode: 'dry-run' } : parseOpts(options);
await submitTransactions(fs.createReadStream(file), profile, server, config, opts);
await loadTransactions(fs.createReadStream(file), profile, server, config, opts);
}
}

View File

@@ -6,13 +6,17 @@ import { Config } from "@/types/config";
import { Readable } from "stream";
import { Transaction } from "@/types/transaction";
export type ImportResult = {
transactions: Transaction[];
result: ActualImportResult;
export type PrepareResult = {
transactions: Transaction[];
skipped: string[][];
};
export const submitTransactions = async (stream: Readable, profile: string, server: string, config: Config, opts: SubmitOptions): Promise<ImportResult> => new Promise((resolve, reject) => {
export type ImportResult = PrepareResult & {
result: ActualImportResult;
};
export const prepareTransactions = async (stream: Readable, profile: string, server: string, config: Config): Promise<PrepareResult> => new Promise((resolve, reject) => {
const profileConfig = config.profiles[profile];
if (!profileConfig) {
@@ -25,8 +29,7 @@ export const submitTransactions = async (stream: Readable, profile: string, serv
}
const parser = createParser(profileConfig, serverConfig);
const actualServer = new Actual(serverConfig);
const skipped: string[][] = [];
const handleRow = async (data: string[]) => {
@@ -40,18 +43,15 @@ export const submitTransactions = async (stream: Readable, profile: string, serv
const handleClose = async () => {
try {
const transactions = await parser.reconcile();
const result = await actualServer.load(transactions, opts);
resolve({
transactions,
result,
transactions,
skipped
});
} catch (e) {
});
} catch (e: unknown) {
console.error(e);
reject(e);
}
reject(e);
}
};
stream
@@ -59,4 +59,26 @@ export const submitTransactions = async (stream: Readable, profile: string, serv
.pipe(Papa.parse(Papa.NODE_STREAM_INPUT, profileConfig.csv ?? parser.csvConfig))
.on('data', handleRow)
.on('close', handleClose);
});
});
export const submitTransactions = async (transactions: Transaction[], server: string, config: Config, opts: SubmitOptions): Promise<ActualImportResult> => {
const serverConfig = config.servers[server];
if(!serverConfig) {
throw new Error(`Unknown server: ${server}`);
}
const actualServer = new Actual(serverConfig);
return await actualServer.load(transactions, opts);
};
export const loadTransactions = async (stream: Readable, profile: string, server: string, config: Config, opts: SubmitOptions): Promise<ImportResult> => {
const prepared = await prepareTransactions(stream, profile, server, config)
const result = await submitTransactions(prepared.transactions, server, config, opts);
return {
...prepared,
result
}
};

View File

@@ -1,130 +1,54 @@
import { SubmitOptions } from "@/backend";
import { ImportResult, submitTransactions } from "@/runner";
import { prepareTransactions, submitTransactions } from "@/runner";
import { Config } from "@/types/config";
import express from "express";
import multer from "multer";
import { Readable } from "stream";
import path from 'path';
export function serve(config: Config, port: number) {
const app = express();
app.set('view engine', 'pug');
app.set('views', path.join(__dirname, '../../views'));
app.use(express.static('public'));
const upload = multer({ storage: multer.memoryStorage() });
app.get("/", (req, res) => {
res.send(`
<html>
<body>
<h2>Import transactions</h2>
<form action="/import" method="POST" enctype="multipart/form-data">
<p>
<label for="file">CSV File</label>
<input id="file" type="file" name="file" accept=".csv" required />
</p>
<p>
<label for="profile">Profile</label>
<select id="profile" name="profile">
${Object.keys(config.profiles).map(p => `<option${p === config.defaultProfile ? " selected": ""}>${p}</option>`)}
</select>
</p>
<p>
<label for="server">Server</label>
<select id="server" name="server">
${Object.keys(config.servers).map(s => `<option${s === config.defaultServer ? " selected": ""}>${s}</option>`)}
</select>
</p>
<p>
<label for="mode">Import mode</label>
<select id="mode" name="mode">
<option value="add" selected>Add</option>
<option value="import">Import</option>
</select>
</p>
<p>
<label for="learn">Learn categories (only for <u>add</u> mode)</label>
<input type="checkbox" id="learn" name="learn" />
</p>
<p>
<label for="transfers">Run transfers (only for <u>add</u> mode)</label>
<input type="checkbox" id="transfers" name="transfers" checked />
</p>
<p>
<button type="submit">Import</button>
</p>
</form>
</body>
</html>
`);
res.render('index', {
profiles: Object.keys(config.profiles),
servers: Object.keys(config.servers),
defaultProfile: config.defaultProfile,
defaultServer: config.defaultServer
});
});
app.post("/import", upload.single('file'), async (req, res) => {
app.post("/prepare", upload.single("file"), async (req, res) => {
if (!req.file) {
throw new Error("No file to upload");
}
const { profile, server, learn, transfers, mode } = req.body;
const { profile, server } = req.body;
const stream = new Readable();
stream.push(req.file.buffer);
stream.push(null);
const opts: SubmitOptions = {
mode: mode as 'import'|'add',
learnCategories: readCheckbox(learn),
runTransfers: readCheckbox(transfers)
};
const response = await prepareTransactions(stream, profile, server, config);
const result = await submitTransactions(stream, profile, server, config, opts);
res.send(formatResult(result));
res.send(response);
});
app.post("/submit", express.json(), async (req, res) => {
const { transactions, opts, server } = req.body;
const response = await submitTransactions(transactions, server, config, opts);
res.send(response);
});
app.listen(port, () => {
console.log(`Server running on ${port} port`);
});
}
function readCheckbox(value: string|undefined): boolean {
return ["on", "true"].includes(value?.toLowerCase() ?? "");
}
function formatResult(result: ImportResult): string {
return `
<html>
<body>
<h2>Import Result</h2>
<h3>Considered transactions (${result.transactions.length})</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>Kind</th>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Amount</th>
<th>Title</th>
</th>
</thead>
<tbody>
${result.transactions.map(t => `<tr><td>${t.id}</td><td>${t.kind}</td><td>${t.date}</td><td>${t.from}</td><td>${t.to}</td><td>${t.amount}</td><td>${t.title}</td></tr>`)}
</tbody>
</table>
<h3>Import status</h3>
<p>
<ul>
<li>Added: <strong>${result.result.added.length}</strong></li>
<li>Updated: <strong>${result.result.updated.length}</strong></li>
<li>Errors: <strong>${result.result?.errors?.length ?? 0}</strong></li>
</ul>
</p>
${(result.result.errors?.length ?? 0) > 0
? `<p>Errors: <ul>${result.result.errors?.map(e => `<li>${e.message}</li>`)}</ul></p>`
: ""}
<h3>Skipped CSV rows</h3>
<p>The <tt>:::</tt> is CSV field delimiter</p>
<pre>${result.skipped.map(d => d.join(" ::: ")).join("\n")}</pre>
</body>
</html>
`;
}