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