List each account's statement files, and open them

The Accounts screen gets a Statements list: every file in every account
folder beside what the index made of it -- imported, changed since,
not imported yet (or failed), or gone from disk while its rows remain --
with its size, modified and import times, and how many transactions it
brought in. Clicking a name opens the file.

The list is read from the folders, not the index, through
importer.StatementFiles, so a file shows exactly when import would read
it; store.SourceFiles and importer.Checksum then say how far each has
got. A file the index remembers but the disk lost is listed as missing
rather than vanishing, since its rows would not survive a rebuild.

A file is served only by finding it in that list, never by joining the
requested name onto a path. Statements come from outside and are served
from the app's origin, so none is rendered as a page: text is text/plain
under CSP sandbox, anything not text or PDF is a sandboxed download, and
PDFs -- whose viewers refuse a sandbox -- open in the browser's own
isolated viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 20:00:03 +02:00
co-authored by Claude Opus 5.5
parent 7f5b4846ef
commit 9ad6c05f9a
8 changed files with 373 additions and 7 deletions
+60 -1
View File
@@ -266,13 +266,15 @@ async function retag() {
function mountAccounts(main) {
const body = h('div');
const files = h('div');
const upload = h('div');
main.append(h('h2', {}, 'Accounts'), body, upload);
main.append(h('h2', {}, 'Accounts'), body, files, upload);
async function refresh() {
if (!state.overview) await loadOverview();
const o = state.overview;
upload.replaceChildren(uploader(o));
api('GET', '/api/files').then((res) => files.replaceChildren(fileList(res.files))).catch(setError);
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.
@@ -295,6 +297,63 @@ function mountAccounts(main) {
return { refresh };
}
const FILE_STATUS = {
imported: ['✓', '', 'imported'],
changed: ['⚠', 'warn', 'changed since it was imported — Import re-reads it'],
new: ['•', 'warn', 'not imported — press Import (a file that fails to parse stays here)'],
missing: ['✗', 'neg', 'gone from disk — its rows stay only until the index is rebuilt'],
};
// fileList shows the statements in every account folder beside what the index
// recorded about each, and opens one on click. The folders decide what is
// listed; the index only says how far each file has got.
function fileList(list) {
const panel = h('div', { class: 'panel files' });
if (!list.length) {
panel.append(h('div', { class: 'empty' }, 'No statement files yet.'));
return h('div', {}, h('h3', { class: 'section' }, 'Statements'), panel);
}
const pending = list.filter((f) => f.status !== 'imported').length;
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('tbody', {}, list.map((f) => {
const [mark, cls, text] = FILE_STATUS[f.status] || ['', '', f.status];
return h('tr', {},
h('td', {}, f.account),
h('td', { class: 'desc' }, f.status === 'missing' ? h('span', { class: 'muted' }, f.name) : h('a', {
href: `/api/files/${encodeURIComponent(f.account)}/${encodeURIComponent(f.name)}`,
target: '_blank', rel: 'noopener', title: 'Open this statement',
}, f.name)),
h('td', { class: 'num' }, f.status === 'missing' ? '' : formatSize(f.size)),
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)));
}))));
return h('div', {}, h('h3', { class: 'section' }, 'Statements ',
h('span', { class: 'sub' }, `· ${list.length} file(s)` + (pending ? ` · ${pending} need attention` : ''))), panel);
}
// localTime shows the index's UTC import time in the same local
// YYYY-MM-DD HH:MM that the server gives the files' modified times.
function localTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return iso;
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// 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.
+5
View File
@@ -179,6 +179,11 @@ td.actions button { padding: 1px 8px; font-size: 12px; }
.kept { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.preview { max-height: calc(100vh - 170px); }
h3.section { font-size: 14px; font-weight: 600; margin: 20px 0 8px; }
h3.section .sub { color: var(--muted); font-weight: 400; }
.files a { color: var(--accent); text-decoration: none; }
.files a:hover { text-decoration: underline; }
.upload { margin-top: 16px; padding: 14px; overflow: visible; }
.upload h3 { font-size: 14px; margin: 0 0 10px; }
.upload .toolbar { margin-bottom: 10px; }