Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec9976b80 | ||
|
|
ec8a844441 |
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Statement-driven personal finance tracker. A data directory holds one folder per
|
Statement-driven personal finance tracker. A data directory holds one folder per
|
||||||
account; statements dropped into those folders are parsed into a rebuildable
|
account; statements dropped into those folders are parsed into a rebuildable
|
||||||
SQLite index, categorised by ordered glob rules, and browsed in a Bubble Tea TUI.
|
SQLite index, categorised by ordered glob rules, and browsed in a web app
|
||||||
|
(`money serve`, which is also the default command).
|
||||||
|
|
||||||
Read `README.md` first — it is the user-facing reference for every config key.
|
Read `README.md` first — it is the user-facing reference for every config key.
|
||||||
This file covers what the code assumes and why.
|
This file covers what the code assumes and why.
|
||||||
@@ -10,7 +11,7 @@ This file covers what the code assumes and why.
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/money/main.go subcommands; the TUI is the default
|
cmd/money/main.go subcommands; serve is the default
|
||||||
internal/config rules.toml (rules + transfers), account.toml, XDG config, data-root resolution
|
internal/config rules.toml (rules + transfers), account.toml, XDG config, data-root resolution
|
||||||
internal/model Account, Transaction, amount formatting, description normalisation
|
internal/model Account, Transaction, amount formatting, description normalisation
|
||||||
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
||||||
@@ -20,7 +21,6 @@ internal/importer directory walk, dedupe, balance checks
|
|||||||
internal/rules applies ordered rules, writing rule_tag
|
internal/rules applies ordered rules, writing rule_tag
|
||||||
internal/transfers pairs the two legs of a movement, writing the transfers table
|
internal/transfers pairs the two legs of a movement, writing the transfers table
|
||||||
internal/report per-tag aggregation
|
internal/report per-tag aggregation
|
||||||
internal/tui Bubble Tea models
|
|
||||||
internal/web `money serve`: JSON API + embedded single page (static/)
|
internal/web `money serve`: JSON API + embedded single page (static/)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ than forgiven.** That is the condition on the tolerance existing at all: the
|
|||||||
pair leaves the report entirely, so a difference swallowed inside one would be
|
pair leaves the report entirely, so a difference swallowed inside one would be
|
||||||
spending that never appears anywhere. `Pair.Fee` is what left less what
|
spending that never appears anywhere. `Pair.Fee` is what left less what
|
||||||
arrived, and `report.Excluded` carries it out per currency alongside the legs —
|
arrived, and `report.Excluded` carries it out per currency alongside the legs —
|
||||||
named on the `money report` line and on its own row under the TUI's report.
|
named on the `money report` line and on its own row under the web report.
|
||||||
Nothing may pair on a mismatch without that difference reaching `Excluded`.
|
Nothing may pair on a mismatch without that difference reaching `Excluded`.
|
||||||
It is a fee and not a tag: no rule produces it, `report.ByTag` never sees it,
|
It is a fee and not a tag: no rule produces it, `report.ByTag` never sees it,
|
||||||
and it must not be turned into a synthetic transaction to make the total
|
and it must not be turned into a synthetic transaction to make the total
|
||||||
@@ -145,49 +145,41 @@ hand, while a narrower one is meant to take precedence and does.
|
|||||||
|
|
||||||
**The report's period narrows the report and nothing else.** The screen opens
|
**The report's period narrows the report and nothing else.** The screen opens
|
||||||
on last month, and `←`/`→` step along `report.Periods` — the named windows,
|
on last month, and `←`/`→` step along `report.Periods` — the named windows,
|
||||||
then the months the index holds. `reloadReport` therefore runs its *own* query
|
then the months the index holds. The report and the transaction list share a
|
||||||
rather than reusing the rows the transaction list is showing: the two share
|
scope (account, search, untagged), which `web.filterFrom` reads and
|
||||||
`m.filter` (account, search, untagged) and differ only in the date bounds, so
|
`state.filter` holds in the page; the period is read by the report handler
|
||||||
opening on last month must not hide the rest of the index from the list beside
|
alone and kept in `state.report`, so opening on last month cannot hide the
|
||||||
it. Nothing may put the period into `m.filter`, which is exactly what would
|
rest of the index from the list. Nothing may put the period into that shared
|
||||||
make it leak. Covered by `TestReportPeriodLeavesTheTransactionListAlone`.
|
scope, which is exactly what would make it leak. Covered by `TestReportPeriodLeavesTransactionsAlone`.
|
||||||
|
|
||||||
The axis is built from `store.Months` over the *whole* index, not from the rows
|
The axis is built from `store.Months` over the *whole* index, not from the rows
|
||||||
in view, or it would grow and shrink as the account or search filter changed and
|
in view, or it would grow and shrink as the account or search filter changed and
|
||||||
move under the cursor. It is rebuilt on every `reload` (an import can reach
|
move under the cursor. It is rebuilt on every request (an import can reach
|
||||||
further back) but keeps the window the user was on. The windows are relative to
|
further back), and the page names the period by its label rather than its
|
||||||
today, never to the newest statement: "last month" with nothing in it reports
|
position, so a grown axis keeps the window the user was on. The windows are
|
||||||
nothing and says so, because silently answering for a month nobody asked for is
|
relative to today, never to the newest statement: "last month" with nothing in
|
||||||
worse than an empty screen. That is also why an empty period and an empty index
|
it reports nothing and says so, because silently answering for a month nobody
|
||||||
give different messages — one asks for another period, the other for an import.
|
asked for is worse than an empty screen. That is also why an empty period and
|
||||||
|
an empty index give different messages — one asks for another period, the
|
||||||
|
other for an import.
|
||||||
|
|
||||||
**The report's sort rearranges rows; it never changes which rows there are.**
|
**The report's sort rearranges rows; it never changes which rows there are.**
|
||||||
`report.Order` is passed to `ByTag` and is deliberately *not* part of
|
`report.Order` is passed to `ByTag` and is deliberately *not* part of
|
||||||
`store.Filter` or `m.filter` — the period decides what is counted, the order
|
`store.Filter` or the shared scope — the period decides what is counted, the
|
||||||
only how it is listed, and merging the two would make a sort able to hide a
|
order only how it is listed, and merging the two would make a sort able to hide
|
||||||
tag. Currency stays the outer sort key under every order, because there are no
|
a tag. Currency stays the outer sort key under every order, because there are no
|
||||||
exchange rates to compare two currencies by, and every order falls back to the
|
exchange rates to compare two currencies by, and every order falls back to the
|
||||||
tag so ties keep a fixed position instead of shuffling between reloads.
|
tag so ties keep a fixed position instead of shuffling between reloads.
|
||||||
`Order.Column` is what lets the TUI mark the sorted heading without keeping its
|
`Order.Column` is what lets the page mark the sorted heading without keeping its
|
||||||
own copy of that mapping; a new order needs a column of its own, which
|
own copy of that mapping; a new order needs a column of its own, which
|
||||||
`TestOrderColumnsAreDistinct` checks.
|
`TestOrderColumnsAreDistinct` checks.
|
||||||
|
|
||||||
**The builders are forms, so the global keymap must not apply there.**
|
**The single-key shortcuts must not fire while typing.** The page's `keydown`
|
||||||
`Update` routes to `updateRules` / `updateTransfers` before `updateNormal`
|
handler ignores them whenever the target is an input, or typing `i` in the tag
|
||||||
whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit
|
field would start an import. A view's own `key` hook runs first and is told
|
||||||
and `i` would start an import. Any new full-screen input needs the same
|
whether the user is typing, so a command *inside* a builder has to be a chord —
|
||||||
treatment. It cuts the other way too: a command *inside* a builder has to be a
|
`ctrl+s` re-sorts the preview — because every printable key belongs to the
|
||||||
chord — `ctrl+s` re-sorts the preview — because every printable key belongs to
|
field being typed in.
|
||||||
the field being typed in.
|
|
||||||
|
|
||||||
**In the rule builder, `tab` completes first and moves focus second.** The
|
|
||||||
account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion`
|
|
||||||
key is `tab` and whose `NextSuggestion`/`PrevSuggestion` are `up`/`down` — all
|
|
||||||
three already meant something here. So `updateRules` intercepts `tab` and calls
|
|
||||||
`acceptCompletion` before falling back to `setRuleFocus`, keeps `up`/`down` on
|
|
||||||
field navigation, and lets `ctrl+n` / `ctrl+p` through to the input for cycling.
|
|
||||||
`SetValue` does not re-match the suggestion list, so `acceptCompletion` re-sets
|
|
||||||
it afterwards or `ctrl+n` would offer candidates that no longer fit the value.
|
|
||||||
|
|
||||||
**A rule's usage count is how many transactions it wins, not how many its glob
|
**A rule's usage count is how many transactions it wins, not how many its glob
|
||||||
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
|
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
|
||||||
@@ -211,41 +203,51 @@ writer goes through `writeFileAtomic`, and both editors re-parse the result
|
|||||||
before replacing the file.
|
before replacing the file.
|
||||||
|
|
||||||
**An edit must not lose what the builder does not show.** The form has four
|
**An edit must not lose what the builder does not show.** The form has four
|
||||||
fields and a `Rule` has five, so `saveRule` carries `Type` through from the rule
|
fields and a `Rule` has five, so `editRule` takes `Type` from the rule on disk —
|
||||||
being edited and the form says it is doing so. Nothing may round-trip a rule
|
never from the request — and the form says it is keeping it. Nothing may
|
||||||
through those four fields alone — a pattern the user was never shown is not a
|
round-trip a rule through those four fields alone — a pattern the user was
|
||||||
pattern they chose to remove. A new field on `Rule` needs the same treatment or
|
never shown is not a pattern they chose to remove. A new field on `Rule` needs
|
||||||
a field of its own. Covered by `TestRuleEditKeepsTypePattern`.
|
the same treatment or a field of its own. Covered by `TestEditKeepsTypePattern`.
|
||||||
|
|
||||||
**The rule builder's preview is what the rule is judged against, which is not
|
**The rule builder's preview is what the rule is judged against, which is not
|
||||||
always "what is untagged".** For a new rule those are the same thing. For an
|
always "what is untagged".** For a new rule those are the same thing. For an
|
||||||
edit they are not: the rule's own transactions are tagged, so an untagged-only
|
edit they are not: the rule's own transactions are tagged, so an untagged-only
|
||||||
preview would be empty for a rule that works. `reloadPreviewGroups` adds them
|
preview would be empty for a rule that works. `previewGroups` adds them back,
|
||||||
back, and `refreshRulePreview` keeps the ones the new glob stops catching on
|
and `rulePreview` keeps the ones the new glob stops catching on screen marked
|
||||||
screen marked `−` instead of dropping them silently, because giving one up is
|
`−` instead of dropping them silently, because giving one up is the decision
|
||||||
the decision being made.
|
being made. Covered by `TestEditPreviewShowsWhatIsLetGo`.
|
||||||
|
|
||||||
**The web app is a second frontend, not a second implementation.**
|
**The web app is a frontend, not a second implementation.** Everything that
|
||||||
`internal/web` ports the TUI's screens over HTTP, and everything that decides
|
decides something stays in Go: amounts are formatted server-side (`money` is
|
||||||
something stays in Go: amounts are formatted server-side (`money` is text plus
|
text plus a sign, never a number the browser could add up), the rule builder's
|
||||||
a sign, never a number the browser could add up), the rule builder's preview is
|
preview is matched by `glob.Match` on the server rather than re-implemented in
|
||||||
matched by `glob.Match` on the server rather than re-implemented in JS, and the
|
JS, and the transfer preview runs `transfers.Analyze`. Keep it that way — a
|
||||||
transfer preview runs `transfers.Analyze`. Keep it that way — a JS glob or a
|
JS glob or a JS sum is a second copy of a rule that would drift from the
|
||||||
JS sum is a second copy of a rule that would drift from the first.
|
first.
|
||||||
`static/app.js` only lays out what the API returns.
|
`static/app.js` only lays out what the API returns.
|
||||||
|
|
||||||
The web server outlives edits to rules.toml in a way the TUI does not, so
|
The server outlives edits to rules.toml in a way a one-shot command does not,
|
||||||
`/api/retag` and `/api/import` re-read it first (import also re-reads the
|
so `/api/retag` and `/api/import` re-read it first (import also re-reads the
|
||||||
account folders), and `/api/overview` reports `stale` when the file on disk no
|
account folders), and `/api/overview` reports `stale` when the file on disk no
|
||||||
longer equals what the engines hold. Every edit and delete sends the rule or
|
longer equals what the engines hold. Every edit and delete sends the rule or
|
||||||
transfer it showed alongside the position, and is refused with 409 unless
|
transfer it showed alongside the position, and is refused with 409 unless
|
||||||
rules.toml still holds exactly that there — a position from a stale page can
|
rules.toml still holds exactly that there — a position from a stale page can
|
||||||
otherwise name a different rule. An edit takes `Type` from the rule *on disk*,
|
otherwise name a different rule. Covered by `TestStalePositionIsRefused`.
|
||||||
never from the request, which is the web side of `TestRuleEditKeepsTypePattern`.
|
|
||||||
|
|
||||||
There is no auth by design (`--addr` defaults to loopback). Non-GET requests
|
There is no auth by design (`--addr` defaults to loopback). Non-GET requests
|
||||||
must be `application/json`, which is what keeps a cross-site form from posting
|
must be `application/json`, which is what keeps a cross-site form from posting
|
||||||
to it; do not relax that without putting something else in its place.
|
to it; do not relax that without putting something else in its place.
|
||||||
|
That is why `/api/upload` takes files base64 inside JSON rather than as
|
||||||
|
multipart: multipart is precisely what a cross-site form can send.
|
||||||
|
|
||||||
|
**An upload only puts a file where `money import` looks.** It writes into an
|
||||||
|
existing account folder and then runs the same import as `/api/import`, so the
|
||||||
|
statements stay the source of truth and nothing reaches the index any other
|
||||||
|
way. It never overwrites a statement (identical contents are a no-op, different
|
||||||
|
ones a 409), refuses any name import would not read back — not a plain file
|
||||||
|
name, a dotfile, `account.toml`, outside the account's `include` — and checks a
|
||||||
|
whole batch before writing any of it. Covered by
|
||||||
|
`TestUploadNeverReplacesAStatement` and `TestUploadRefusesBadNames`.
|
||||||
|
|
||||||
## Adding a bank parser
|
## Adding a bank parser
|
||||||
|
|
||||||
@@ -306,20 +308,18 @@ migrates it, so a demo root left over from an earlier build either carries dead
|
|||||||
columns or fails with `no such column`, depending on which way the schema
|
columns or fails with `no such column`, depending on which way the schema
|
||||||
moved.
|
moved.
|
||||||
|
|
||||||
### Driving the TUI in tests
|
### Testing the web app
|
||||||
|
|
||||||
Prefer feeding `tea.Msg` values to `Model.Update` directly — that is how every
|
Drive the API through `Server.Handler()` with `httptest` — `newTestServer` in
|
||||||
existing TUI test works, and it covers the keymap without a terminal.
|
`internal/web/server_test.go` builds a data root and an index tagged and paired
|
||||||
|
as an import would leave them, with "today" fixed so the report's default
|
||||||
|
window is predictable. That is how every web test works, and since the page
|
||||||
|
only lays out what the API returns, it is where behaviour gets tested.
|
||||||
|
|
||||||
If a real terminal is genuinely needed, note that piping into `script` does not
|
For a look at the real page, run `money --root <demo> serve --addr
|
||||||
deliver keystrokes. Use a pty and answer the two capability queries Bubble Tea
|
127.0.0.1:<port>` against a throwaway root. Delete the index first if you want
|
||||||
sends on startup, or the program blocks before its first render:
|
to watch an import do work, or it will be skipped by checksum and finish
|
||||||
|
instantly.
|
||||||
- `ESC]11;?` (background colour) → reply `ESC]11;rgb:1e1e/1e1e/1e1e ESC\`
|
|
||||||
- `ESC[6n` (cursor position) → reply `ESC[1;1R`
|
|
||||||
|
|
||||||
Also delete the index first, or the import you are trying to observe will be
|
|
||||||
skipped by checksum and finish instantly.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,7 @@ only if you hand the binary to someone else.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
money # open the TUI (default)
|
money # the web app on 127.0.0.1:8080 (same as `money serve`)
|
||||||
money serve # the same screens as a web app, on 127.0.0.1:8080
|
|
||||||
money serve --addr :8080 # listen on every interface (there is no login)
|
money serve --addr :8080 # listen on every interface (there is no login)
|
||||||
money import # extract new transactions from every statement
|
money import # extract new transactions from every statement
|
||||||
money import --force # re-parse statements even if unchanged
|
money import --force # re-parse statements even if unchanged
|
||||||
@@ -103,7 +102,8 @@ money config # which data root is in use, and why
|
|||||||
|
|
||||||
An account folder appears in the app only once its statements have been
|
An account folder appears in the app only once its statements have been
|
||||||
imported — creating an `account.toml` is not enough on its own. Run
|
imported — creating an `account.toml` is not enough on its own. Run
|
||||||
`money import` (or press `i` in the TUI) after adding one.
|
`money import` (or press Import in the web app) after adding one, or add its
|
||||||
|
first statements from the Accounts screen, which imports them as it saves them.
|
||||||
|
|
||||||
`--uniq` turns `ls` into a list of patterns still to write rather than a list of
|
`--uniq` turns `ls` into a list of patterns still to write rather than a list of
|
||||||
rows to read: one line per distinct description, since fifty visits to the same
|
rows to read: one line per distinct description, since fifty visits to the same
|
||||||
@@ -132,20 +132,74 @@ It composes with the other filters (`--account`, `--month`, `--search`), and
|
|||||||
`--limit` caps the rows printed, saying how many it held back. It cannot be
|
`--limit` caps the rows printed, saying how many it held back. It cannot be
|
||||||
combined with `--wide`, whose columns all belong to a single transaction.
|
combined with `--wide`, whose columns all belong to a single transaction.
|
||||||
|
|
||||||
### TUI keys
|
## The web app
|
||||||
|
|
||||||
|
`money serve`, which is also what `money` runs with no command, puts seven
|
||||||
|
screens in a browser: accounts, transactions, report, rule builder, rules,
|
||||||
|
transfer builder and transfers. It runs the same code as the CLI: amounts are formatted,
|
||||||
|
globs matched and transfers paired on the server, and the page only shows the
|
||||||
|
answers. It is one binary with the page built in; nothing else to deploy.
|
||||||
|
|
||||||
|
```
|
||||||
|
money # http://127.0.0.1:8080
|
||||||
|
money serve --root /srv/money --addr 0.0.0.0:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**There is no authentication.** Anyone who can reach the port can read every
|
||||||
|
transaction, rewrite `rules.toml` and start an import, which is why it listens
|
||||||
|
on loopback unless `--addr` says otherwise. To use it from elsewhere, put it
|
||||||
|
behind a reverse proxy that does the logging in, or reach it over SSH / a VPN.
|
||||||
|
Requests that change anything must be sent as JSON, so another website open in
|
||||||
|
the same browser cannot post a form to it.
|
||||||
|
|
||||||
|
The server outlives hand edits to `rules.toml`, so it treats the file the way a
|
||||||
|
fresh command would:
|
||||||
|
|
||||||
|
- **Retag and Import re-read `rules.toml`** (and Import the account folders)
|
||||||
|
before running, exactly as `money retag` / `money import` would. Until then a
|
||||||
|
banner says the file on disk no longer matches what the index was derived
|
||||||
|
from.
|
||||||
|
- **Edits and deletes check the file first.** They go by rule position, and the
|
||||||
|
page also sends the rule it showed you; if `rules.toml` no longer holds that
|
||||||
|
rule there (another tab, a hand edit) the change is refused and you are asked
|
||||||
|
to reload, rather than editing whichever rule moved into its place.
|
||||||
|
|
||||||
|
Shift-click **Import** for `import --force`.
|
||||||
|
|
||||||
|
### Adding statements from the browser
|
||||||
|
|
||||||
|
The Accounts screen (`1`) has an **Add statements** panel: pick the account,
|
||||||
|
drop files on it (or click to choose them) and press **Upload and import**. The
|
||||||
|
files are saved into that account's folder, exactly where you would have copied
|
||||||
|
them by hand, and then imported — the folder stays the source of truth, so
|
||||||
|
deleting `index.db` and re-importing still gets everything back.
|
||||||
|
|
||||||
|
An upload never replaces a statement. A file whose name is already in the folder
|
||||||
|
is skipped if its contents are identical and refused if they differ; rename it
|
||||||
|
or remove the old one first. Names import would not read back — dotfiles,
|
||||||
|
`account.toml`, anything outside an account's `include` patterns — are refused
|
||||||
|
too, and a batch with one bad file writes none of them. The folder itself must
|
||||||
|
already exist with an `account.toml`: an upload adds statements to an account,
|
||||||
|
it does not create one.
|
||||||
|
|
||||||
|
### Keys
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `1` `2` `3` `4` `5` `6` `7` / `tab` | accounts · transactions · report · rule builder · rules · transfer builder · transfers |
|
| `1` – `7` | accounts · transactions · report · rule builder · rules · transfer builder · transfers |
|
||||||
| `enter` | open the selected account (accounts view) |
|
|
||||||
| `/` | filter by description |
|
| `/` | filter by description |
|
||||||
| `u` | show only untagged transactions (matched transfer legs are not among them) |
|
| `u` | show only untagged transactions (matched transfer legs are not among them) |
|
||||||
| `a` | clear the account filter |
|
| `a` | clear the account filter |
|
||||||
| `←` `→` | move the report's period (report view) |
|
| `←` `→` | move the report's period (report) |
|
||||||
| `s` | change how the report is sorted (report view) |
|
| `s` | change how the report is sorted (report) |
|
||||||
| `i` | import · `r` re-apply rules · `q` quit |
|
| `ctrl+s` | sort the rule builder's preview by name / by count |
|
||||||
|
| `i` | import · `r` retag |
|
||||||
|
|
||||||
There is no key that tags a transaction. Tags come from `rules.toml` and
|
Inside a form every printable key belongs to the field you are typing in, so
|
||||||
|
the single-letter keys only work outside one; that is why the builder's sort is
|
||||||
|
a chord. Clicking an account on `1` filters the transaction list to it.
|
||||||
|
|
||||||
|
There is no control that tags a transaction. Tags come from `rules.toml` and
|
||||||
nowhere else, so tagging what you are looking at means writing a rule for it on
|
nowhere else, so tagging what you are looking at means writing a rule for it on
|
||||||
`4` — which is why that screen shows you what a glob catches before you save.
|
`4` — which is why that screen shows you what a glob catches before you save.
|
||||||
|
|
||||||
@@ -157,34 +211,23 @@ and the month that has just ended is the last one your statements can be
|
|||||||
complete for. If you have not downloaded that month yet, the screen says the
|
complete for. If you have not downloaded that month yet, the screen says the
|
||||||
period is empty rather than quietly showing you a different one.
|
period is empty rather than quietly showing you a different one.
|
||||||
|
|
||||||
`←` and `→` move along the time axis on the left of the totals, which runs from
|
The time axis beside the totals runs from the widest window down to the oldest
|
||||||
the widest window down to the oldest month in the index:
|
month in the index — `all time`, `this year`, `last 12 months`, `last 3
|
||||||
|
months`, `this month`, `last month`, then one entry per month. Click one, or
|
||||||
```
|
step along it with `←` and `→`.
|
||||||
Period Tag Cur Out In Net N
|
|
||||||
all time groceries EUR 10.00 0.00 -10.00 1
|
|
||||||
this year TOTAL EUR 10.00 0.00 -10.00
|
|
||||||
last 12 months
|
|
||||||
last 3 months
|
|
||||||
this month
|
|
||||||
▸ last month
|
|
||||||
2026-05
|
|
||||||
2026-04
|
|
||||||
```
|
|
||||||
|
|
||||||
The three rolling windows run to the end of *this* month rather than to the
|
The three rolling windows run to the end of *this* month rather than to the
|
||||||
last complete one — you ask for "last 3 months" to see what is happening now,
|
last complete one — you ask for "last 3 months" to see what is happening now,
|
||||||
and leaving out the days since the 1st would answer a different question. Each
|
and leaving out the days since the 1st would answer a different question. Each
|
||||||
month below `last month` is a month the index actually holds; months that a
|
month below `last month` is a month the index actually holds; months that a
|
||||||
named window above already covers are not repeated. The axis is hidden on a
|
named window above already covers are not repeated.
|
||||||
terminal too narrow for it and the totals both, where the title still names the
|
|
||||||
period.
|
|
||||||
|
|
||||||
`s` cycles how the rows are arranged, and the marked column heading says which
|
Click a column heading, or press `s` to cycle, to change how the rows are
|
||||||
one they are arranged by: `Out ▾` largest spend first (the default question a
|
arranged; the marked heading says which one they are arranged by: `Out ▾`
|
||||||
spending report answers), then `In ▾`, `Net ▾` lowest first so the biggest
|
largest spend first (the default question a spending report answers), then
|
||||||
losses lead, `N ▾` most transactions first, and `Tag ▴` A→Z. `money report`
|
`In ▾`, `Net ▾` lowest first so the biggest losses lead, `N ▾` most
|
||||||
takes the same choice as `--sort out|in|net|count|tag`.
|
transactions first, and `Tag ▴` A→Z. `money report` takes the same choice as
|
||||||
|
`--sort out|in|net|count|tag`.
|
||||||
|
|
||||||
Currency is always the outer grouping and no sort changes that — there are no
|
Currency is always the outer grouping and no sort changes that — there are no
|
||||||
exchange rates here, so two currencies interleaved by amount would invite a
|
exchange rates here, so two currencies interleaved by amount would invite a
|
||||||
@@ -192,10 +235,9 @@ comparison between numbers that cannot be compared. Rows that tie fall back to
|
|||||||
the tag, so they keep a fixed position rather than shuffling between reloads.
|
the tag, so they keep a fixed position rather than shuffling between reloads.
|
||||||
The sort rearranges rows; it never changes which rows there are.
|
The sort rearranges rows; it never changes which rows there are.
|
||||||
|
|
||||||
The period narrows the report and only the report. The account filter (`a`,
|
The period narrows the report and only the report. The account, the search and
|
||||||
`enter`), the search (`/`) and the untagged toggle (`u`) are shared with the
|
the untagged toggle are shared with the transaction list, so opening on last
|
||||||
transaction list as before, so opening on last month does not hide the rest of
|
month does not hide the rest of the index from `2`.
|
||||||
the index from `2`.
|
|
||||||
|
|
||||||
`money report` takes `--month` instead; there is no command-line equivalent of
|
`money report` takes `--month` instead; there is no command-line equivalent of
|
||||||
the wider windows.
|
the wider windows.
|
||||||
@@ -203,9 +245,10 @@ the wider windows.
|
|||||||
### Rule builder (`4`)
|
### Rule builder (`4`)
|
||||||
|
|
||||||
Writing rules by hand means guessing what a glob will catch. This screen shows
|
Writing rules by hand means guessing what a glob will catch. This screen shows
|
||||||
the answer as you type: the form is on the left, and on the right the
|
the answer as you type: the form is on the left — glob, account, tag, note —
|
||||||
still-untagged descriptions the glob currently matches, marked `▸`, grouped by
|
and on the right the still-untagged descriptions the glob currently matches,
|
||||||
description, under a running "*n* of *m* descriptions match" count.
|
marked `▸`, grouped by description, under a running "*n* of *m* descriptions
|
||||||
|
match" count.
|
||||||
|
|
||||||
The list narrows as you type, so what is on screen is what the rule would
|
The list narrows as you type, so what is on screen is what the rule would
|
||||||
claim — nothing else is left there to read past. The count keeps the context
|
claim — nothing else is left there to read past. The count keeps the context
|
||||||
@@ -214,60 +257,26 @@ glob picked two descriptions out of seven, and `0 of 7` says the glob is wrong.
|
|||||||
With the glob still empty there is nothing to filter by, and the list is every
|
With the glob still empty there is nothing to filter by, and the list is every
|
||||||
untagged description in the data — which is the other question this screen
|
untagged description in the data — which is the other question this screen
|
||||||
answers, and where you go looking for the next thing to write a rule for.
|
answers, and where you go looking for the next thing to write a rule for.
|
||||||
|
Clicking a description fills the glob with `*THAT DESCRIPTION*`, as a starting
|
||||||
|
point to narrow down.
|
||||||
|
|
||||||
```
|
`enter` (or **Save rule**) appends the rule to `rules.toml` and retags
|
||||||
glob Untagged description ↓ N
|
immediately, so the rows it caught disappear from the list. The account stays
|
||||||
╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2
|
filled in, since the next rule is usually for the same one.
|
||||||
│ *LIDL* │ ▸ LIDL VARNA 9911 1
|
|
||||||
╰────────────────────────────╯
|
|
||||||
vs. the description
|
|
||||||
|
|
||||||
account
|
Clicking the column headings, or `ctrl+s`, switches the list between by name and
|
||||||
╭────────────────────────────╮
|
by count, most seen first; the `↓` says which column the order is read from.
|
||||||
│ blank = every account │
|
|
||||||
╰────────────────────────────╯
|
|
||||||
blank = all accounts
|
|
||||||
|
|
||||||
▸ tag
|
|
||||||
╭────────────────────────────╮
|
|
||||||
│ groceries │
|
|
||||||
╰────────────────────────────╯
|
|
||||||
tab completes · ctrl+n: 1 more
|
|
||||||
|
|
||||||
note
|
|
||||||
╭────────────────────────────╮
|
|
||||||
│ optional │
|
|
||||||
╰────────────────────────────╯
|
|
||||||
why this rule exists
|
|
||||||
|
|
||||||
2 of 7 descriptions match
|
|
||||||
```
|
|
||||||
|
|
||||||
Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
|
|
||||||
|
|
||||||
`tab` / `↑↓` move between the glob, account, tag and note fields, `pgup` /
|
|
||||||
`pgdn` scroll the list, and `enter` appends the rule to `rules.toml` and retags
|
|
||||||
immediately, so the rows it caught disappear from the list. `esc` goes back.
|
|
||||||
On a short window the form gives up its spacing and then its hints, so all four
|
|
||||||
fields stay on screen.
|
|
||||||
|
|
||||||
`ctrl+s` switches the list between by name and by count, most seen first — a
|
|
||||||
chord rather than a letter, because every printable key belongs to the field you
|
|
||||||
are typing in. The `↓` in the header says which column the order is read from.
|
|
||||||
The two answer different questions: by name finds the payee you are looking at,
|
The two answer different questions: by name finds the payee you are looking at,
|
||||||
by count finds the rule worth writing next, since one pattern claiming forty
|
by count finds the rule worth writing next, since one pattern claiming forty
|
||||||
rows is worth more than the first of forty claiming one. Ties keep the
|
rows is worth more than the first of forty claiming one. Ties keep the
|
||||||
alphabetical order, so the list does not reshuffle under you, and the choice
|
alphabetical order, so the list does not reshuffle under you, and the choice
|
||||||
lasts until you change it.
|
lasts until you change it.
|
||||||
|
|
||||||
The account and tag fields complete as you type: the rest of the match is
|
The account and tag fields offer completions as you type. Accounts come from
|
||||||
ghosted in grey after the cursor, and `tab` (or `→` at the end of the line)
|
|
||||||
takes it. When several candidates share the prefix, the hint under the box says
|
|
||||||
how many, and `ctrl+n` / `ctrl+p` cycle through them. Nothing is committed until
|
|
||||||
you accept it, so a new tag is still just typed out in full. Accounts come from
|
|
||||||
the folders on disk and the index; tags from every tag in use plus any named in
|
the folders on disk and the index; tags from every tag in use plus any named in
|
||||||
`rules.toml`, so a tag is completable from the moment a rule mentions it —
|
`rules.toml`, so a tag is completable from the moment a rule mentions it —
|
||||||
which is what stops `groceries` acquiring a `grocery` twin.
|
which is what stops `groceries` acquiring a `grocery` twin. A new tag is just
|
||||||
|
typed out in full.
|
||||||
|
|
||||||
Leaving the account blank applies the rule everywhere; filling it in also
|
Leaving the account blank applies the rule everywhere; filling it in also
|
||||||
narrows the preview to that account. The preview lists only transactions no
|
narrows the preview to that account. The preview lists only transactions no
|
||||||
@@ -281,8 +290,8 @@ report leaves them out anyway.
|
|||||||
|
|
||||||
#### Editing a rule
|
#### Editing a rule
|
||||||
|
|
||||||
`e` on the rules screen (`5`) opens the selected rule in this same form with the
|
**Edit** on the rules screen (`5`) opens the rule in this same form with the
|
||||||
fields filled in, and `enter` rewrites that rule where it sits instead of
|
fields filled in, and saving rewrites that rule where it sits instead of
|
||||||
appending a new one. It keeps its position, since position still breaks ties
|
appending a new one. It keeps its position, since position still breaks ties
|
||||||
between equally specific rules; `rules.toml` is edited textually, so the
|
between equally specific rules; `rules.toml` is edited textually, so the
|
||||||
comments and formatting around it survive, exactly as when deleting.
|
comments and formatting around it survive, exactly as when deleting.
|
||||||
@@ -297,8 +306,8 @@ you are about to hand back to whatever rule catches it next.
|
|||||||
|
|
||||||
The form has no `type` field, so a rule that sets one carries it through
|
The form has no `type` field, so a rule that sets one carries it through
|
||||||
unchanged rather than losing it; it is shown under the glob as `+ type:… ·
|
unchanged rather than losing it; it is shown under the glob as `+ type:… ·
|
||||||
kept`. Saving lands back on the rules screen with the new counts, and `esc`
|
kept`. Saving lands back on the rules screen with the new counts; **Cancel**,
|
||||||
leaves the rule as it was.
|
`esc`, or leaving the screen leaves the rule as it was.
|
||||||
|
|
||||||
### Rules (`5`)
|
### Rules (`5`)
|
||||||
|
|
||||||
@@ -307,7 +316,7 @@ are tried in — with the number of transactions it actually claims. Rules that
|
|||||||
claim none are marked `✗`.
|
claim none are marked `✗`.
|
||||||
|
|
||||||
```
|
```
|
||||||
money · rules · 5 rules · 2 match nothing
|
Rules · 5 rules · 2 match nothing
|
||||||
|
|
||||||
# Pattern Account Tag Txns Note
|
# Pattern Account Tag Txns Note
|
||||||
1 *LIDL SOFIA* (all) groceries 3 the weekly shop
|
1 *LIDL SOFIA* (all) groceries 3 the weekly shop
|
||||||
@@ -326,75 +335,39 @@ deleting. Note that shadowing has nothing to do with the numbering: rule 2 would
|
|||||||
be just as dead written above rule 1, because precedence is decided by how
|
be just as dead written above rule 1, because precedence is decided by how
|
||||||
specific a rule is and not by where it sits.
|
specific a rule is and not by where it sits.
|
||||||
|
|
||||||
`e` opens the selected rule in the builder to edit it, `d` deletes it, and `p`
|
**Edit** opens a rule in the builder, **Delete** deletes it, and **Prune
|
||||||
deletes every rule marked `✗` at once; both deletions ask for a `y` first. `r`
|
unused** deletes every rule marked `✗` at once; both deletions ask first.
|
||||||
recounts against what is currently in the index, which is what you want after an
|
**Refresh counts** recounts against what is currently in the index. Editing and
|
||||||
import has added rows; `rules.toml` itself is read at startup and whenever you
|
deleting both work on `rules.toml` textually, so your comments, ordering and
|
||||||
save a rule from the builder, so an edit made in another window needs a restart.
|
formatting survive. A comment sitting directly above a deleted rule goes with
|
||||||
Editing and deleting both work on `rules.toml` textually, so your comments,
|
it, while one separated by a blank line is treated as a section heading and
|
||||||
ordering and formatting survive. A comment sitting directly above a deleted rule
|
left alone; an edited rule keeps its comments, since they say why it is there
|
||||||
goes with it, while one separated by a blank line is treated as a section
|
and changing its glob rarely changes that.
|
||||||
heading and left alone; an edited rule keeps its comments, since they say why it
|
|
||||||
is there and changing its glob rarely changes that.
|
|
||||||
|
|
||||||
### Transfer builder (`6`)
|
### Transfer builder (`6`)
|
||||||
|
|
||||||
Same idea as the rule builder, for money moved between your own accounts. Both
|
Same idea as the rule builder, for money moved between your own accounts. The
|
||||||
sides are named, and the preview on the right shows what the pair would be:
|
form names both sides — from account, from desc, to account, to desc — plus an
|
||||||
`▸` for a movement it matches end to end, `⚠` for a leg it catches on one side
|
optional tolerance and note, and the preview beside it shows what the pair
|
||||||
and cannot pair with anything on the other.
|
would be: `▸` for a movement it matches end to end, `⚠` for a leg it catches on
|
||||||
|
one side and cannot pair with anything on the other.
|
||||||
|
|
||||||
```
|
```
|
||||||
from account Date Amount Movement Description
|
Date Amount Movement Description
|
||||||
╭────────────────────────────╮ ⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
||||||
│ checking │ ▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
||||||
╰────────────────────────────╯
|
|
||||||
money leaves here
|
|
||||||
|
|
||||||
▸ from desc
|
|
||||||
╭────────────────────────────╮
|
|
||||||
│ *TO SAVINGS* │
|
|
||||||
╰────────────────────────────╯
|
|
||||||
glob vs. the leaving leg
|
|
||||||
|
|
||||||
to account
|
|
||||||
╭────────────────────────────╮
|
|
||||||
│ savings │
|
|
||||||
╰────────────────────────────╯
|
|
||||||
money arrives here
|
|
||||||
|
|
||||||
to desc
|
|
||||||
╭────────────────────────────╮
|
|
||||||
│ *FROM CHECKING* │
|
|
||||||
╰────────────────────────────╯
|
|
||||||
glob vs. the arriving leg
|
|
||||||
|
|
||||||
tolerance %
|
|
||||||
╭────────────────────────────╮
|
|
||||||
│ 0 │
|
|
||||||
╰────────────────────────────╯
|
|
||||||
0 = amounts must match exactly
|
|
||||||
|
|
||||||
note
|
|
||||||
╭────────────────────────────╮
|
|
||||||
│ optional │
|
|
||||||
╰────────────────────────────╯
|
|
||||||
why this transfer exists
|
|
||||||
|
|
||||||
1 pairs · 1 unpaired
|
1 pairs · 1 unpaired
|
||||||
```
|
```
|
||||||
|
|
||||||
The account fields complete exactly as the rule builder's do. `tab` / `↑↓` move
|
The account fields complete exactly as the rule builder's do. `enter` (or
|
||||||
between fields, `pgup` / `pgdn` scroll the list, `enter` appends the definition
|
**Save transfer**) appends the definition to `rules.toml` and re-pairs
|
||||||
to `rules.toml` and re-pairs immediately, and `esc` goes back. Saving keeps both
|
immediately. Saving keeps both account names in place and clears the two globs,
|
||||||
account names in place and clears the two globs, since the next transfer you
|
since the next transfer you write is usually the same route in the other
|
||||||
write is usually the same route in the other direction. The tolerance is cleared
|
direction. The tolerance is cleared with them: carried over silently it would
|
||||||
with them: carried over silently it would loosen a route that never asked for
|
loosen a route that never asked for one.
|
||||||
one. Six fields need more room than the rule builder's four, so on a short
|
|
||||||
window this form gives up its spacing, then its hints, then the borders on the
|
|
||||||
fields you are not editing — every field stays on screen.
|
|
||||||
|
|
||||||
`tolerance %` is the one field worth previewing before you save. Type a
|
`Tolerance %` is the one field worth previewing before you save. Type a
|
||||||
percentage and the pairs it buys appear immediately, showing both amounts —
|
percentage and the pairs it buys appear immediately, showing both amounts —
|
||||||
`500.00 → 495.00` — with what they cost summarised beside the counts:
|
`500.00 → 495.00` — with what they cost summarised beside the counts:
|
||||||
|
|
||||||
@@ -411,7 +384,7 @@ written the other half.
|
|||||||
Every definition in file order, with what it currently pairs.
|
Every definition in file order, with what it currently pairs.
|
||||||
|
|
||||||
```
|
```
|
||||||
money · transfers · 4 definitions · 1 leg(s) unpaired
|
Transfers · 4 definitions · 1 leg(s) unpaired
|
||||||
|
|
||||||
# From To Pairs Unpaired Tol Note
|
# From To Pairs Unpaired Tol Note
|
||||||
1 ⚠ checking *TO SAVINGS* savings *FROM CHECKING* 11 1 monthly saving
|
1 ⚠ checking *TO SAVINGS* savings *FROM CHECKING* 11 1 monthly saving
|
||||||
@@ -430,51 +403,10 @@ it: row 3's two pairs were matched on slack rather than on the amount agreeing.
|
|||||||
It is blank for every definition that requires the exact amount — which is the
|
It is blank for every definition that requires the exact amount — which is the
|
||||||
default, so an empty column means nothing here is pairing on a mismatch.
|
default, so an empty column means nothing here is pairing on a mismatch.
|
||||||
|
|
||||||
`d` deletes the selected definition and `p` deletes every `✗` one at once, both
|
**Delete** deletes a definition and **Prune unmatched** deletes every `✗` one at
|
||||||
after a `y`. Definitions marked `⚠` are never pruned — they are doing something,
|
once, both after asking. Definitions marked `⚠` are never pruned — they are
|
||||||
just not finishing it, and deleting one would hide the problem rather than fix
|
doing something, just not finishing it, and deleting one would hide the problem
|
||||||
it. `r` re-pairs against what is currently in the index.
|
rather than fix it. **Refresh pairing** re-reads what is currently in the index.
|
||||||
|
|
||||||
## Web app
|
|
||||||
|
|
||||||
`money serve` puts the TUI's seven screens in a browser — accounts,
|
|
||||||
transactions, report, rule builder, rules, transfer builder, transfers — with
|
|
||||||
the same behaviour, because it runs the same code: amounts are formatted, globs
|
|
||||||
matched and transfers paired on the server, and the page only shows the
|
|
||||||
answers. It is one binary with the page built in; nothing else to deploy.
|
|
||||||
|
|
||||||
```
|
|
||||||
money serve # http://127.0.0.1:8080
|
|
||||||
money --root /srv/money serve --addr 0.0.0.0:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
**There is no authentication.** Anyone who can reach the port can read every
|
|
||||||
transaction, rewrite `rules.toml` and start an import, which is why it listens
|
|
||||||
on loopback unless `--addr` says otherwise. To use it from elsewhere, put it
|
|
||||||
behind a reverse proxy that does the logging in, or reach it over SSH / a VPN.
|
|
||||||
Requests that change anything must be sent as JSON, so another website open in
|
|
||||||
the same browser cannot post a form to it.
|
|
||||||
|
|
||||||
What differs from the TUI:
|
|
||||||
|
|
||||||
- **Retag and Import re-read `rules.toml`** (and Import the account folders)
|
|
||||||
before running, exactly as a fresh `money retag` / `money import` would — the
|
|
||||||
server outlives hand edits to both. Until then a banner says the file on disk
|
|
||||||
no longer matches what the index was derived from.
|
|
||||||
- **Edits and deletes check the file first.** They go by rule position, as in
|
|
||||||
the TUI, but the page also sends the rule it showed you; if `rules.toml` no
|
|
||||||
longer holds that rule there (another tab, a hand edit) the change is refused
|
|
||||||
and you are asked to reload, rather than editing whichever rule moved into
|
|
||||||
its place.
|
|
||||||
- Clicking a description in the rule builder's preview fills the glob with
|
|
||||||
`*THAT DESCRIPTION*`, as a starting point to narrow down.
|
|
||||||
- Shift-click **Import** for `import --force`.
|
|
||||||
|
|
||||||
The keys still work where they do not fight the browser: `1`–`7` switch
|
|
||||||
screens, `/` searches, `u` toggles untagged, `a` clears the account filter, `i`
|
|
||||||
imports and `r` retags. On the report `←`/`→` move the period and `s` cycles
|
|
||||||
the sort (or click a column heading); in the rule builder `ctrl+s` re-sorts the
|
|
||||||
preview. Inside a form every printable key belongs to the field, as in the TUI.
|
|
||||||
|
|
||||||
## rules.toml
|
## rules.toml
|
||||||
|
|
||||||
@@ -597,7 +529,7 @@ amount wins.
|
|||||||
|
|
||||||
**The difference is not forgiven, it is reported.** A pair leaves the report
|
**The difference is not forgiven, it is reported.** A pair leaves the report
|
||||||
entirely, so a fee hidden inside one would be spending that appears nowhere at
|
entirely, so a fee hidden inside one would be spending that appears nowhere at
|
||||||
all. `money report` names it on the excluded line and the TUI's report gives it
|
all. `money report` names it on the excluded line and the web app's report gives it
|
||||||
its own row (see below). Across currencies there is no fee to compute — the two
|
its own row (see below). Across currencies there is no fee to compute — the two
|
||||||
numbers are in different units — so a tolerance there means nothing and is
|
numbers are in different units — so a tolerance there means nothing and is
|
||||||
ignored.
|
ignored.
|
||||||
|
|||||||
+6
-18
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// The data root holds one folder per account. Drop statements into those
|
// The data root holds one folder per account. Drop statements into those
|
||||||
// folders, run `money import`, and tag what comes out with glob rules in
|
// folders, run `money import`, and tag what comes out with glob rules in
|
||||||
// rules.toml. Running it with no subcommand opens the TUI.
|
// rules.toml. Running it with no subcommand serves the web app.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -28,7 +28,6 @@ import (
|
|||||||
"git.petrovv.com/nikola/money/internal/rules"
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
"git.petrovv.com/nikola/money/internal/store"
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
"git.petrovv.com/nikola/money/internal/transfers"
|
"git.petrovv.com/nikola/money/internal/transfers"
|
||||||
"git.petrovv.com/nikola/money/internal/tui"
|
|
||||||
"git.petrovv.com/nikola/money/internal/web"
|
"git.petrovv.com/nikola/money/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +36,9 @@ const usage = `money - statement-driven personal finance tracker
|
|||||||
usage: money [--root DIR] <command> [flags] (--root may also follow the command)
|
usage: money [--root DIR] <command> [flags] (--root may also follow the command)
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
tui browse transactions, build the rules that tag them and the
|
serve browse transactions, build the rules that tag them and the
|
||||||
transfers that pair them across accounts (default)
|
transfers that pair them across accounts, in a browser
|
||||||
serve the same screens as a web app (--addr, default 127.0.0.1:8080)
|
(--addr, default 127.0.0.1:8080; the default command)
|
||||||
import extract transactions from every statement into the index
|
import extract transactions from every statement into the index
|
||||||
retag re-apply rules.toml: retag everything and re-pair transfers
|
retag re-apply rules.toml: retag everything and re-pair transfers
|
||||||
ls list transactions
|
ls list transactions
|
||||||
@@ -83,7 +82,7 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := "tui"
|
cmd := "serve"
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
cmd, args = args[0], args[1:]
|
cmd, args = args[0], args[1:]
|
||||||
}
|
}
|
||||||
@@ -120,8 +119,6 @@ func run(args []string) error {
|
|||||||
case "parsers":
|
case "parsers":
|
||||||
fmt.Println(strings.Join(parser.Names(), "\n"))
|
fmt.Println(strings.Join(parser.Names(), "\n"))
|
||||||
return nil
|
return nil
|
||||||
case "tui":
|
|
||||||
return cmdTUI(abs, args)
|
|
||||||
case "serve":
|
case "serve":
|
||||||
return cmdServe(abs, args)
|
return cmdServe(abs, args)
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
@@ -488,15 +485,6 @@ func cmdAccounts(root string, _ []string) error {
|
|||||||
return w.Flush()
|
return w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdTUI(root string, _ []string) error {
|
|
||||||
o, err := open(root)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer o.db.Close()
|
|
||||||
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
|
|
||||||
}
|
|
||||||
|
|
||||||
// takeRoot removes --root/-root (as `--root DIR` or `--root=DIR`) from a
|
// takeRoot removes --root/-root (as `--root DIR` or `--root=DIR`) from a
|
||||||
// subcommand's arguments, returning the last value given. It stops at `--`,
|
// subcommand's arguments, returning the last value given. It stops at `--`,
|
||||||
// after which everything is positional.
|
// after which everything is positional.
|
||||||
@@ -529,7 +517,7 @@ func takeRoot(args []string) (root string, rest []string, err error) {
|
|||||||
return root, rest, nil
|
return root, rest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdServe serves the TUI's screens over HTTP until interrupted. There is no
|
// cmdServe serves the web app until interrupted. There is no
|
||||||
// authentication, so it listens on loopback unless told otherwise: anyone who
|
// authentication, so it listens on loopback unless told otherwise: anyone who
|
||||||
// can reach the port can rewrite rules.toml and start an import.
|
// can reach the port can rewrite rules.toml and start an import.
|
||||||
func cmdServe(root string, args []string) error {
|
func cmdServe(root string, args []string) error {
|
||||||
|
|||||||
@@ -4,38 +4,16 @@ go 1.25.6
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
|
||||||
modernc.org/sqlite v1.56.0
|
modernc.org/sqlite v1.56.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -1,76 +1,25 @@
|
|||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
|
||||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
|
||||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ func (d *DB) Months() ([]string, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags lists every tag in use, for completion in the TUI.
|
// Tags lists every tag in use, for completion in the rule builder.
|
||||||
func (d *DB) Tags() ([]string, error) {
|
func (d *DB) Tags() ([]string, error) {
|
||||||
rows, err := d.sql.Query(`
|
rows, err := d.sql.Query(`
|
||||||
SELECT DISTINCT rule_tag FROM transactions
|
SELECT DISTINCT rule_tag FROM transactions
|
||||||
|
|||||||
-2483
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+179
-18
@@ -1,17 +1,20 @@
|
|||||||
// Package web serves the TUI's screens over HTTP: a JSON API over the index
|
// Package web is money's interactive frontend: a JSON API over the index
|
||||||
// and rules.toml, and a single page that drives it. It holds no logic of its
|
// and rules.toml, and a single page that drives it. It holds no logic of its
|
||||||
// own about money — every number is formatted, every glob matched and every
|
// own about money — every number is formatted, every glob matched and every
|
||||||
// pair decided by the same packages the TUI and the CLI use, so the browser
|
// pair decided by the same packages the CLI uses, so the browser never does
|
||||||
// never does arithmetic on an amount or re-implements the matcher.
|
// arithmetic on an amount or re-implements the matcher.
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -32,9 +35,9 @@ import (
|
|||||||
//go:embed static
|
//go:embed static
|
||||||
var static embed.FS
|
var static embed.FS
|
||||||
|
|
||||||
// Server is the long-lived counterpart of the TUI's Model.
|
// Server holds the index and the engines derived from rules.toml.
|
||||||
//
|
//
|
||||||
// Unlike the TUI it outlives any one edit of rules.toml by hand, so retag and
|
// It outlives any one edit of rules.toml by hand, so retag and
|
||||||
// import re-read that file (and import the account folders) before running,
|
// import re-read that file (and import the account folders) before running,
|
||||||
// exactly as a fresh `money retag` or `money import` would. Between those, the
|
// exactly as a fresh `money retag` or `money import` would. Between those, the
|
||||||
// engines are what the index was last derived from, which is what every screen
|
// engines are what the index was last derived from, which is what every screen
|
||||||
@@ -84,6 +87,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/transfers/delete", s.write(s.deleteTransfers))
|
mux.HandleFunc("POST /api/transfers/delete", s.write(s.deleteTransfers))
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/import", s.write(s.runImport))
|
mux.HandleFunc("POST /api/import", s.write(s.runImport))
|
||||||
|
mux.HandleFunc("POST /api/upload", s.write(s.upload))
|
||||||
mux.HandleFunc("POST /api/retag", s.write(s.retag))
|
mux.HandleFunc("POST /api/retag", s.write(s.retag))
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
@@ -159,7 +163,11 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decode(r *http.Request, v any) error {
|
func decode(r *http.Request, v any) error {
|
||||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
|
return decodeLimit(r, v, 1<<20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLimit(r *http.Request, v any, limit int64) error {
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, limit))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(v); err != nil {
|
if err := dec.Decode(v); err != nil {
|
||||||
return badRequest("bad request body: %v", err)
|
return badRequest("bad request body: %v", err)
|
||||||
@@ -167,8 +175,8 @@ func decode(r *http.Request, v any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// status is the reply to every write: one line saying what happened, as the
|
// status is the reply to every write: one line saying what happened, shown
|
||||||
// TUI's status bar would.
|
// in the page's status banner.
|
||||||
type status struct {
|
type status struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
@@ -196,13 +204,17 @@ type accountRow struct {
|
|||||||
|
|
||||||
type overview struct {
|
type overview struct {
|
||||||
Root string `json:"root"`
|
Root string `json:"root"`
|
||||||
// Configured is how many account folders are on disk, which the empty
|
|
||||||
// accounts screen needs: an account.toml is not enough until an import.
|
|
||||||
Configured int `json:"configured"`
|
|
||||||
Accounts []accountRow `json:"accounts"`
|
Accounts []accountRow `json:"accounts"`
|
||||||
// AccountSlugs and Tags are what the builders' fields complete against.
|
// AccountSlugs and Tags are what the builders' fields complete against.
|
||||||
AccountSlugs []string `json:"accountSlugs"`
|
AccountSlugs []string `json:"accountSlugs"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
// Folders are the account folders on disk now — what an upload can go
|
||||||
|
// into, and what the empty accounts screen counts, since an account.toml
|
||||||
|
// is not enough to appear until an import. Read fresh, since a folder made
|
||||||
|
// after startup is a valid target.
|
||||||
|
// A broken account.toml is reported rather than failing the whole page.
|
||||||
|
Folders []string `json:"folders"`
|
||||||
|
FoldersErr string `json:"foldersError,omitempty"`
|
||||||
// Stale reports that rules.toml on disk no longer says what the index was
|
// Stale reports that rules.toml on disk no longer says what the index was
|
||||||
// derived from — it was edited by hand — so the page can offer a retag
|
// derived from — it was edited by hand — so the page can offer a retag
|
||||||
// instead of quietly describing rules that are not the ones in force.
|
// instead of quietly describing rules that are not the ones in force.
|
||||||
@@ -215,7 +227,7 @@ func (s *Server) overview(*http.Request) (any, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := overview{Root: s.root, Configured: len(s.accounts), Accounts: []accountRow{}}
|
out := overview{Root: s.root, Accounts: []accountRow{}}
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
bal, err := s.db.Balance(a.ID)
|
bal, err := s.db.Balance(a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -236,6 +248,14 @@ func (s *Server) overview(*http.Request) (any, error) {
|
|||||||
if out.Tags, err = s.knownTags(); err != nil {
|
if out.Tags, err = s.knownTags(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
out.Folders = []string{}
|
||||||
|
if folders, err := config.LoadAccounts(s.root); err != nil {
|
||||||
|
out.FoldersErr = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, f := range folders {
|
||||||
|
out.Folders = append(out.Folders, f.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
onDisk, err := config.LoadRules(s.root)
|
onDisk, err := config.LoadRules(s.root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Stale, out.RulesErr = true, err.Error()
|
out.Stale, out.RulesErr = true, err.Error()
|
||||||
@@ -576,7 +596,7 @@ type descGroup struct {
|
|||||||
Claimed bool
|
Claimed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// rulePreview is the TUI builder's right-hand panel: what the glob would claim
|
// rulePreview is the rule builder's right-hand panel: what the glob would claim
|
||||||
// among everything still waiting for a rule, plus — when editing — what the
|
// among everything still waiting for a rule, plus — when editing — what the
|
||||||
// rule claims already, since those are tagged and an untagged-only preview
|
// rule claims already, since those are tagged and an untagged-only preview
|
||||||
// would be empty for a rule that works. Matching runs here rather than in the
|
// would be empty for a rule that works. Matching runs here rather than in the
|
||||||
@@ -1119,22 +1139,26 @@ func (s *Server) runImport(r *http.Request) (any, error) {
|
|||||||
if err := decode(r, &req); err != nil {
|
if err := decode(r, &req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return s.importAll(req.Force)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) importAll(force bool) (importJSON, error) {
|
||||||
accounts, err := config.LoadAccounts(s.root)
|
accounts, err := config.LoadAccounts(s.root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return importJSON{}, err
|
||||||
}
|
}
|
||||||
if len(accounts) == 0 {
|
if len(accounts) == 0 {
|
||||||
return nil, badRequest("no accounts found in %s (an account is a folder containing %s)",
|
return importJSON{}, badRequest("no accounts found in %s (an account is a folder containing %s)",
|
||||||
s.root, config.AccountFile)
|
s.root, config.AccountFile)
|
||||||
}
|
}
|
||||||
if err := s.reloadRules(); err != nil {
|
if err := s.reloadRules(); err != nil {
|
||||||
return nil, err
|
return importJSON{}, err
|
||||||
}
|
}
|
||||||
s.accounts = accounts
|
s.accounts = accounts
|
||||||
|
|
||||||
res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: req.Force})
|
res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: force})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return importJSON{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, added, skipped := res.Total()
|
_, added, skipped := res.Total()
|
||||||
@@ -1158,6 +1182,143 @@ func (s *Server) runImport(r *http.Request) (any, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uploadLimit caps an upload request. Statements are small — a year of PDF is
|
||||||
|
// a few hundred kilobytes — and the body is base64, a third larger than the
|
||||||
|
// files it carries.
|
||||||
|
const uploadLimit = 64 << 20
|
||||||
|
|
||||||
|
type uploadFile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data []byte `json:"data"` // base64 on the wire
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadReq struct {
|
||||||
|
Account string `json:"account"`
|
||||||
|
Files []uploadFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload saves statements into an account folder and imports them. The files
|
||||||
|
// on disk are the source of truth and the index is derived from them, so an
|
||||||
|
// upload is nothing more than putting a file where `money import` looks; the
|
||||||
|
// import that follows is the one the Import button runs.
|
||||||
|
//
|
||||||
|
// Files arrive base64 inside JSON rather than as multipart: a multipart body
|
||||||
|
// is exactly what a cross-site form can send, and the JSON-only rule is the
|
||||||
|
// only thing standing between another page and this server.
|
||||||
|
func (s *Server) upload(r *http.Request) (any, error) {
|
||||||
|
var req uploadReq
|
||||||
|
if err := decodeLimit(r, &req, uploadLimit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(req.Files) == 0 {
|
||||||
|
return nil, badRequest("no files to upload")
|
||||||
|
}
|
||||||
|
accounts, err := config.LoadAccounts(s.root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var acc *config.Account
|
||||||
|
for _, a := range accounts {
|
||||||
|
if a.Slug == req.Account {
|
||||||
|
acc = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if acc == nil {
|
||||||
|
return nil, badRequest("no account folder %q in %s (an account is a folder containing %s)",
|
||||||
|
req.Account, s.root, config.AccountFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything is checked before anything is written, so a bad file in a
|
||||||
|
// batch leaves the folder as it was rather than half uploaded.
|
||||||
|
var write []uploadFile
|
||||||
|
var same []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, f := range req.Files {
|
||||||
|
if err := checkStatementName(acc, f.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if seen[f.Name] {
|
||||||
|
return nil, badRequest("%s is in the upload twice", f.Name)
|
||||||
|
}
|
||||||
|
seen[f.Name] = true
|
||||||
|
existing, err := os.ReadFile(filepath.Join(acc.Dir, f.Name))
|
||||||
|
switch {
|
||||||
|
case err == nil && bytes.Equal(existing, f.Data):
|
||||||
|
same = append(same, f.Name)
|
||||||
|
case err == nil:
|
||||||
|
// A statement is the source of truth for what it already
|
||||||
|
// imported; replacing it under the same name is not an upload's
|
||||||
|
// decision to make.
|
||||||
|
return nil, &apiError{http.StatusConflict, fmt.Sprintf(
|
||||||
|
"%s/%s already exists with different contents; rename the file or remove the old one first",
|
||||||
|
acc.Slug, f.Name)}
|
||||||
|
case !errors.Is(err, fs.ErrNotExist):
|
||||||
|
return nil, err
|
||||||
|
default:
|
||||||
|
write = append(write, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range write {
|
||||||
|
if err := writeStatement(acc.Dir, f); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := s.importAll(false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("uploaded %d file(s) to %s", len(write), acc.Slug)
|
||||||
|
if len(same) > 0 {
|
||||||
|
msg += fmt.Sprintf(" (%d already there)", len(same))
|
||||||
|
}
|
||||||
|
out.Status = msg + " · " + out.Status
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStatementName refuses any name the importer would not read back as a
|
||||||
|
// statement of this account, and anything that is not a plain file name.
|
||||||
|
func checkStatementName(acc *config.Account, name string) error {
|
||||||
|
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\\x00") ||
|
||||||
|
name == "." || name == ".." {
|
||||||
|
return badRequest("%q is not a plain file name", name)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, ".") || name == config.AccountFile {
|
||||||
|
return badRequest("%s would be ignored by import (dotfiles and %s are not statements)",
|
||||||
|
name, config.AccountFile)
|
||||||
|
}
|
||||||
|
if len(acc.Include) > 0 && !slices.ContainsFunc(acc.Include, func(p string) bool { return glob.Match(p, name) }) {
|
||||||
|
return badRequest("%s does not match %s's include patterns (%s), so import would ignore it",
|
||||||
|
name, acc.Slug, strings.Join(acc.Include, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStatement writes through a dotfile and renames it into place, so a
|
||||||
|
// concurrent `money import` never reads half a statement.
|
||||||
|
func writeStatement(dir string, f uploadFile) error {
|
||||||
|
path := filepath.Join(dir, f.Name)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name()) // a no-op once renamed
|
||||||
|
if _, err := tmp.Write(f.Data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// retag is `money retag`: both halves of what rules.toml decides, re-derived
|
// retag is `money retag`: both halves of what rules.toml decides, re-derived
|
||||||
// from the file as it is on disk now.
|
// from the file as it is on disk now.
|
||||||
func (s *Server) retag(*http.Request) (any, error) {
|
func (s *Server) retag(*http.Request) (any, error) {
|
||||||
|
|||||||
@@ -386,3 +386,43 @@ func TestPageIsServed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A paired leg is spoken for, so it is neither listed as untagged nor offered
|
||||||
|
// to the rule builder; an unpaired leg is untagged like anything else, which
|
||||||
|
// is how it gets noticed.
|
||||||
|
func TestPairedLegsAreNotUntagged(t *testing.T) {
|
||||||
|
_, h := newTestServer(t, `
|
||||||
|
[[transfer]]
|
||||||
|
from_account = "checking"
|
||||||
|
from_desc = "*TO SAVINGS*"
|
||||||
|
to_account = "savings"
|
||||||
|
to_desc = "*FROM CHECKING*"
|
||||||
|
`,
|
||||||
|
fixtureTxn{"checking", "2026-02-01", "TRANSFER TO SAVINGS", -1000},
|
||||||
|
fixtureTxn{"savings", "2026-02-01", "TRANSFER FROM CHECKING", 1000},
|
||||||
|
fixtureTxn{"checking", "2026-02-20", "TRANSFER TO SAVINGS", -2000},
|
||||||
|
fixtureTxn{"checking", "2026-02-05", "LIDL SOFIA", -500},
|
||||||
|
)
|
||||||
|
|
||||||
|
var txns struct{ Rows []txnRow }
|
||||||
|
call(t, h, "GET", "/api/transactions?untagged=1", nil, http.StatusOK, &txns)
|
||||||
|
var seen []string
|
||||||
|
for _, r := range txns.Rows {
|
||||||
|
seen = append(seen, r.Date+" "+r.Description)
|
||||||
|
}
|
||||||
|
want := "2026-02-20 TRANSFER TO SAVINGS|2026-02-05 LIDL SOFIA"
|
||||||
|
if strings.Join(seen, "|") != want {
|
||||||
|
t.Errorf("untagged = %v, want only the unpaired leg and the shopping", seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
var p rulePreviewJSON
|
||||||
|
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*"}, http.StatusOK, &p)
|
||||||
|
for _, r := range p.Rows {
|
||||||
|
if r.Description == "TRANSFER FROM CHECKING" {
|
||||||
|
t.Error("the arriving leg of a matched transfer is still offered for tagging")
|
||||||
|
}
|
||||||
|
if r.Description == "TRANSFER TO SAVINGS" && r.Count != 1 {
|
||||||
|
t.Errorf("departing leg offered %d times, want only the unpaired one", r.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+106
-15
@@ -140,8 +140,8 @@ async function loadOverview() {
|
|||||||
fillDatalists();
|
fillDatalists();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The account and tag fields complete natively, from the lists the TUI
|
// The account and tag fields complete natively, from lists the server
|
||||||
// completes against. (transfer) is never among the tags: it is a label.
|
// builds. (transfer) is never among the tags: it is a label.
|
||||||
function fillDatalists() {
|
function fillDatalists() {
|
||||||
const o = state.overview;
|
const o = state.overview;
|
||||||
if (!o) return;
|
if (!o) return;
|
||||||
@@ -186,8 +186,8 @@ function go(id) {
|
|||||||
function show() {
|
function show() {
|
||||||
const id = location.hash.slice(1) || 'accounts';
|
const id = location.hash.slice(1) || 'accounts';
|
||||||
const view = VIEWS.find((v) => v.id === id) || VIEWS[0];
|
const view = VIEWS.find((v) => v.id === id) || VIEWS[0];
|
||||||
// Leaving the builder mid-edit abandons the edit, as esc does in the TUI: a
|
// Leaving the builder mid-edit abandons the edit, as esc does: a new rule
|
||||||
// new rule must not start out as a copy of the one being edited.
|
// must not start out as a copy of the one being edited.
|
||||||
if (view.id !== 'rule' && state.rule.editing) state.rule = blankRule();
|
if (view.id !== 'rule' && state.rule.editing) state.rule = blankRule();
|
||||||
state.view = view.id;
|
state.view = view.id;
|
||||||
renderNav();
|
renderNav();
|
||||||
@@ -203,22 +203,31 @@ async function refreshAll() {
|
|||||||
|
|
||||||
// --- import and retag -----------------------------------------------------
|
// --- import and retag -----------------------------------------------------
|
||||||
|
|
||||||
async function runImport(force) {
|
function runImport(force) {
|
||||||
if (state.busy) return;
|
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;
|
||||||
state.busy = true;
|
state.busy = true;
|
||||||
const btn = document.getElementById('import');
|
const btn = document.getElementById('import');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Importing…';
|
btn.textContent = 'Importing…';
|
||||||
setStatus('importing… parsing PDF statements can take a while');
|
setStatus('importing… parsing PDF statements can take a while');
|
||||||
try {
|
try {
|
||||||
const res = await api('POST', '/api/import', { force: !!force });
|
const res = await request();
|
||||||
state.status = res.status;
|
state.status = res.status;
|
||||||
state.error = res.failed ? `${res.failed} file(s) failed to import` : '';
|
state.error = res.failed ? `${res.failed} file(s) failed to import` : '';
|
||||||
renderBanners();
|
renderBanners();
|
||||||
showImportDetails(res);
|
showImportDetails(res);
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e);
|
setError(e);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -255,17 +264,19 @@ async function retag() {
|
|||||||
|
|
||||||
function mountAccounts(main) {
|
function mountAccounts(main) {
|
||||||
const body = h('div');
|
const body = h('div');
|
||||||
main.append(h('h2', {}, 'Accounts'), body);
|
const upload = h('div');
|
||||||
|
main.append(h('h2', {}, 'Accounts'), body, upload);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!state.overview) await loadOverview();
|
if (!state.overview) await loadOverview();
|
||||||
const o = state.overview;
|
const o = state.overview;
|
||||||
|
upload.replaceChildren(uploader(o));
|
||||||
if (!o.accounts.length) {
|
if (!o.accounts.length) {
|
||||||
// An account.toml is not enough on its own: folders reach the index
|
// An account.toml is not enough on its own: folders reach the index
|
||||||
// only through an import, so say that rather than show nothing.
|
// only through an import, so say that rather than show nothing.
|
||||||
body.replaceChildren(h('div', { class: 'panel empty' }, o.configured
|
body.replaceChildren(h('div', { class: 'panel empty' }, o.folders.length
|
||||||
? `No accounts imported yet.\n\n${o.configured} account folder(s) configured in ${o.root}.\nPress Import to read their statements.`
|
? `No accounts imported yet.\n\n${o.folders.length} account folder(s) in ${o.root}.\nAdd statements below, or put them in the folders and press Import.`
|
||||||
: `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, drop statements in, then press Import.`));
|
: `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, then add statements to it here.`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
|
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
|
||||||
@@ -282,6 +293,87 @@ function mountAccounts(main) {
|
|||||||
return { refresh };
|
return { refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
function uploader(o) {
|
||||||
|
const panel = h('div', { class: 'panel upload' }, h('h3', {}, 'Add statements'));
|
||||||
|
if (!o.folders.length) {
|
||||||
|
panel.append(h('div', { class: 'muted' }, o.foldersError
|
||||||
|
? `Account folders do not load: ${o.foldersError}`
|
||||||
|
: `There is no account folder to add statements to. Create one in ${o.root} with an account.toml.`));
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
const account = h('select', { 'aria-label': 'Account to add statements to' },
|
||||||
|
o.folders.map((f) => h('option', { value: f }, f)));
|
||||||
|
// Default to the account being browsed, since that is usually the one.
|
||||||
|
account.value = o.folders.includes(state.filter.account) ? state.filter.account : o.folders[0];
|
||||||
|
|
||||||
|
const picker = h('input', {
|
||||||
|
type: 'file', multiple: true, hidden: true,
|
||||||
|
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 zone = h('div', {
|
||||||
|
class: 'dropzone',
|
||||||
|
tabindex: 0,
|
||||||
|
role: 'button',
|
||||||
|
onclick: () => picker.click(),
|
||||||
|
onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); } },
|
||||||
|
ondragover: (e) => { e.preventDefault(); zone.classList.add('over'); },
|
||||||
|
ondragleave: () => zone.classList.remove('over'),
|
||||||
|
ondrop: (e) => { e.preventDefault(); zone.classList.remove('over'); choose(e.dataTransfer.files); },
|
||||||
|
}, 'Drop statement files here, or click to choose');
|
||||||
|
|
||||||
|
function choose(list) {
|
||||||
|
files = Array.from(list || []);
|
||||||
|
send.disabled = !files.length;
|
||||||
|
chosen.textContent = files.length
|
||||||
|
? files.map((f) => `${f.name} (${Math.ceil(f.size / 1024)} KB)`).join(' · ')
|
||||||
|
: 'No files chosen';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
send.disabled = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
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([]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e);
|
||||||
|
} finally {
|
||||||
|
send.disabled = !files.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.append(
|
||||||
|
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. ' +
|
||||||
|
'A file with the same name and different contents is refused rather than replaced.'));
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64 encodes a file for the JSON body. Chunked, because spreading a whole
|
||||||
|
// statement into String.fromCharCode overflows the call stack.
|
||||||
|
async function base64(file) {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
let bin = '';
|
||||||
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||||
|
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
|
||||||
// --- shared scope toolbar ---------------------------------------------------
|
// --- shared scope toolbar ---------------------------------------------------
|
||||||
|
|
||||||
function scopeToolbar(onChange) {
|
function scopeToolbar(onChange) {
|
||||||
@@ -481,7 +573,7 @@ function textInput(id, value, placeholder, oninput, list) {
|
|||||||
function mountRuleBuilder(main) {
|
function mountRuleBuilder(main) {
|
||||||
const r = state.rule;
|
const r = state.rule;
|
||||||
const editing = r.editing;
|
const editing = r.editing;
|
||||||
// A new rule starts on the account being browsed, as in the TUI.
|
// A new rule starts on the account being browsed.
|
||||||
if (!editing && !r.account && state.filter.account) r.account = state.filter.account;
|
if (!editing && !r.account && state.filter.account) r.account = state.filter.account;
|
||||||
|
|
||||||
const summary = h('div', { class: 'summary' });
|
const summary = h('div', { class: 'summary' });
|
||||||
@@ -857,9 +949,8 @@ function mountTransfers(main) {
|
|||||||
|
|
||||||
// --- keyboard -------------------------------------------------------------
|
// --- keyboard -------------------------------------------------------------
|
||||||
|
|
||||||
// The TUI's keys, where they do not fight the browser. A builder is a form,
|
// Single-key shortcuts, where they do not fight the browser. A builder is a
|
||||||
// so outside of chords its keys belong to the field being typed in — exactly
|
// form, so outside of chords its keys belong to the field being typed in.
|
||||||
// the rule the TUI keeps.
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
const t = e.target;
|
const t = e.target;
|
||||||
const typing = t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement;
|
const typing = t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement;
|
||||||
|
|||||||
@@ -176,6 +176,26 @@ td.actions button { padding: 1px 8px; font-size: 12px; }
|
|||||||
.kept { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
.kept { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||||
.preview { max-height: calc(100vh - 170px); }
|
.preview { max-height: calc(100vh - 170px); }
|
||||||
|
|
||||||
|
.upload { margin-top: 16px; padding: 14px; overflow: visible; }
|
||||||
|
.upload h3 { font-size: 14px; margin: 0 0 10px; }
|
||||||
|
.upload .toolbar { margin-bottom: 10px; }
|
||||||
|
.dropzone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 22px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dropzone:hover, .dropzone:focus, .dropzone.over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.upload .chosen { margin-top: 8px; font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
.upload .hint { margin-top: 8px; font-size: 12px; }
|
||||||
|
|
||||||
.import-files { margin-top: 6px; font-size: 13px; }
|
.import-files { margin-top: 6px; font-size: 13px; }
|
||||||
.import-files li { font-family: var(--mono); }
|
.import-files li { font-family: var(--mono); }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.petrovv.com/nikola/money/internal/config"
|
||||||
|
"git.petrovv.com/nikola/money/internal/parser"
|
||||||
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
|
"git.petrovv.com/nikola/money/internal/transfers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The upload tests are about where files land and what is refused, not about
|
||||||
|
// any bank's layout, so they read "date,description,amount" with a header.
|
||||||
|
func init() {
|
||||||
|
parser.Register("webtest", func(acc *config.Account) (parser.Parser, error) {
|
||||||
|
return csvParser{digits: acc.Digits()}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvParser struct{ digits int }
|
||||||
|
|
||||||
|
func (p csvParser) Parse(path string, _ *config.Account) ([]parser.RawTxn, error) {
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var txns []parser.RawTxn
|
||||||
|
for i, line := range strings.Split(strings.TrimSpace(string(body)), "\n")[1:] {
|
||||||
|
f := strings.Split(line, ",")
|
||||||
|
if len(f) != 3 {
|
||||||
|
return nil, fmt.Errorf("row %d: want 3 fields", i+2)
|
||||||
|
}
|
||||||
|
amount, err := parser.ParseAmount(f[2], ".", "", p.digits)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
txns = append(txns, parser.RawTxn{Date: f[0], Description: f[1], AmountMinor: amount})
|
||||||
|
}
|
||||||
|
return txns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUploadServer builds a server over a data root with one empty account
|
||||||
|
// folder, as `money serve` sees it before the first import.
|
||||||
|
func newUploadServer(t *testing.T, accountTOML string) (string, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "checking")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, config.AccountFile), []byte(accountTOML), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := store.Open(config.IndexPath(root))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
accounts, err := config.LoadAccounts(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := New(root, db, accounts, rules.New(loaded), transfers.New(loaded))
|
||||||
|
s.now = func() time.Time { return time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) }
|
||||||
|
return dir, s.Handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkingTOML = "currency = \"EUR\"\nparser = \"webtest\"\n"
|
||||||
|
|
||||||
|
const statement = "date,description,amount\n2026-02-01,LIDL SOFIA,-12.50\n2026-02-03,SALARY,1000.00\n"
|
||||||
|
|
||||||
|
func TestUploadSavesAndImports(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML)
|
||||||
|
req := uploadReq{Account: "checking", Files: []uploadFile{{Name: "2026-02.csv", Data: []byte(statement)}}}
|
||||||
|
|
||||||
|
var res importJSON
|
||||||
|
call(t, h, "POST", "/api/upload", req, http.StatusOK, &res)
|
||||||
|
if !strings.Contains(res.Status, "uploaded 1 file(s) to checking") || !strings.Contains(res.Status, "2 new") {
|
||||||
|
t.Errorf("status = %q", res.Status)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(filepath.Join(dir, "2026-02.csv")); err != nil || string(got) != statement {
|
||||||
|
t.Errorf("file on disk = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
var txns struct{ Rows []txnRow }
|
||||||
|
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||||||
|
if len(txns.Rows) != 2 {
|
||||||
|
t.Errorf("index holds %d rows, want the 2 uploaded", len(txns.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same file again is not an error, and adds nothing.
|
||||||
|
call(t, h, "POST", "/api/upload", req, http.StatusOK, &res)
|
||||||
|
if !strings.Contains(res.Status, "uploaded 0 file(s)") || !strings.Contains(res.Status, "1 already there") {
|
||||||
|
t.Errorf("re-upload status = %q", res.Status)
|
||||||
|
}
|
||||||
|
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
|
||||||
|
if len(txns.Rows) != 2 {
|
||||||
|
t.Errorf("re-upload left %d rows, want 2", len(txns.Rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A statement already in the folder is the source of truth for what it
|
||||||
|
// imported, so an upload never replaces one with different contents.
|
||||||
|
func TestUploadNeverReplacesAStatement(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML)
|
||||||
|
path := filepath.Join(dir, "2026-02.csv")
|
||||||
|
if err := os.WriteFile(path, []byte(statement), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := uploadReq{Account: "checking", Files: []uploadFile{{Name: "2026-02.csv", Data: []byte("date,description,amount\n")}}}
|
||||||
|
call(t, h, "POST", "/api/upload", req, http.StatusConflict, nil)
|
||||||
|
if got, _ := os.ReadFile(path); string(got) != statement {
|
||||||
|
t.Errorf("statement was overwritten: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A batch is checked as a whole before anything is written, and nothing may
|
||||||
|
// land outside the account folder or where import would not read it back.
|
||||||
|
func TestUploadRefusesBadNames(t *testing.T) {
|
||||||
|
dir, h := newUploadServer(t, checkingTOML+"include = [\"*.csv\"]\n")
|
||||||
|
for _, bad := range []string{"../escape.csv", "sub/x.csv", ".hidden.csv", config.AccountFile, "notes.txt", ""} {
|
||||||
|
req := uploadReq{Account: "checking", Files: []uploadFile{
|
||||||
|
{Name: "good.csv", Data: []byte(statement)},
|
||||||
|
{Name: bad, Data: []byte(statement)},
|
||||||
|
}}
|
||||||
|
call(t, h, "POST", "/api/upload", req, http.StatusBadRequest, nil)
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if len(entries) != 1 {
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
t.Errorf("folder holds %v, want only %s", names, config.AccountFile)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "escape.csv")); err == nil {
|
||||||
|
t.Error("a file escaped the account folder")
|
||||||
|
}
|
||||||
|
|
||||||
|
call(t, h, "POST", "/api/upload", uploadReq{Account: "nope", Files: []uploadFile{{Name: "a.csv"}}},
|
||||||
|
http.StatusBadRequest, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multipart is what a cross-site form can send, so it is refused like any
|
||||||
|
// other non-JSON write.
|
||||||
|
func TestUploadRefusesMultipart(t *testing.T) {
|
||||||
|
_, h := newUploadServer(t, checkingTOML)
|
||||||
|
r := httptest.NewRequest("POST", "/api/upload", strings.NewReader("--x\r\n"))
|
||||||
|
r.Header.Set("Content-Type", "multipart/form-data; boundary=x")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("status %d, want %d", w.Code, http.StatusUnsupportedMediaType)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user