Name NLB uploads, delete statements, and stop importing on upload

Four changes to statement handling in the web app, made together and
touching the same upload and statements-list code.

Name NLB uploads by statement date. parser.Namer is an optional
interface, like Warner, through which a parser names its statements;
nlb reads the "Datum izpiska" from the izpisek header and names it
izpisek_YYYY_MM_DD, lowercase, extension included -- ported from the
rename_izpiski.py it replaces. Uploads are staged as dotfiles, invisible
to import, so the parser can read them; two downloads of one statement
then meet under one name and the second is recognised as already there,
while a different statement of the same date is numbered _2 as the
script did. Only uploads are named: source_files records statements by
path, so renaming a file already in a folder would orphan its rows.

Delete a statement from the statements list. The file is removed from
disk for good -- the page says so before it asks -- and
store.ForgetSourceFile drops its transactions and their transfer rows.
A row two overlapping statements share is stored once, under the file
imported first, so it goes too; the account's other statements forget
their checksums and show as changed until the next Import re-reads them
and restores it. A file already gone from disk can be forgotten.

Upload and delete no longer import. Importing stays the user's call,
made with the Import button, so a batch can be put together and looked
over first. Delete still re-pairs transfers, which reads no statement.

Show rows and new rows per statement. The list read "0" for a file
whose rows an earlier, overlapping statement already held, which looked
like a file that failed to parse. source_files now records how many
transactions each statement holds, and the list reads "3 rows · 0 new".

This adds a column the code reads, so an index built by an earlier
version fails with "no such column: s.rows": delete index.db and import
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 21:48:29 +02:00
co-authored by Claude Opus 5.5
parent 9ad6c05f9a
commit a5f7541980
12 changed files with 652 additions and 146 deletions
+33 -22
View File
@@ -205,31 +205,22 @@ async function refreshAll() {
// --- import and retag -----------------------------------------------------
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;
async function runImport(force) {
if (state.busy) return;
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 request();
const res = await api('POST', '/api/import', { force: !!force });
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;
@@ -317,8 +308,8 @@ function fileList(list) {
panel.append(h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, 'Account'), h('th', {}, 'File'), h('th', { class: 'num' }, 'Size'),
h('th', {}, 'Modified'), h('th', {}, ''), h('th', {}, 'Status'),
h('th', { class: 'num', title: 'Transactions this file brought into the index; rows already brought by an overlapping statement count there' }, 'Added'),
h('th', {}, 'Imported'))),
h('th', { class: 'num', title: 'Transactions the file holds, and how many of them it was the first to bring in — an overlapping statement imported earlier already holds the rest' }, 'Transactions'),
h('th', {}, 'Imported'), h('th', {}, ''))),
h('tbody', {}, list.map((f) => {
const [mark, cls, text] = FILE_STATUS[f.status] || ['', '', f.status];
return h('tr', {},
@@ -331,8 +322,11 @@ function fileList(list) {
h('td', { class: 'muted' }, f.modified),
h('td', { class: 'mark ' + cls }, mark),
h('td', { class: cls || 'muted' }, text),
h('td', { class: 'num' }, f.status === 'new' ? '' : f.added),
h('td', { class: 'muted' }, localTime(f.importedAt)));
h('td', { class: 'num' }, f.status === 'new' ? '' : `${f.rows} ${f.rows === 1 ? 'row' : 'rows'} · ${f.added} new`),
h('td', { class: 'muted' }, localTime(f.importedAt)),
h('td', { class: 'actions' }, h('button', {
type: 'button', class: 'danger', onclick: () => removeFile(f),
}, f.status === 'missing' ? 'Forget' : 'Delete')));
}))));
return h('div', {}, h('h3', { class: 'section' }, 'Statements ',
h('span', { class: 'sub' }, `· ${list.length} file(s)` + (pending ? ` · ${pending} need attention` : ''))), panel);
@@ -348,6 +342,22 @@ function localTime(iso) {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
// removeFile deletes a statement for good. It does not import: rows another
// statement also holds come back at the next Import, so the question asked
// here is only about what this file alone brought in.
function removeFile(f) {
const where = `${f.account}/${f.name}`;
const rows = f.status === 'new' ? 'It has not been imported, so no transactions change.'
: `The ${f.added} transaction(s) it brought in leave the index; any another statement also holds come back at the next Import.`;
const question = f.status === 'missing'
? `Forget ${where}? The file is already gone from disk. ${rows}`
: `Delete ${where} permanently? The file is removed from disk and cannot be restored from here. ${rows}`;
if (!confirm(question)) return;
api('POST', '/api/files/delete', { account: f.account, name: f.name })
.then((res) => { setStatus(res.status); return refreshAll(); })
.catch(setError);
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -377,7 +387,7 @@ function uploader(o) {
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 send = h('button', { type: 'button', class: 'primary', disabled: true, onclick: submit }, 'Upload');
const zone = h('div', {
class: 'dropzone',
tabindex: 0,
@@ -404,10 +414,11 @@ function uploader(o) {
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([]);
}
const res = await api('POST', '/api/upload', payload);
picker.value = '';
choose([]);
setStatus(res.status);
await refreshAll();
} catch (e) {
setError(e);
} finally {
@@ -419,7 +430,7 @@ function uploader(o) {
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. ' +
'Files are saved into the account folder, next to the statements already there; press Import to read them. ' +
'A file with the same name and different contents is refused rather than replaced.'));
return panel;
}