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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user