Add support for 2-stage import with web server
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user