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
+36
View File
@@ -393,6 +393,42 @@ func (d *DB) Months() ([]string, error) {
return out, rows.Err()
}
// SourceFileInfo is what the index recorded about one imported statement.
type SourceFileInfo struct {
AccountSlug string
Path string // relative to the data root, as the importer records it
SHA256 string
ImportedAt string
// Added counts the transactions this file introduced. A row seen again in
// an overlapping statement is deduplicated and stays with the file that
// brought it first, so this is not how many rows the file holds.
Added int
}
// SourceFiles lists every statement the index has imported.
func (d *DB) SourceFiles() ([]SourceFileInfo, error) {
rows, err := d.sql.Query(`
SELECT a.slug, s.path, s.sha256, s.imported_at, COUNT(t.id)
FROM source_files s
JOIN accounts a ON a.id = s.account_id
LEFT JOIN transactions t ON t.source_file_id = s.id
GROUP BY s.id
ORDER BY a.slug, s.path`)
if err != nil {
return nil, fmt.Errorf("list source files: %w", err)
}
defer rows.Close()
var out []SourceFileInfo
for rows.Next() {
var f SourceFileInfo
if err := rows.Scan(&f.AccountSlug, &f.Path, &f.SHA256, &f.ImportedAt, &f.Added); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// Tags lists every tag in use, for completion in the rule builder.
func (d *DB) Tags() ([]string, error) {
rows, err := d.sql.Query(`