Wire admin add-account endpoint into the UI #46

Merged
padreug merged 12 commits from feat/add-account-ui into main 2026-06-18 10:03:10 +00:00
Showing only changes of commit 788a9998f6 - Show all commits

fix(fava): escape string metadata + make Open currencies optional

add_account wrote free-text metadata values straight into the ledger
source via /api/source with no escaping — an unescaped quote or newline
in an admin-supplied description would corrupt the Beancount file (or
forge extra metadata lines). Escape backslash/quote/newline per the
tokenizer's cunescape rules (verified round-trip through beancount's
parser). Also make the currency constraint list optional so an Open
directive can be written unconstrained (currencies are an optional
part of the directive, not required).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Padreug 2026-06-15 20:31:27 +02:00

View file

@ -44,6 +44,22 @@ def _infer_target_file(account_name: str) -> str:
return "accounts/chart.beancount" return "accounts/chart.beancount"
def _escape_beancount_string(value: str) -> str:
"""Escape a value for safe inclusion in a Beancount string literal.
Beancount's tokenizer unescapes \\", \\n, \\t, \\r, \\\\ etc. (tokens.c
cunescape). Unescaped quotes or newlines in free-text metadata written
straight into the ledger source would corrupt the file, so escape the
backslash first (to keep it round-tripping) then quotes and newlines.
"""
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
)
class FavaClient: class FavaClient:
""" """
Async client for Fava REST API. Async client for Fava REST API.
@ -1544,7 +1560,7 @@ class FavaClient:
async def add_account( async def add_account(
self, self,
account_name: str, account_name: str,
currencies: list[str], currencies: Optional[list[str]] = None,
opening_date: Optional[date] = None, opening_date: Optional[date] = None,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
target_file: Optional[str] = None, target_file: Optional[str] = None,
@ -1642,19 +1658,23 @@ class FavaClient:
lines = source.split('\n') lines = source.split('\n')
insert_index = len(lines) insert_index = len(lines)
# Step 4: Format Open directive as Beancount text # Step 4: Format Open directive as Beancount text.
currencies_str = ", ".join(currencies) # Currencies are an optional constraint on an Open
open_lines = [ # directive; when none are given the account accepts
"", # any commodity.
f"{opening_date.isoformat()} open {account_name} {currencies_str}" open_directive = f"{opening_date.isoformat()} open {account_name}"
] if currencies:
open_directive += f" {', '.join(currencies)}"
open_lines = ["", open_directive]
# Add metadata if provided # Add metadata if provided
if metadata: if metadata:
for key, value in metadata.items(): for key, value in metadata.items():
# Format metadata with proper indentation # Format metadata with proper indentation
if isinstance(value, str): if isinstance(value, str):
open_lines.append(f' {key}: "{value}"') open_lines.append(
f' {key}: "{_escape_beancount_string(value)}"'
)
else: else:
open_lines.append(f' {key}: {value}') open_lines.append(f' {key}: {value}')