Upload statements from the web app

The Accounts screen gets an Add statements panel: pick an account, drop
files on it or choose them, and they are saved into that account's folder
and imported. The folder stays the source of truth -- an upload only puts
a file where `money import` looks, then runs the same import as the
Import button, so deleting index.db and re-importing still loses nothing.

Files travel base64 inside JSON rather than as multipart. There is no
auth, and the JSON-only rule is what keeps another site's form from
posting here; multipart is exactly what such a form can send.

An upload never replaces a statement: identical contents are a no-op and
different ones are refused with 409. Names import would not read back --
not a plain file name, dotfiles, account.toml, outside the account's
include patterns -- are refused, and a batch is checked whole before any
of it is written. Files are written through a dotfile and renamed, so a
concurrent import never reads half of one.

The overview now lists the account folders on disk, read fresh so one
created after startup is a valid target; it replaces the configured
count the empty accounts screen used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 18:51:56 +02:00
co-authored by Claude Opus 5.5
parent ec8a844441
commit 7ec9976b80
6 changed files with 489 additions and 22 deletions
+99 -7
View File
@@ -203,22 +203,31 @@ async function refreshAll() {
// --- import and retag -----------------------------------------------------
async function runImport(force) {
if (state.busy) return;
function runImport(force) {
return importing(() => api('POST', '/api/import', { force: !!force }));
}
// importing runs a request that ends in an import — the Import button, or an
// upload — and reports it the same way. It answers whether the request
// succeeded; per-file failures inside the import still count as success.
async function importing(request) {
if (state.busy) return false;
state.busy = true;
const btn = document.getElementById('import');
btn.disabled = true;
btn.textContent = 'Importing…';
setStatus('importing… parsing PDF statements can take a while');
try {
const res = await api('POST', '/api/import', { force: !!force });
const res = await request();
state.status = res.status;
state.error = res.failed ? `${res.failed} file(s) failed to import` : '';
renderBanners();
showImportDetails(res);
await refreshAll();
return true;
} catch (e) {
setError(e);
return false;
} finally {
state.busy = false;
btn.disabled = false;
@@ -255,17 +264,19 @@ async function retag() {
function mountAccounts(main) {
const body = h('div');
main.append(h('h2', {}, 'Accounts'), body);
const upload = h('div');
main.append(h('h2', {}, 'Accounts'), body, upload);
async function refresh() {
if (!state.overview) await loadOverview();
const o = state.overview;
upload.replaceChildren(uploader(o));
if (!o.accounts.length) {
// An account.toml is not enough on its own: folders reach the index
// only through an import, so say that rather than show nothing.
body.replaceChildren(h('div', { class: 'panel empty' }, o.configured
? `No accounts imported yet.\n\n${o.configured} account folder(s) configured in ${o.root}.\nPress Import to read their statements.`
: `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, drop statements in, then press Import.`));
body.replaceChildren(h('div', { class: 'panel empty' }, o.folders.length
? `No accounts imported yet.\n\n${o.folders.length} account folder(s) in ${o.root}.\nAdd statements below, or put them in the folders and press Import.`
: `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, then add statements to it here.`));
return;
}
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
@@ -282,6 +293,87 @@ function mountAccounts(main) {
return { refresh };
}
// uploader puts statement files into an account folder and imports them. The
// folder is where they belong: the index is rebuilt from it, so a statement
// that lived only in the index would be lost the next time it is deleted.
function uploader(o) {
const panel = h('div', { class: 'panel upload' }, h('h3', {}, 'Add statements'));
if (!o.folders.length) {
panel.append(h('div', { class: 'muted' }, o.foldersError
? `Account folders do not load: ${o.foldersError}`
: `There is no account folder to add statements to. Create one in ${o.root} with an account.toml.`));
return panel;
}
let files = [];
const account = h('select', { 'aria-label': 'Account to add statements to' },
o.folders.map((f) => h('option', { value: f }, f)));
// Default to the account being browsed, since that is usually the one.
account.value = o.folders.includes(state.filter.account) ? state.filter.account : o.folders[0];
const picker = h('input', {
type: 'file', multiple: true, hidden: true,
onchange: (e) => choose(e.target.files),
});
const chosen = h('div', { class: 'chosen muted' }, 'No files chosen');
const send = h('button', { type: 'button', class: 'primary', disabled: true, onclick: submit }, 'Upload and import');
const zone = h('div', {
class: 'dropzone',
tabindex: 0,
role: 'button',
onclick: () => picker.click(),
onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); } },
ondragover: (e) => { e.preventDefault(); zone.classList.add('over'); },
ondragleave: () => zone.classList.remove('over'),
ondrop: (e) => { e.preventDefault(); zone.classList.remove('over'); choose(e.dataTransfer.files); },
}, 'Drop statement files here, or click to choose');
function choose(list) {
files = Array.from(list || []);
send.disabled = !files.length;
chosen.textContent = files.length
? files.map((f) => `${f.name} (${Math.ceil(f.size / 1024)} KB)`).join(' · ')
: 'No files chosen';
}
async function submit() {
send.disabled = true;
try {
const payload = {
account: account.value,
files: await Promise.all(files.map(async (f) => ({ name: f.name, data: await base64(f) }))),
};
if (await importing(() => api('POST', '/api/upload', payload))) {
picker.value = '';
choose([]);
}
} catch (e) {
setError(e);
} finally {
send.disabled = !files.length;
}
}
panel.append(
h('div', { class: 'toolbar' }, h('label', {}, 'Account ', account), send),
zone, picker, chosen,
h('div', { class: 'hint muted' },
'Files are saved into the account folder, next to the statements already there, and imported. ' +
'A file with the same name and different contents is refused rather than replaced.'));
return panel;
}
// base64 encodes a file for the JSON body. Chunked, because spreading a whole
// statement into String.fromCharCode overflows the call stack.
async function base64(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(bin);
}
// --- shared scope toolbar ---------------------------------------------------
function scopeToolbar(onChange) {