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:
+33
-22
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user