From c95cece7be436b72ecf26fc9cc2b29ef16079ac2 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Mon, 14 Apr 2025 00:56:45 +0200 Subject: [PATCH] Enhance new DatBlock structure and apply to template, general overhaul. --- src/ledgplom/http.py | 164 +++--- src/ledgplom/ledger.py | 774 ++++++++++++++------------- src/templates/_base.tmpl | 45 +- src/templates/_macros.tmpl | 298 +++++------ src/templates/balance.tmpl | 121 +++-- src/templates/edit_raw.tmpl | 19 +- src/templates/edit_structured.tmpl | 235 ++++---- src/templates/ledger_raw.tmpl | 25 +- src/templates/ledger_structured.tmpl | 46 +- 9 files changed, 914 insertions(+), 813 deletions(-) diff --git a/src/ledgplom/http.py b/src/ledgplom/http.py index 65a6894..a57690b 100644 --- a/src/ledgplom/http.py +++ b/src/ledgplom/http.py @@ -2,11 +2,10 @@ # standard libs from pathlib import Path -from typing import Any, Optional +from typing import Any # non-standard libs from plomlib.web import PlomHttpHandler, PlomHttpServer, PlomQueryMap -from ledgplom.ledger import ( - Account, BookingLine, DatLine, IntroLine, Ledger, TransferLine) +from ledgplom.ledger import Account, DatBlock, DEFAULT_INDENT, Ledger _SERVER_PORT = 8084 @@ -15,11 +14,11 @@ _PATH_TEMPLATES = Path('templates') _PREFIX_LEDGER = 'ledger_' _PREFIX_EDIT = 'edit_' _PREFIX_FILE = 'file_' -_TOK_STRUCT = 'structured' +_TOK_STRUCTURED = 'structured' _TOK_RAW = 'raw' -_PAGENAME_EDIT_STRUCT = f'{_PREFIX_EDIT}{_TOK_STRUCT}' +_PAGENAME_EDIT_STRUCTURED = f'{_PREFIX_EDIT}{_TOK_STRUCTURED}' _PAGENAME_EDIT_RAW = f'{_PREFIX_EDIT}{_TOK_RAW}' -_PAGENAME_LEDGER_STRUCT = f'{_PREFIX_LEDGER}{_TOK_STRUCT}' +_PAGENAME_LEDGER_STRUCTURED = f'{_PREFIX_LEDGER}{_TOK_STRUCTURED}' _PAGENAME_LEDGER_RAW = f'{_PREFIX_LEDGER}{_TOK_RAW}' @@ -61,9 +60,9 @@ class _Handler(PlomHttpHandler): def post_edit(self) -> Path: """Based on postvars, edit targeted Booking.""" - booking = self.server.ledger.bookings[int(self.path_toks[2])] + old_id = int(self.path_toks[2]) new_lines = [] - if self.pagename == _PAGENAME_EDIT_STRUCT: + if self.pagename == _PAGENAME_EDIT_STRUCTURED: line_keys = self.postvars.keys_prefixed('line_') lineno_to_inputs: dict[int, list[str]] = {} for key in line_keys: @@ -73,49 +72,38 @@ class _Handler(PlomHttpHandler): lineno_to_inputs[lineno] = [] lineno_to_inputs[lineno] += [toks[2]] for lineno, input_names in lineno_to_inputs.items(): - line_d = {key: self.postvars.first(f'line_{lineno}_{key}') + inputs = {key: self.postvars.first(f'line_{lineno}_{key}') for key in input_names} - booking_line: Optional[BookingLine] = None - if 'date' in line_d: - booking_line = IntroLine( - line_d['date'], - line_d['target']) - elif 'account' in line_d: - booking_line = TransferLine( - line_d['account'], - line_d['amount'], - line_d['currency']) - if booking_line: - new_lines += [booking_line.to_dat_line(line_d['comment'])] + if 0 == lineno: + code = f'{inputs["date"]} {inputs["target"]}' else: - new_lines += [DatLine(line_d['error'], line_d['comment'], - add_indent=True)] - else: # edit_raw - new_lines += [DatLine.from_raw(line) for line - in self.postvars.first('booking').splitlines()] - new_id = self.server.ledger.rewrite_booking(booking.id_, new_lines) - return Path('/bookings').joinpath(f'{new_id}') + code = f'{DEFAULT_INDENT}{inputs["account"]} ' +\ + f'{inputs["amount"]} {inputs["currency"]}' + new_lines += [f'{code} ; {inputs["comment"]}'] + new_lines += self.postvars.first('raw_lines').splitlines() + new_id = self.server.ledger.rewrite_block(old_id, new_lines) + return Path('/').joinpath(self.pagename).joinpath(f'{new_id}') def post_ledger_action(self) -> Path: """Call .server.ledger.(move|copy|add_empty_new)_booking.""" if 'add_booking' in self.postvars.as_dict: - id_ = self.server.ledger.add_empty_booking() + id_ = self.server.ledger.add_empty_block() else: keys_prefixed = self.postvars.keys_prefixed(_PREFIX_LEDGER) action, id_str = keys_prefixed[0].split('_', maxsplit=2)[1:] id_ = int(id_str) if action.startswith('move'): - id_ = self.server.ledger.move_booking(id_, action == 'moveup') - return Path(self.path).joinpath(f'#{id_}') - id_ = self.server.ledger.copy_booking(id_) - return Path(_PAGENAME_EDIT_STRUCT).joinpath(f'{id_}') + id_ = self.server.ledger.move_block(id_, action == 'moveup') + return Path(self.path).joinpath(f'#block_{id_}') + id_ = self.server.ledger.copy_block(id_) + return Path(self.path).joinpath(f'#block_{id_}') def do_GET(self) -> None: """"Route GET requests to respective handlers.""" # pylint: disable=invalid-name - if self.pagename == 'bookings': + if self.pagename == 'blocks': self.redirect( - Path('/').joinpath(_PAGENAME_EDIT_STRUCT + Path('/').joinpath(_PAGENAME_EDIT_STRUCTURED ).joinpath(self.path_toks[2])) return ctx = {'tainted': self.server.ledger.tainted, 'path': self.path} @@ -129,66 +117,76 @@ class _Handler(PlomHttpHandler): self.get_ledger(ctx, False) def get_balance(self, ctx) -> None: - """Display tree of calculated Accounts over .bookings[:up_incl+1].""" + """Display tree of calculated Accounts over blocks up_incl+1.""" id_ = int(self.params.first('up_incl') - or str(len(self.server.ledger.bookings) - 1)) + or str(len(self.server.ledger.blocks) - 1)) roots = [ac for ac in self.server.ledger.accounts.values() if not ac.parent] ctx['roots'] = sorted(roots, key=lambda r: r.basename) - ctx['valid'] = self.server.ledger.bookings_valid_up_incl(id_) - ctx['booking'] = self.server.ledger.bookings[id_] + ctx['valid'] = self.server.ledger.blocks_valid_up_incl(id_) + ctx['block'] = self.server.ledger.blocks[id_] ctx['path_up_incl'] = f'{self.path_toks[1]}?up_incl=' self._send_rendered('balance', ctx) def get_edit(self, ctx, raw: bool) -> None: """Display edit form for individual Booking.""" + + def make_balance_roots(b: DatBlock) -> list[dict[str, Any]]: + acc_changes = b.booking.account_changes if b.booking else {} + observed_tree: list[dict[str, Any]] = [] + for full_path in sorted(acc_changes.keys()): + parent_children: list[dict[str, Any]] = observed_tree + for path, _ in Account.path_to_steps(full_path): + already_registered = False + for child in [n for n in parent_children + if path == n['name']]: + parent_children = child['children'] + already_registered = True + break + if already_registered: + continue + pre = self.server.ledger.accounts[path].get_wealth(id_ - 1) + post = self.server.ledger.accounts[path].get_wealth(id_) + targeted = full_path == path + diff = { + cur: amt for cur, amt in (post - pre).moneys.items() + if amt != 0 + or (targeted and cur in acc_changes[full_path].moneys)} + if diff or targeted: + displayed_currs = set(diff.keys()) + for wealth in pre, post: + wealth.ensure_currencies(displayed_currs) + wealth.purge_currencies_except(displayed_currs) + node: dict[str, Any] = { + 'name': path, + 'direct_target': targeted, + 'wealth_before': pre.moneys, + 'wealth_diff': diff, + 'wealth_after': post.moneys, + 'children': []} + parent_children += [node] + parent_children = node['children'] + return observed_tree + id_ = int(self.path_toks[2]) - booking = self.server.ledger.bookings[id_] - observed_tree: list[dict[str, Any]] = [] - for full_path in sorted(booking.account_changes.keys()): - parent_children: list[dict[str, Any]] = observed_tree - for path, _ in Account.path_to_steps(full_path): - already_registered = False - for child in [n for n in parent_children if path == n['name']]: - parent_children = child['children'] - already_registered = True - break - if already_registered: - continue - before = self.server.ledger.accounts[path].get_wealth(id_ - 1) - after = self.server.ledger.accounts[path].get_wealth(id_) - direct_target = full_path == path - diff = { - cur: amt for cur, amt in (after - before).moneys.items() - if amt != 0 - or (direct_target - and cur in booking.account_changes[full_path].moneys)} - if diff or direct_target: - displayed_currencies = set(diff.keys()) - for wealth in before, after: - wealth.ensure_currencies(displayed_currencies) - wealth.purge_currencies_except(displayed_currencies) - node: dict[str, Any] = { - 'name': path, - 'direct_target': direct_target, - 'wealth_before': before.moneys, - 'wealth_diff': diff, - 'wealth_after': after.moneys, - 'children': []} - parent_children += [node] - parent_children = node['children'] - ctx['roots'] = observed_tree - ctx['id'] = id_ - ctx['dat_lines'] = [dl if raw else dl.as_dict for dl in booking.lines] - ctx['sink_error'] = booking.sink_error - ctx['valid'] = self.server.ledger.bookings_valid_up_incl(id_) - if not raw: + block = self.server.ledger.blocks[id_] + ctx['block'] = block + ctx['valid'] = self.server.ledger.blocks_valid_up_incl(id_) + ctx['roots'] = make_balance_roots(block) + if raw: + self._send_rendered(_PAGENAME_EDIT_RAW, ctx) + else: + ctx['raw_gap_lines'] = [dl.raw for dl in block.gap.lines] ctx['all_accounts'] = sorted(self.server.ledger.accounts.keys()) - self._send_rendered( - _PAGENAME_EDIT_RAW if raw else _PAGENAME_EDIT_STRUCT, ctx) + ctx['booking_lines'] = [] + if block.booking: + ctx['booking_lines'] += [block.booking.intro_line.as_dict] + ctx['booking_lines'] += [tf_line.as_dict for tf_line + in block.booking.transfer_lines] + self._send_rendered(_PAGENAME_EDIT_STRUCTURED, ctx) def get_ledger(self, ctx: dict[str, Any], raw: bool) -> None: """Display ledger of all Bookings.""" - ctx['dat_lines'] = self.server.ledger.dat_lines + ctx['blocks'] = self.server.ledger.blocks self._send_rendered( - _PAGENAME_LEDGER_RAW if raw else _PAGENAME_LEDGER_STRUCT, ctx) + _PAGENAME_LEDGER_RAW if raw else _PAGENAME_LEDGER_STRUCTURED, ctx) diff --git a/src/ledgplom/ledger.py b/src/ledgplom/ledger.py index 552987b..669a8ba 100644 --- a/src/ledgplom/ledger.py +++ b/src/ledgplom/ledger.py @@ -5,29 +5,14 @@ from abc import ABC, abstractmethod from datetime import date as dt_date from decimal import Decimal, InvalidOperation as DecimalInvalidOperation from pathlib import Path -from typing import Any, Iterator, Optional, Self +from typing import Any, Generic, Iterator, Optional, Self, TypeVar -_PREFIX_DEF = '#def ' -_DEFAULT_INDENT = 2 * ' ' - +TypeDatLine = TypeVar('TypeDatLine', bound='_DatLine') -class _Dictable: - """Line abstraction featuring .as_dict property.""" - dictables: set[str] = set() - @property - def as_dict(self) -> dict[str, Any]: - """Return as JSON-ready dict attributes listed in .dictables.""" - d = {} - for name in self.dictables: - value = getattr(self, name) - if hasattr(value, 'as_dict'): - value = value.as_dict - elif not isinstance(value, (str, int)): - value = str(value) - d[name] = value - return d +_PREFIX_DEF = '#def ' +DEFAULT_INDENT = 2 * ' ' class _Wealth(): @@ -127,36 +112,44 @@ class Account: yield rebuilt_path, step_name -class DatLine(_Dictable): +class _DatLine: """Line of .dat file parsed into comments and machine-readable data.""" - dictables = {'booked', 'code', 'comment', 'error', 'is_intro'} def __init__( self, code: str = '', comment: str = '', - add_indent: bool = False ) -> None: + self._code_read = code self.comment = comment - self.code = f'{_DEFAULT_INDENT}{code}' if add_indent else code - self.raw = self.code + ' ; '.join([''] + [s for s in [self.comment] - if s]) - self.booking: Optional['_Booking'] = None - self.booked: Optional[BookingLine] = None - self.prev: Optional[DatLine] = None - def copy_unbooked(self) -> 'DatLine': - """Create DatLine of .code and .comment, but no _Booking ties yet.""" - return DatLine(self.code, self.comment) + @property + def code(self) -> str: + """Return collected code (re-generate by subclasses for writing).""" + return self._code_read + + @property + def raw(self) -> str: + """Return as how to be written in .dat file's text content.""" + return self.code + ' ; '.join([''] + [s for s in [self.comment] if s]) + + def copy(self) -> Self: + """Create new _DatLine of same .code and .comment.""" + return self.__class__(self.code, self.comment) @classmethod def from_raw(cls, line: str) -> Self: - """Parse line into new DatLine.""" + """Parse line into new _DatLine.""" halves = [t.rstrip() for t in line.split(';', maxsplit=1)] comment = halves[1].lstrip() if len(halves) > 1 else '' code = halves[0] return cls(code, comment) + @classmethod + def from_subclass(cls, line: '_DatLine') -> Self: + """Devolve from subclassed line into cls.""" + return cls(line.code, line.comment) + @property def comment_instructions(self) -> dict[str, str]: """Parse .comment into Account modification instructions.""" @@ -170,264 +163,201 @@ class DatLine(_Dictable): instructions[account_name] = desc return instructions - @property - def comment_in_ledger(self) -> str: - """What to show in structured ledger view (no instructions).""" - return '' if len(self.comment_instructions) > 0 else self.comment - @property - def is_intro(self) -> bool: - """Return if intro line of a _Booking.""" - return isinstance(self.booked, IntroLine) - - @property - def booking_id(self) -> int: - """If .booking, its .booking_id, else -1.""" - return self.booking.id_ if self.booking else -1 - - @property - def error(self) -> str: - """Return error if registered on attempt to parse into BookingLine.""" - return '; '.join(self.booked.errors) if self.booked else '' - - @property - def raw_nbsp(self) -> str: - """Return .raw but ensure whitespace as  , and at least one.""" - if not self.raw: - return ' ' - return self.raw.replace(' ', ' ') +class _DatLineSubclass(_DatLine, ABC): + @classmethod + @abstractmethod + def from_dat(cls, dat_line: '_DatLine') -> Self: + """Evolve from mere dat_line into subclass.""" -class _LinesBlock: - """Represents either _Booking or a gap between bookings.""" - def __init__(self, lines: list[DatLine]) -> None: - self.lines = lines - self._next_block: Optional[_LinesBlock] = None - self._prev_block: Optional[_LinesBlock] = None +class _GapLine(_DatLineSubclass): - @property - def _block_id(self) -> int: - count = -1 - block_iterated: Optional[_LinesBlock] = self - while block_iterated: - block_iterated = block_iterated.prev_block - count += 1 - return count - - @property - def start_block(self) -> '_LinesBlock': - """For chain surrounding self, get first item.""" - block_iterated = self - while True: - if not block_iterated.prev_block: - return block_iterated - block_iterated = block_iterated.prev_block + @classmethod + def from_dat(cls, dat_line: _DatLine) -> Self: + return cls('', dat_line.comment) - @property - def last_block(self) -> '_LinesBlock': - """For chain surrounding self, get last item.""" - block_iterated = self - while True: - if not block_iterated.next_block: - return block_iterated - block_iterated = block_iterated.next_block - - def _neighbor_block( - self, - new_this_block: Optional['_LinesBlock'], - this: str, - that: str, - ) -> None: - if (old_this_block := getattr(self, f'_{this}_block')): - setattr(old_this_block, f'_{that}_block', None) - if new_this_block: - if (new_this_that := getattr(new_this_block, f'_{that}_block')): - setattr(new_this_that, f'_{this}_block', None) - setattr(new_this_block, f'_{that}_block', self) - setattr(self, f'_{this}_block', new_this_block) - @property - def next_block(self) -> Optional['_LinesBlock']: - """Next block in chain.""" - return self._next_block +class _BookingLine(_DatLineSubclass): + """Parsed _DatLine belonging to a _Booking.""" + dictables = {'comment', 'errors'} - @next_block.setter - def next_block(self, new_next_block: Optional['_LinesBlock']) -> None: - self._neighbor_block(new_next_block, 'next', 'prev') + def __init__(self, comment, errors: Optional[list[str]]) -> None: + super().__init__('', comment) + self._errors: list[str] = errors if errors else [] @property - def prev_block(self) -> Optional['_LinesBlock']: - """Prev block in chain.""" - return self._prev_block - - @prev_block.setter - def prev_block(self, new_prev_block: Optional['_LinesBlock']) -> None: - self._neighbor_block(new_prev_block, 'prev', 'next') - - -class BookingLine(_Dictable, ABC): - """Parsed code part of a DatLine belonging to a _Booking.""" - - def __init__(self, idx: int, errors: Optional[list[str]] = None) -> None: - self.idx = idx - self._errors: list[str] = errors if errors else [] + def as_dict(self) -> dict[str, Any]: + """Return as JSON-ready dict attributes listed in .dictables.""" + def to_dictable(value): + if isinstance(value, (str, int)): + return value + if hasattr(value, 'as_dict'): + return value.as_dict + if isinstance(value, list): + return [to_dictable(v) for v in value] + return str(value) + d = {} + for name in self.dictables: + d[name] = to_dictable(getattr(self, name)) + return d @property def errors(self) -> list[str]: - """Return ._errors, allowing sub-classes to add entries dynamically.""" - return self._errors - - def add_error(self, msg: str) -> None: - """Store message to display with line's dynamic .errors.""" - self._errors += [msg] - - @abstractmethod - def to_code(self) -> str: - """Parse to ledger file line code part.""" - - def to_dat_line(self, comment: str = '') -> DatLine: - """Make matching DatLine.""" - return DatLine(self.to_code(), comment) + """Return collected errors (subclasses may add dynamic ones).""" + return self._errors[:] -class IntroLine(BookingLine): +class _IntroLine(_BookingLine): """First line of a _Booking, expected to carry date etc.""" - dictables = {'date', 'target'} + dictables = {'date', 'target'} | _BookingLine.dictables def __init__( self, date: str, target: str, + comment: str = '', errors: Optional[list[str]] = None, - booking: Optional['_Booking'] = None ) -> None: - super().__init__(0, errors) + super().__init__(comment, errors) self.target = target self.date = date - if not IntroLine._date_valid(self.date): - self._errors += [f'not properly formatted legal date: {self.date}'] - self._booking = booking - - @staticmethod - def _date_valid(date: str) -> bool: - try: - dt_date.fromisoformat(date) - except ValueError: - return False - return True @property def errors(self) -> list[str]: - errors = self._errors[:] - if (self._booking and self._booking.prev - and IntroLine._date_valid(self._booking.date) - and IntroLine._date_valid(self._booking.prev.date) - and self._booking.prev.date > self._booking.date): - errors += ['date < previous valid date'] + errors = super().errors + try: + dt_date.fromisoformat(self.date) + except ValueError: + errors += [f'not properly formatted legal date: {self.date}'] + if not self.target: + errors += ['target empty'] return errors @classmethod - def from_code(cls, code: str, booking: '_Booking') -> Self: - """Parse from ledger file line code part, assume booking context.""" + def from_dat(cls, dat_line: _DatLine) -> Self: errors = [] - if code[0].isspace(): - errors += ['intro line indented'] - toks = code.lstrip().split(maxsplit=1) - if len(toks) == 1: - errors += ['illegal number of tokens'] - return cls(toks[0], toks[1] if len(toks) > 1 else '', errors, booking) - - def to_code(self) -> str: + if dat_line.code[0].isspace(): + errors += ['(intro line indented)'] + toks = dat_line.code.lstrip().split(maxsplit=1) + return cls(toks[0], toks[1] if len(toks) > 1 else '', + dat_line.comment, errors) + + @property + def code(self) -> str: return f'{self.date} {self.target}' -class TransferLine(BookingLine): +class _TransferLine(_BookingLine): """Non-first _Booking line, expected to carry value movement.""" - dictables = {'amount', 'account', 'currency'} + dictables = {'amount', 'account', 'currency'} | _BookingLine.dictables def __init__( self, account: str, - amount: Optional[Decimal], + amount_str: str, currency: str, - errors: Optional[list[str]] = None, - idx: int = -1 + comment: str = '', + errors: Optional[list[str]] = None ) -> None: - super().__init__(idx, errors) + super().__init__(comment, errors) self.account = account - self.amount = amount + self._amount_str = amount_str self.currency = currency + @property + def amount(self) -> Optional[Decimal] | str: + """Decimal if amount known, None if not, str if un-decimable.""" + if not self._amount_str: + return None + try: + return Decimal(self._amount_str) + except DecimalInvalidOperation: + return self._amount_str + + @property + def errors(self) -> list[str]: + errors = super().errors + if isinstance(self.amount, str): + errors += [f'improper amount value: {self.amount}'] + if len(self.currency.split()) > 1: + errors += ['improper number of tokens'] + return errors + @classmethod - def from_code_at_idx(cls, code: str, idx: int) -> Self: - """Parse from ledger file line code part, assign in-Booking index.""" + def from_dat(cls, dat_line: _DatLine) -> Self: errors = [] currency: str = '' - amount: Optional[Decimal] = None - if not code[0].isspace(): - errors += ['transfer line not indented'] - toks = code.lstrip().split() + if not dat_line.code[0].isspace(): + errors += ['(transfer line not indented)'] + toks = dat_line.code.lstrip().split(maxsplit=2) + amount_str = toks[1] if len(toks) > 1 else '' if len(toks) > 1: currency = toks[2] if 3 == len(toks) else '€' - try: - amount = Decimal(toks[1]) - except DecimalInvalidOperation: - errors += [f'improper amount value: {toks[1]}'] - if len(toks) > 3: - errors += ['illegal number of tokens'] - return cls(toks[0], amount, currency, errors, idx) - - def to_code(self) -> str: - code = f'{_DEFAULT_INDENT}{self.account}' - if self.amount: + return cls(toks[0], amount_str, currency, dat_line.comment, errors) + + @property + def code(self) -> str: + code = f'{DEFAULT_INDENT}{self.account}' + if self.amount is not None: code += f' {self.amount} {self.currency}' return code @property def amount_short(self) -> str: - """If no .amount, '', else printed – but if too long, ellipsized.""" - if self.amount is not None: + """If decimal .amount, print ellipsized if too long, else directly.""" + if isinstance(self.amount, Decimal): exp = self.amount.as_tuple().exponent assert isinstance(exp, int) return f'{self.amount:.1f}…' if exp < -2 else f'{self.amount:.2f}' + if isinstance(self.amount, str): + return self.amount return '' -class _Booking(_LinesBlock): - """Represents lines of individual booking.""" +class _LinesBlock(Generic[TypeDatLine]): + + def __init__(self, lines: Optional[list[TypeDatLine]] = None) -> None: + self._lines = lines if lines else [] + + @property + def lines(self) -> list[TypeDatLine]: + """Return collected lines.""" + return self._lines + + def copy(self) -> Self: + """Re-create via .lines' copy().""" + return self.__class__([line.copy() for line in self.lines]) + + +class _Gap(_LinesBlock[_GapLine]): + + def add(self, lines: list[_GapLine]) -> None: + """Grow self by lines.""" + self._lines += lines + - def __init__(self, lines: list[DatLine]) -> None: +class _Booking(_LinesBlock[_BookingLine]): + + def __init__(self, lines: list[_BookingLine]) -> None: super().__init__(lines) - self.parse() - - def parse(self) -> None: - """Parse .lines for BookingLine structures, .account_changes""" - for line in self.lines: - line.booking = self - self.intro_line = IntroLine.from_code(self.lines[0].code, self) - self._transfer_lines = [ - TransferLine.from_code_at_idx(b_line.code, i+1) - for i, b_line in enumerate(self.lines[1:])] - self.lines[0].booked = self.intro_line - for i, b_line in enumerate(self._transfer_lines): - self.lines[i + 1].booked = b_line changes = _Wealth() sink_account = None self.sink_error = '' self.account_changes: dict[str, _Wealth] = {} - for transfer_line in [tl for tl in self._transfer_lines - if not tl.errors]: - if transfer_line.account not in self.account_changes: - self.account_changes[transfer_line.account] = _Wealth() - if transfer_line.amount is None: + for tf_line in [tl for tl in self.transfer_lines if not tl.errors]: + if tf_line.account not in self.account_changes: + self.account_changes[tf_line.account] = _Wealth() + if tf_line.amount is None: if sink_account: self.sink_error = 'too many sinks' else: - sink_account = transfer_line.account + sink_account = tf_line.account continue - change = _Wealth({transfer_line.currency: transfer_line.amount}) - self.account_changes[transfer_line.account] += change + assert isinstance(tf_line.amount, Decimal) + change = _Wealth({tf_line.currency: tf_line.amount}) + self.account_changes[tf_line.account] += change changes += change if sink_account: self.account_changes[sink_account] += changes.as_sink @@ -435,51 +365,114 @@ class _Booking(_LinesBlock): self.sink_error = 'needed sink missing' @property - def id_(self): - """Index in chain of bookings.""" - return self._block_id // 2 - - @staticmethod - def _cast(item: Optional[_LinesBlock]) -> Optional['_Booking']: - assert isinstance(item, (_Booking, type(None))) - return item + def intro_line(self) -> _IntroLine: + """Return collected _IntroLine.""" + assert isinstance(self._lines[0], _IntroLine) + return self._lines[0] @property - def next(self) -> Optional['_Booking']: - """Next Booking, assuming it's two block steps away.""" - return self._cast(self.next_block.next_block if self.next_block - else None) + def transfer_lines(self) -> list[_TransferLine]: + """Return collected _TransferLines.""" # NB: currently no easy way to + return self._lines[1:] # type: ignore # assert mypy list be of type @property - def prev(self) -> Optional['_Booking']: - """Prev Booking, assuming it's two block steps away.""" - return self._cast(self.prev_block.prev_block if self.prev_block - else None) - - def fix_position(self): - """Move around in bookings chain until properly positioned by .date.""" - while self.prev and self.prev.date > self.date: - self.move(up=True) - while self.next and self.next.date < self.date: - self.move(up=False) + def lines(self) -> list[_BookingLine]: + return [self.intro_line] + list(self.transfer_lines) @property - def lines_copied(self) -> list[DatLine]: - """Return new DatLines generated from .booked_lines.""" - return [dat_line.copy_unbooked() for dat_line in self.lines] + def date(self) -> str: + """Chronological position as per .booking, or empty string.""" + return self.intro_line.date @property def target(self) -> str: - """Return main other party for transaction.""" + """Main other party for transaction.""" return self.intro_line.target + def copy_to_current_date(self) -> Self: + """Make copy of same lines but now as date.""" + copy = self.copy() + copy.intro_line.date = dt_date.today().isoformat() + return copy + + +class DatBlock: + """Unit of lines with optional .booking, and possibly empty .gap.""" + + def __init__( + self, + booking: Optional['_Booking'], + gap: Optional['_Gap'] = None + ) -> None: + self.booking = booking + self.gap = gap if gap else _Gap() + self._prev: Optional[Self] = None + self._next: Optional[Self] = None + + @property + def id_(self) -> int: + """Return index in chain.""" + count = -1 + block_iterated: Optional[DatBlock] = self + while block_iterated: + block_iterated = block_iterated.prev + count += 1 + return count + + @property + def date_error(self) -> str: + """If not empty, notify about .date not matching position in chain.""" + if self.prev and self.prev.date > self.date: + return 'date < previous date' + return '' + + @property + def lines(self) -> list[_DatLine]: + """Return .lines of .booking and .gap as list[_DatLine].""" + lines = (self.booking.lines if self.booking else []) + self.gap.lines + if self.booking and not self.gap.lines: + lines += [_GapLine()] + return [_DatLine.from_subclass(line) for line in lines] + + def _set_neighbor( + self, + new_this: Optional[Self], + this: str, + that: str, + ) -> None: + if (old_this := getattr(self, f'_{this}')): + setattr(old_this, f'_{that}', None) + if new_this: + if (new_this_that := getattr(new_this, f'_{that}')): + setattr(new_this_that, f'_{this}', None) + setattr(new_this, f'_{that}', self) + setattr(self, f'_{this}', new_this) + + @property + def next(self) -> Optional['DatBlock']: + """Successor in chain.""" + return self._next + + @next.setter + def next(self, new_next: Optional['DatBlock']) -> None: + self._set_neighbor(new_next, 'next', 'prev') + + @property + def prev(self) -> Optional['DatBlock']: + """Predecessor in chain.""" + return self._prev + + @prev.setter + def prev(self, new_prev: Optional['DatBlock']): + self._set_neighbor(new_prev, 'prev', 'next') + @property def date(self) -> str: - """Return _Booking's day's date.""" - return self.intro_line.date + """Chronological position as per .booking, or empty string.""" + return self.booking.date if self.booking else '' def can_move(self, up: bool) -> bool: - """Whether movement rules would allow self to move up or down.""" + """Whether move up/down in chain possible, respecting .date.""" if up and ((not self.prev) or self.prev.date != self.date): return False if (not up) and ((not self.next) or self.next.date != self.date): @@ -487,97 +480,104 @@ class _Booking(_LinesBlock): return True def move(self, up: bool) -> None: - """Move self and following gap up or down in line blocks chain.""" + """Move up/down in chain.""" + old_prev = self.prev old_next = self.next - assert self.next_block is not None if up: - old_prev = self.prev assert old_prev is not None - assert old_prev.prev_block is not None - assert old_prev.next_block is not None - old_prev.prev_block.next_block = self - self.next_block.next_block = old_prev - old_prev.next_block.next_block = old_next + if old_prev.prev: + old_prev.prev.next = self + self.next = old_prev + old_prev.next = old_next else: assert old_next is not None - old_prev_block = self.prev_block - self.next_block.next_block = old_next.next - self.prev_block = old_next.next_block - old_next.prev_block = old_prev_block + if old_next.next: + old_next.next.prev = self + self.prev = old_next + old_next.prev = old_prev def drop(self) -> None: - """Remove self and following gap from line blocks chain.""" - assert self.prev_block is not None - assert self.next_block is not None - self.prev_block.lines += self.next_block.lines - self.prev_block.next_block = self.next_block.next_block + """Remove from chain.""" + if self.prev: + self.prev.next = self.next + elif self.next: + self.next.prev = self.prev - @property - def is_questionable(self) -> bool: - """Whether lines count any errors, or add up to a .sink_error.""" - if self.sink_error: - return True - for _ in [bl for bl in [self.intro_line] + self._transfer_lines - if bl.errors]: - return True - return False + def fix_position(self): + """Move around in chain until properly positioned by .date.""" + while self.prev and self.prev.date > self.date: + self.move(up=True) + while self.next and self.next.date < self.date: + self.move(up=False) + + def replace_with(self, new_block: Self) -> None: + """Have new_block take own position.""" + if self.prev: + self.prev.next = new_block + if self.next: + self.next.prev = new_block + new_block.fix_position() + + def copy_to_current_date(self) -> 'DatBlock': + """Make copy of same lines but now as date, position accordingly.""" + copy = DatBlock( + self.booking.copy_to_current_date() if self.booking else None, + self.gap.copy()) + if self.next: + self.next.prev = copy + self.next = copy + copy.fix_position() + return copy class Ledger: - """Collection of DatLines, and Accounts, _Bookings derived from them.""" - _blocks_start: Optional[_LinesBlock] + """Collection of DatBlocks, _Bookings and Accounts derived from them.""" + _blocks_start: Optional[DatBlock] def __init__(self, path_dat: Path) -> None: self._path_dat = path_dat self.load() + def load(self) -> None: + """(Re-)read ledger from file at ._path_dat.""" + dat_lines: list[_DatLine] = [ + _DatLine.from_raw(line) + for line in self._path_dat.read_text(encoding='utf8').splitlines()] + booking_lines: list[_BookingLine] = [] + new_block = DatBlock(None, _Gap()) + self._blocks_start = new_block + for dat_line in dat_lines: + if bool(dat_line.code): + if not booking_lines: + booking_lines += [_IntroLine.from_dat(dat_line)] + else: + booking_lines += [_TransferLine.from_dat(dat_line)] + else: # enter new gap -> ready to start next block + if booking_lines: + new_block.next = DatBlock(_Booking(booking_lines)) + new_block = new_block.next + booking_lines = [] + new_block.gap.add([_GapLine.from_dat(dat_line)]) + self.last_save_hash = self._hash_dat_lines() + @property - def _blocks(self) -> list[_LinesBlock]: + def blocks(self) -> list[DatBlock]: + """Return blocks chain as list.""" blocks = [] block = self._blocks_start while block: blocks += [block] - block = block.next_block + block = block.next return blocks - def load(self) -> None: - """(Re-)read ledger from file at ._path_dat.""" - dat_lines: list[DatLine] = [ - DatLine.from_raw(line) - for line in self._path_dat.read_text(encoding='utf8').splitlines()] - booked = False - self._blocks_start = None - block_lines: list[DatLine] = [] - for dat_line in dat_lines + [DatLine()]: - if bool(dat_line.code) != booked: - block = (_Booking if booked else _LinesBlock)(block_lines[:]) - if self._blocks_start: - block.prev_block = self._blocks_start.last_block - else: - self._blocks_start = block - booked = not booked - block_lines.clear() - block_lines += [dat_line] - if self._blocks_start: - _LinesBlock(block_lines[:]).prev_block =\ - self._blocks_start.last_block - self.last_save_hash = self._hash_dat_lines() - @property - def dat_lines(self) -> list[DatLine]: - """From ._blocks build list of current DatLines.""" + def _dat_lines(self) -> list[_DatLine]: + """From .blocks build list of current _DatLines.""" lines = [] - for block in self._blocks: + for block in self.blocks: lines += block.lines - for i, line in enumerate(lines): - line.prev = lines[i - 1] if i > 0 else None return lines - @property - def bookings(self) -> list[_Booking]: - """Build chain of bookings.""" - return [block for block in self._blocks if isinstance(block, _Booking)] - @property def accounts(self) -> dict[str, Account]: """Build mapping of account names to Accounts.""" @@ -592,94 +592,96 @@ class Ledger: step_name) parent_path = path - for dat_line in self.dat_lines: + for dat_line in self._dat_lines: for acc_name, desc in dat_line.comment_instructions.items(): ensure_accounts(acc_name) accounts[acc_name].desc = desc - for booking in self.bookings: - for acc_name, wealth in booking.account_changes.items(): + for block in [b for b in self.blocks if b.booking]: + assert block.booking is not None + for acc_name, wealth in block.booking.account_changes.items(): ensure_accounts(acc_name) - accounts[acc_name].add_wealth_diff(booking.id_, wealth) + accounts[acc_name].add_wealth_diff(block.id_, wealth) return accounts def save(self) -> None: """Save current state to ._path_dat.""" - text = '\n'.join([line.raw for line in self.dat_lines]) + text = '\n'.join([line.raw for line in self._dat_lines]) self._path_dat.write_text(text, encoding='utf8') self.load() def _hash_dat_lines(self) -> int: - return hash(tuple(dl.raw for dl in self.dat_lines)) - - def bookings_valid_up_incl(self, booking_id: int) -> bool: - """If no .is_questionable in self.bookings up to booking_id.""" - return len([b for b in self.bookings[:booking_id + 1] - if b.is_questionable] - ) < 1 + return hash(tuple(dl.raw for dl in self._dat_lines)) + + def blocks_valid_up_incl(self, block_id: int) -> bool: + """Whether nothing questionable about blocks until block_id.""" + for block in self.blocks[:block_id]: + if block.booking: + if block.booking.sink_error: + return False + if [line for line in block.booking.lines if line.errors]: + return False + if block.date_error: + return False + return True @property def tainted(self) -> bool: - """If .dat_lines different to those of last .load().""" + """If ._dat_lines different to those of last .load().""" return self._hash_dat_lines() != self.last_save_hash - def move_booking(self, idx_from: int, up: bool) -> int: - """Move _Booking of old_id one step up or downwards""" - booking = self.bookings[idx_from] - booking.move(up) - return booking.id_ - - def rewrite_booking(self, old_id: int, new_lines: list[DatLine]) -> int: - """Rewrite _Booking with new_lines, move if changed date.""" - booking = self.bookings[old_id] - booked_start, booked_end, gap_start_found = -1, 0, False - for i, line in enumerate(new_lines): - if booked_start < 0 and line.code.strip(): # ignore any initial - booked_start = i # empty lines - elif booked_start >= 0 and not line.code.strip(): # past start, - gap_start_found = True # yet empty? gap - if not gap_start_found: # end index is always after current line, - booked_end += 1 # provided we're not yet in the gap - elif line.code.strip(): - new_lines[i] = DatLine.from_raw(f'; {line.code}') - assert booking.prev_block is not None - assert booking.next_block is not None - booking.prev_block.lines += new_lines[:booked_start] - booking.next_block.lines = (new_lines[booked_end:] - + booking.next_block.lines) - booking.lines = ( - new_lines[booked_start:booked_end] if booked_start > -1 - else []) - if not booking.lines: # interpret empty posting as deletion request - booking.drop() - return old_id if old_id < len(self.bookings) else 0 - booking.parse() - booking.fix_position() - return booking.id_ - - def _add_new_booking( - self, - target: str, - dat_lines_transaction: list[DatLine], - intro_comment: str = '' - ) -> int: - intro = IntroLine(dt_date.today().isoformat(), target) - booking = _Booking( - [intro.to_dat_line(intro_comment)] + dat_lines_transaction) - booking.next_block = _LinesBlock([DatLine()]) - if self._blocks_start: - self._blocks_start.last_block.next_block = booking - else: - self._blocks_start = booking - booking.fix_position() - return booking.id_ - - def add_empty_booking(self) -> int: - """Add new _Booking to end of ledger.""" - return self._add_new_booking('?', []) - - def copy_booking(self, copied_id: int) -> int: - """Add copy of _Booking of copied_id to_end of ledger.""" - copied = self.bookings[copied_id] - return self._add_new_booking(copied.target, - copied.lines_copied[1:], - copied.lines[0].comment) + def move_block(self, idx_from: int, up: bool) -> int: + """Move DatBlock of idx_from step up or downwards""" + block = self.blocks[idx_from] + block.move(up) + return block.id_ + + def rewrite_block(self, old_id: int, new_lines: list[str]) -> int: + """Rewrite with new_lines, move if changed date.""" + lines_gap_pre_booking: list[_GapLine] = [] + lines_booking: list[_BookingLine] = [] + lines_gap_post_booking: list[_GapLine] = [] + for dat_line in [_DatLine.from_raw(line) for line in new_lines]: + if dat_line.code: + if lines_gap_post_booking: + lines_gap_post_booking += [_GapLine.from_dat(dat_line)] + elif not lines_booking: + lines_booking += [_IntroLine.from_dat(dat_line)] + else: + lines_booking += [_TransferLine.from_dat(dat_line)] + else: + if not lines_booking: + lines_gap_pre_booking += [_GapLine.from_dat(dat_line)] + else: + lines_gap_post_booking += [_GapLine.from_dat(dat_line)] + old_block = self.blocks[old_id] + if not lines_booking: + if old_block.prev: + old_block.prev.gap.add(lines_gap_pre_booking) + old_block.drop() + else: + old_block.booking = None + old_block.gap.add(lines_gap_pre_booking) + return max(0, old_id - 1) + new_block = DatBlock(_Booking(lines_booking), + _Gap(lines_gap_post_booking)) + self.blocks[old_id].replace_with(new_block) + if not new_block.prev: + self._blocks_start = DatBlock(None, _Gap()) + self._blocks_start.next = new_block + assert new_block.prev is not None + if lines_gap_pre_booking: + new_block.prev.gap.add(lines_gap_pre_booking) + return new_block.id_ + + def add_empty_block(self) -> int: + """Add new DatBlock of empty _Booking to end of ledger.""" + new_block = DatBlock( + _Booking([_IntroLine(dt_date.today().isoformat(), '?')])) + self.blocks[-1].next = new_block + new_block.fix_position() + return new_block.id_ + + def copy_block(self, id_: int) -> int: + """Add copy DatBlock of id_ but with current date.""" + copy = self.blocks[id_].copy_to_current_date() + return copy.id_ diff --git a/src/templates/_base.tmpl b/src/templates/_base.tmpl index a8d04d3..2c222c1 100644 --- a/src/templates/_base.tmpl +++ b/src/templates/_base.tmpl @@ -7,23 +7,50 @@ {% block script %}{% endblock %} + + {% block content %}{% endblock %} diff --git a/src/templates/_macros.tmpl b/src/templates/_macros.tmpl index 9e8ec3e..7a374ba 100644 --- a/src/templates/_macros.tmpl +++ b/src/templates/_macros.tmpl @@ -1,198 +1,198 @@ -{% macro css_td_money() %} -td.amt { text-align: right } -td.amt, td.curr { font-family: monospace; font-size: 1.3em; } +{# =====================[ general css ]========================== #} + +{% macro css_bg_white() %}background: #ffffff;{% endmacro %} +{% macro css_bg_red() %}background: #ff6666;{% endmacro %} + + + +{% macro css_noninput_monospace() %} + font-family: monospace; + font-size: 1.25em; {% endmacro %} -{% macro css_td_money_balance() %} -td.balance.amt { width: 10em; } -td.balance.curr { width: 3em; } +{% macro css_tabular_money() %} +td.amount { + text-align: right; +} +td.amount, td.currency { + {{ css_noninput_monospace() }} +} {% endmacro %} -{% macro css_errors() %} -span.sink_error, td.invalid, tr.warning td.invalid { background-color: #ff0000; } +{% macro css_balance() %} +table.alternating.bad > tbody > tr:nth-child(odd) { + {{ css_bg_red() }} +} +table.alternating.bad > tbody > tr:nth-child(even) { + background-color: #ff8a8a; +} +td.balance.amount { + width: 10em; +} +td.balance.currency { + width: 3em; +} {% endmacro %} -{% macro css_ledger_index_col() %} -table.ledger tr > td:first-child { text-align: right; } + +{# =====================[ general other ]======================== #} + +{% macro currency_short(currency) %}{{ currency|truncate(4, true, "…") }}{% endmacro %} + + + +{% macro conditional_block_nav(path, direction, block) %} +{% if block[direction] %} +{{direction}} +{% else %} +{{direction}} +{% endif %} {% endmacro %} -{% macro table_dat_lines_action_button(dat_line, action, label, enabled=true) %} - +{# =====================[ for ledger pages ]===================== #} + +{% macro css_ledger() %} +td.block_column { + {{css_bg_white()}} +} +td.block_column.bad { + {{css_bg_red()}} +} {% endmacro %} -{% macro table_dat_lines(dat_lines, raw) %} -
- -{% for dat_line in dat_lines %} - {% if raw or dat_line.code or dat_line.comment_in_ledger %} - {% if (not raw) and dat_line.prev.raw == "" %} - - {% endif %} - - - - {% if dat_line.is_intro %} - [#] - {{ table_dat_lines_action_button(dat_line, "moveup", "^", dat_line.booking.can_move(1)) }} - {% elif dat_line.booked.idx == 1 %} - [b] - {{ table_dat_lines_action_button(dat_line, "movedown", "v", dat_line.booking.can_move(0)) }} - {% elif dat_line.booked.idx == 2 %} - {{ table_dat_lines_action_button(dat_line, "copy", "C") }} - {% endif %} - - {% if raw %} - - {% if dat_line.is_intro %} - {{dat_line.raw_nbsp|safe}} - {% else %} - {{dat_line.raw_nbsp|safe}} - {% endif %} - - {% else %} - {% if dat_line.is_intro %} - - {{dat_line.booking.target}} - - {% elif dat_line.error %} - - - {% elif dat_line.booked %} - - - - - {% else %} - - - {% endif %} - {% endif %} - +{% macro ledger_block_columns(mode, block) %} + + + + +{% endmacro %} - {% if (not raw) and dat_line.error %} - - - - - - {% endif %} - {% endif %} -{% endfor %} -
 
- {{dat_line.booking.date}} - {{dat_line.comment_in_ledger}}{{dat_line.code}}{{dat_line.comment_in_ledger}}{{dat_line.booked.amount_short}}{{dat_line.booked.currency|truncate(4,true,"…")}}{{dat_line.booked.account}}{{dat_line.comment_in_ledger}}{{dat_line.comment_in_ledger}} 
+
+
+ +
+[#]
+[b]
+[e] +
{{dat_line.error}}
- -
+ +{# =====================[ for edit pages ]======================= #} + +{% macro css_booking_balance() %} +{{ css_balance() }} +td.direct_target { + font-weight: bold; +} {% endmacro %} -{% macro taint_js() %} + +{% macro js_taint() %} function taint() { - // activate buttons "apply", "revert" - Array.from(document.getElementsByClassName("enable_on_change")).forEach((el) => { - el.disabled = false; - }); - // deactivate Booking links - Array.from(document.getElementsByClassName("disable_on_change")).forEach((el) => { - if (el.tagName == 'SPAN') { - let links_text = ''; - Array.from(el.childNodes).forEach((node) => { - links_text += node.textContent + ' '; - }); - el.innerHTML = ''; - const del = document.createElement("del"); - el.appendChild(del); - del.textContent = links_text; - } else if (el.type == "button") { - el.disabled = true; - } - }); - // remove oninput handlers no longer needed (since we only ever go one way) - ['INPUT', 'TEXTAREA'].forEach((tag_name) => { - Array.from(document.getElementsByTagName(tag_name)).forEach((el) => { - el.oninput = null; - }); - }); + // activate buttons "apply", "revert" + Array.from(document.getElementsByClassName('enable_on_change')).forEach((el) => { + el.disabled = false; + }); + // deactivate "disable_on_change" links + Array.from(document.getElementsByClassName('disable_on_change')).forEach((el) => { + if (el.tagName == 'SPAN') { + let links_text = ''; + Array.from(el.childNodes).forEach((node) => { + links_text += node.textContent + ' '; + }); + el.innerHTML = ''; + const del = document.createElement('del'); + el.appendChild(del); + del.textContent = links_text; + } else if (el.type == 'button') { + el.disabled = true; + } + }); + // remove oninput handlers no longer needed (since we only ever go one way) + Array.from(document.querySelectorAll('*') + ).filter(el => (el.oninput !== null) + ).forEach(el => el.oninput = null); } {% endmacro %} -{% macro edit_bar(target, id, sink_error) %} +{% macro edit_bar(block, here, there) %} +
-prev · next +{{conditional_block_nav('/blocks/','prev',block)}} +{{conditional_block_nav('/blocks/','next',block)}} -switch to {{target}} · balance after · in ledger +switch to {{there}} +· +balance after +· +in ledger
-{% if sink_error %} -balancing error: {{ sink_error }} -
+{% if block.date_error or (block.booking and block.booking.sink_error) %} +
block-wide errors: + {{block.date_error}} + {% if block.booking %} + {% if block.date_error %}– and:{% endif %} + {{block.booking.sink_error}} + {% endif %} +
+
{% endif %} {% endmacro %} -{% macro tr_money_balance(amt, curr) %} - -{{amt}} -{{curr|truncate(4,true,"…")}} - -{% endmacro %} - +{% macro booking_balance(roots, valid) %} {% macro booking_balance_account_with_children(node) %} - -{{node.name}}{% if node.children %}:{% endif %} - - -{% for curr, amt in node.wealth_before.items() %} - {{ tr_money_balance(amt, curr) }} -{% endfor %} -
- - - -{% for curr, amt in node.wealth_diff.items() %} - {{ tr_money_balance(amt, curr) }} -{% endfor %} -
- - - -{% for curr, amt in node.wealth_after.items() %} - {{ tr_money_balance(amt, curr) }} -{% endfor %} -
- - - -{% for child in node.children %} - {{ booking_balance_account_with_children(child) }} -{% endfor %} + {% macro td_wealth(wealth_dict) %} + + + {% for curr, amt in wealth_dict.items() %} + + + + + {% endfor %} +
{{amt}}{{ currency_short(curr) }}
+ + {% endmacro %} + + + {{node.name}}{% if node.children %}:{% endif %} + + {{ td_wealth(node.wealth_before) }} + {{ td_wealth(node.wealth_diff) }} + {{ td_wealth(node.wealth_after) }} + + {% for child in node.children %} + {{ booking_balance_account_with_children(child) }} + {% endfor %} {% endmacro %} - - - -{% macro booking_balance(valid, roots) %} -
- -accountbeforediffafterdescription + + + + + + + {% for root in roots %} -{{ booking_balance_account_with_children(root) }} + {{ booking_balance_account_with_children(root) }} {% endfor %}
accountbeforediffafter
{% endmacro %} diff --git a/src/templates/balance.tmpl b/src/templates/balance.tmpl index 35f0fae..04bacb2 100644 --- a/src/templates/balance.tmpl +++ b/src/templates/balance.tmpl @@ -1,67 +1,100 @@ {% extends '_base.tmpl' %} -{% macro account_with_children(booking_id, account, indent) %} - {% if account.get_wealth(booking_id).moneys|length > 0 %} - + +{% block css %} +{{ macros.css_tabular_money() }} +{{ macros.css_balance() }} +td.money table { + float: left; +} +summary::marker { + {{ macros.css_noninput_monospace() }} +} +summary { + list-style-type: "[…]"; +} +details[open] > summary { + list-style-type: "[^]"; +} +span.indent { + letter-spacing: 3em; +} +{% endblock css %} + + + +{% macro account_with_children(block_id, account, indent) %} +{% macro tr_money_balance(amount, currency) %} + + {{amount}} + {{ macros.currency_short(curr) }} + +{% endmacro %} +{% if account.get_wealth(block_id).moneys|length > 0 %} + - {% if account.get_wealth(booking_id).moneys|length == 1 %} - - {% for curr, amt in account.get_wealth(booking_id).moneys.items() %} - {{ macros.tr_money_balance(amt, curr) }} - {% endfor %} -
+ {% if account.get_wealth(block_id).moneys|length == 1 %} + + {% for currency, amount in account.get_wealth(block_id).moneys.items() %} + {{tr_money_balance(amount, currency)}} + {% endfor %} +
{% else %} -
- - - {% for curr, amt in account.get_wealth(booking_id).moneys.items() %} - {% if 1 == loop.index %} - {{ macros.tr_money_balance(amt, curr) }} - {% endif %} - {% endfor %} -
-
- - {% for curr, amt in account.get_wealth(booking_id).moneys.items() %} - {% if 1 < loop.index %} - {{ macros.tr_money_balance(amt, curr) }} - {% endif %} - {% endfor %} -
-
+
+ + + {% for currency, amount in account.get_wealth(block_id).moneys.items() %} + {% if 1 == loop.index %} + {{tr_money_balance(amount, currency)}} + {% endif %} + {% endfor %} +
+
+ + {% for currency, amount in account.get_wealth(block_id).moneys.items() %} + {% if 1 < loop.index %} + {{tr_money_balance(amount, currency)}} + {% endif %} + {% endfor %} +
+
{% endif %} - {% for i in range(indent) %} {% endfor %}{% if account.parent %}:{% endif %}{{account.basename}}{% if account.children %}:{% endif %} + + {% + for i in range(indent) %} {% + endfor + %}{% + if account.parent + %}:{% + endif + %}{{account.basename}}{% + if account.children + %}:{% + endif + %} + {{account.desc}} {% for child in account.children %} - {{ account_with_children(booking_id, child, indent=indent+1) }} + {{account_with_children(block_id, child, indent=indent+1)}} {% endfor %} - {% endif %} +{% endif %} {% endmacro %} -{% block css %} -{{ macros.css_td_money() }} -{{ macros.css_td_money_balance() }} -td.money table { float: left; } -summary::marker { font-family: monospace; font-size: 1.2em; } -summary { list-style-type: "[…]"; } -details[open] > summary { list-style-type: "[^]"; } -span.indent { letter-spacing: 3em; } -{% endblock css %} {% block content %}

-prev -next +{{macros.conditional_block_nav(path_up_incl,'prev',block)}} +{{macros.conditional_block_nav(path_up_incl,'next',block)}} | -balance after booking {{booking.id_}} ({{booking.date}}: {{booking.target}}) +balance after booking {{block.id_}} ({{block.booking.date}}: {{block.booking.target}})

- + {% for root in roots %} -{{ account_with_children(booking.id_, root, indent=0) }} + {{account_with_children(block.id_,root,indent=0)}} {% endfor %}
{% endblock %} diff --git a/src/templates/edit_raw.tmpl b/src/templates/edit_raw.tmpl index fb7b804..ac68926 100644 --- a/src/templates/edit_raw.tmpl +++ b/src/templates/edit_raw.tmpl @@ -1,24 +1,25 @@ {% extends '_base.tmpl' %} + {% block css %} -{{ macros.css_td_money() }} -{{ macros.css_td_money_balance() }} -{{ macros.css_errors() }} +{{ macros.css_tabular_money() }} +{{ macros.css_booking_balance() }} {% endblock %} + {% block script %} -{{ macros.taint_js() }} +{{macros.js_taint()}} {% endblock %} + {% block content %} - -{{ macros.edit_bar("structured", id, sink_error) }} - -{{ macros.booking_balance(valid, roots) }} +{{ macros.booking_balance(roots, valid) }} {% endblock %} diff --git a/src/templates/edit_structured.tmpl b/src/templates/edit_structured.tmpl index f4942f0..9276e8d 100644 --- a/src/templates/edit_structured.tmpl +++ b/src/templates/edit_structured.tmpl @@ -1,73 +1,70 @@ {% extends '_base.tmpl' %} + {% block css %} -{{ macros.css_td_money() }} -{{ macros.css_td_money_balance() }} -{{ macros.css_errors() }} -input.date_input, input.number_input { font-family: monospace; } -input.number_input { text-align: right; } -input.date_input { margin-right: 0.1em; } -td.direct_target { font-weight: bold; } +{{ macros.css_tabular_money() }} +{{ macros.css_booking_balance() }} +input.amount { + text-align: right; + font-family: monospace; +} +#date_input { + margin-right: 0.3em; + font-family: monospace; +} {% endblock %} -{% block script %} -var dat_lines = {{dat_lines|tojson|safe}}; -{{ macros.taint_js() }} +{% block script %} +{{macros.js_taint()}} +var raw_gap_lines = {{raw_gap_lines|tojson|safe}}; +var booking_lines = {{booking_lines|tojson|safe}}; -function new_dat_line(account='', amount='None', currency='') { +function new_booking_line(account='', amount='None', currency='') { return { - error: '', + errors: [], comment: '', - booked: { - account: account, - amount: amount, - currency: currency, - } + account: account, + amount: amount, + currency: currency, }; } function update_form() { - // catch and empty table - const table = document.getElementById("dat_lines"); - table.innerHTML = ""; + // empty and redo gap_lines + textarea = document.getElementById('gap_lines'); + textarea.value = ''; + raw_gap_lines.forEach((line) => { + textarea.value += `${line}\n`; + }); + + // catch and empty booking lines table + const table = document.getElementById('booking_lines'); + table.innerHTML = ''; // basic helpers function add_button(parent_td, label, disabled, onclick) { - // add button to td to run onclick (after updating dat_lines from inputs, + // add button to td to run onclick (after updating booking_lines from inputs, // and followed by calling taint and update_form) const btn = document.createElement("button"); parent_td.appendChild(btn); btn.textContent = label; - btn.type = "button"; // otherwise will act as form submit + btn.type = 'button'; // otherwise would act as form submit btn.disabled = disabled; btn.onclick = function() { - let n_rows_jumped = 0; // to ignore table rows not representing dat_lines + let n_rows_skipped = 0; // to ignore table rows not representing booking_lines for (let i = 0; i < table.rows.length; i++) { const row = table.rows[i]; - if (row.classList.contains('warning')) { - n_rows_jumped++; + if (row.classList.contains('skip')) { + n_rows_skipped++; continue; }; - for (const input of table.rows[i].querySelectorAll('td input')) { - const line_to_update = dat_lines[i - n_rows_jumped]; - if (input.name.endsWith('comment')) { - line_to_update.comment = input.value; - } else if (input.name.endsWith('error')) { - line_to_update.code = input.value; - } else if (input.name.endsWith('date')) { - line_to_update.booked.date = input.value; - } else if (input.name.endsWith('target')) { - line_to_update.booked.target = input.value; - } else if (input.name.endsWith('account')) { - line_to_update.booked.account = input.value; - } else if (input.name.endsWith('amount')) { - line_to_update.booked.amount = input.value; - } else if (input.name.endsWith('currency')) { - line_to_update.booked.currency = input.value; - } + for (const input of table.rows[i].querySelectorAll('td > input')) { + const line_to_update = booking_lines[i - n_rows_skipped]; + const key = input.name.split('_').at(-1); + line_to_update[key] = input.value; } } onclick(); @@ -76,25 +73,28 @@ function update_form() { }; } function add_td(tr, colspan=1) { - const td = document.createElement("td"); + const td = document.createElement('td'); tr.appendChild(td); td.colSpan = colspan; return td; } - for (let i = 0; i < dat_lines.length; i++) { - const dat_line = dat_lines[i]; - const tr = document.createElement("tr"); + // work through individual booking lines + for (let i = 0; i < booking_lines.length; i++) { + const booking_line = booking_lines[i]; + const tr = document.createElement('tr'); table.appendChild(tr); - // add line inputs + // helpers depending on line-specific variables function setup_input_td(tr, colspan) { const td = add_td(tr, colspan); - if (dat_line.error) { td.classList.add("invalid"); }; + if (booking_line.errors.length > 0) { + td.classList.add('bad'); + }; return td; } function add_input(td, name, value, size) { - const input = document.createElement("input"); + const input = document.createElement('input'); td.appendChild(input); input.name = `line_${i}_${name}`; input.value = value.trim(); @@ -110,79 +110,74 @@ function update_form() { const td_btns_updown = add_td(tr); if (i > 0) { [{label: '^', earlier_idx: i-1, enabled: i > 1}, - {label: 'v', earlier_idx: i, enabled: i && i+1 < dat_lines.length} + {label: 'v', earlier_idx: i, enabled: i && i+1 < booking_lines.length} ].forEach((kwargs) => { add_button(td_btns_updown, kwargs.label, ! kwargs.enabled, function() { - const other_line = dat_lines[kwargs.earlier_idx]; - dat_lines.splice(kwargs.earlier_idx, 1); - dat_lines.splice(kwargs.earlier_idx + 1, 0, other_line); + const other_line = booking_lines[kwargs.earlier_idx]; + booking_lines.splice(kwargs.earlier_idx, 1); + booking_lines.splice(kwargs.earlier_idx + 1, 0, other_line); }); }); } // actual input lines - if (dat_line.is_intro) { + if (i == 0) { const td = setup_input_td(tr, 3); - const date_input = add_input(td, 'date', dat_line.booked.date, 10) - date_input.classList.add('date_input'); - add_input(td, 'target', dat_line.booked.target, 37) - } else if (!dat_line.error) { // i.e. valid TransferLine - const acc_input = add_td_input('account', dat_line.booked.account, 30); + const date_input = add_input(td, 'date', booking_line.date, 10) + date_input.id = 'date_input'; + add_input(td, 'target', booking_line.target, 37) + } else { + const acc_input = add_td_input('account', booking_line.account, 30); acc_input.setAttribute ('list', 'all_accounts'); acc_input.autocomplete = 'off'; // not using input[type=number] cuz no minimal step size, therefore regex test instead - const amt_input = add_td_input('amount', dat_line.booked.amount == 'None' ? '' : dat_line.booked.amount, 12); + const amt_input_val = booking_line.amount == 'None' ? '' : booking_line.amount; + const amt_input = add_td_input('amount', amt_input_val, 12); amt_input.pattern = '^-?[0-9]+(\.[0-9]+)?$'; - amt_input.classList.add("number_input"); + amt_input.classList.add('amount'); // ensure integer amounts at least line up with double-digit decimals - if (amt_input.value.match(/^-?[0-9]+$/)) { amt_input.value += '.00'; } + if (amt_input.value.match(/^-?[0-9]+$/)) { + amt_input.value += '.00'; + } // imply that POST handler will set '€' currency if unset, but amount set - const curr_input = add_td_input('currency', dat_line.booked.currency, 3); + const curr_input = add_td_input('currency', booking_line.currency, 3); curr_input.placeholder = '€'; - } else { - add_td_input('error', dat_line.code, 20, 3) } - add_td_input('comment', dat_line.comment, 40); + add_td_input('comment', booking_line.comment, 40); // line deletion and addition buttons td_add_del = add_td(tr); - add_button(td_add_del, 'add new', false, function() { - dat_lines.splice(i + 1, 0, new_dat_line()); + add_button(td_add_del, 'add new', false, function() { + booking_lines.splice(i + 1, 0, new_booking_line()); }); if (i > 0) { add_button(td_add_del, 'delete', i > 0 ? false : true, function() { - dat_lines.splice(i, 1); + booking_lines.splice(i, 1); }); } // add error explanation row if necessary - if (dat_line.error) { - const tr = document.createElement("tr"); - table.appendChild(tr); - const td = add_td(tr, 3); - tr.appendChild(document.createElement("td")); - td.textContent = dat_line.error; - tr.classList.add("warning"); + if (booking_line.errors.length > 0) { + tr.classList.add('bad'); + const tr_info = document.createElement('tr'); + tr_info.classList.add('skip'); + table.appendChild(tr_info); + const td = add_td(tr_info, 7); + tr.appendChild(document.createElement('td')); + td.textContent = 'line bad:' + booking_line.errors; + tr_info.classList.add('bad'); } } - - // make all rows alternate background color for better readability - Array.from(table.rows).forEach((tr) => { - tr.classList.add('alternating'); - }); } function replace() { - const from = document.getElementById("replace_from").value; - const to = document.getElementById("replace_to").value; - dat_lines.forEach((dat_line) => { - dat_line.comment = dat_line.comment.replaceAll(from, to); - if ('code' in dat_line) { - dat_line.code = dat_line.code.replaceAll(from, to); - } - ['date', 'target', 'account', 'amount', 'currency'].forEach((key) => { - if (key in dat_line.booked) { - dat_line.booked[key] = dat_line.booked[key].replaceAll(from, to); + const from = document.getElementById('replace_from').value; + const to = document.getElementById('replace_to').value; + document.getElementById('gap_lines').replaceAll(from, to); + booking_lines.forEach((booking_line) => { + Object.keys(booking_line).forEach((key) => { + if (key != 'errors') { + booking_line[key] = booking_line[key].replaceAll(from, to); } }); }); @@ -191,15 +186,15 @@ function replace() { } function mirror() { - dat_lines.slice(1).forEach((dat_line) => { + booking_lines.slice(1).forEach((booking_line) => { let inverted_amount = 'None'; - if (dat_line.booked.amount != 'None') { - inverted_amount = `-${dat_line.booked.amount}`; + if (booking_line.amount != 'None') { + inverted_amount = `-${booking_line.amount}`; if (inverted_amount.startsWith('--')) { inverted_amount = inverted_amount.slice(2); } } - dat_lines.push(new_dat_line('?', inverted_amount, dat_line.booked.currency)); + booking_lines.push(new_booking_line('?', inverted_amount, booking_line.currency)); }) taint(); update_form(); @@ -209,23 +204,21 @@ function fill_sink() { let sink_account = ''; let sink_indices = []; const sum_per_currency = {}; - for (let i = 0; i < dat_lines.length; i++) { - const dat_line = dat_lines[i]; - if (!dat_line.is_intro && !dat_line.error) { - const currency = dat_line.booked.currency || '€'; - if (dat_line.booked.amount == 'None') { - if (sink_account == dat_line.booked.account || !sink_account) { - if (!sink_account) { - sink_account = dat_line.booked.account; - } - sink_indices.push(i); + for (let i = 1; i < booking_lines.length; i++) { + const booking_line = booking_lines[i]; + const currency = booking_line.currency || '€'; + if (booking_line.amount == 'None') { + if (sink_account == booking_line.account || !sink_account) { + if (!sink_account) { + sink_account = booking_line.account; } - } else { - if (!Object.hasOwn(sum_per_currency, currency)) { - sum_per_currency[currency] = 0; - } - sum_per_currency[currency] += parseFloat(dat_line.booked.amount); + sink_indices.push(i); } + } else { + if (!Object.hasOwn(sum_per_currency, currency)) { + sum_per_currency[currency] = 0; + } + sum_per_currency[currency] += parseFloat(booking_line.amount); } } if (!sink_account) { @@ -238,15 +231,15 @@ function fill_sink() { } } for (i = 0; i < Object.keys(sink_amounts_per_currency).length - sink_indices.length; i++) { - sink_indices.push(dat_lines.length); - dat_lines.push(new_dat_line(sink_account)); + sink_indices.push(booking_lines.length); + booking_lines.push(new_booking_line(sink_account)); } let sink_indices_index = 0; for (const [currency, amount] of Object.entries(sink_amounts_per_currency)) { - const dat_line = dat_lines[sink_indices[sink_indices_index]]; + const booking_line = booking_lines[sink_indices[sink_indices_index]]; sink_indices_index++; - dat_line.booked.currency = currency; - dat_line.booked.amount = amount.toString(); + booking_line.currency = currency; + booking_line.amount = amount.toString(); } taint(); update_form(); @@ -256,9 +249,9 @@ window.onload = update_form; {% endblock %} + {% block content %} -
-{{ macros.edit_bar("raw", id, sink_error) }} +{{macros.edit_bar(block,'structured','raw')}} | @@ -268,13 +261,17 @@ from to
- +
+
Gap:
+
{% for acc in all_accounts %} -{{ macros.booking_balance(valid, roots) }} +
+{{ macros.booking_balance(roots, valid) }} {% endblock %} diff --git a/src/templates/ledger_raw.tmpl b/src/templates/ledger_raw.tmpl index 7f803ab..3bc1b39 100644 --- a/src/templates/ledger_raw.tmpl +++ b/src/templates/ledger_raw.tmpl @@ -1,14 +1,27 @@ {% extends '_base.tmpl' %} + {% block css %} -table { font-family: monospace; } -{{ macros.css_errors() }} -{{ macros.css_ledger_index_col() }} -table.ledger > tbody > tr > td:first-child input[type=submit] { font-size: 0.5em; } +{{macros.css_ledger()}} +table { + font-family: monospace; +} {% endblock %} + + {% block content %} -{{ macros.table_dat_lines(dat_lines, raw=true) }} +
+ +{% for block in blocks %} + {{macros.ledger_block_columns('raw', block)}} + {% for line in block.lines %} + + + + {% endfor %} +{% endfor %} +
{{line.raw}} 
+
{% endblock %} - diff --git a/src/templates/ledger_structured.tmpl b/src/templates/ledger_structured.tmpl index da853e5..1838c34 100644 --- a/src/templates/ledger_structured.tmpl +++ b/src/templates/ledger_structured.tmpl @@ -1,16 +1,46 @@ {% extends '_base.tmpl' %} + {% block css %} -{{ macros.css_td_money() }} -{{ macros.css_errors() }} -{{ macros.css_ledger_index_col() }} -table.ledger > tbody > tr > td { vertical-align: middle; } -table.ledger > tbody > tr > td.date, table.ledger > tbody > tr > td:first-child { font-family: monospace; font-size: 1.3em; } -table.ledger > tbody > tr > td.date { text-align: center; } -table.ledger > tbody > tr > td:first-child { white-space: nowrap; } +{{macros.css_ledger()}} +{{macros.css_tabular_money()}} +td.amount { + text-align: right; +} +td.amount, td.currency { + vertical-align: bottom; +} {% endblock %} + + {% block content %} -{{ macros.table_dat_lines(dat_lines, raw=false) }} +
+ +{% for block in blocks %} + {{macros.ledger_block_columns('structured', block)}} + {% if block.booking %} + + + + + {% for line in block.booking.transfer_lines %} + + + + + + + {% endfor %} + {% endif %} + {% for line in block.gap.lines %} + + + + {% endfor %} +{% endfor %} +
{{block.booking.date}} {{block.booking.target}}{{block.booking.intro_line.comment}}
{{line.amount_short}}{{ macros.currency_short(line.currency) }}{{line.account}}{{line.comment}}
{{ line.raw }} 
+ +
{% endblock %} -- 2.30.2