home · contact · privacy
Adapt to package installation structure used with ytplom.
authorChristian Heller <c.heller@plomlompom.de>
Sat, 8 Feb 2025 19:04:17 +0000 (20:04 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Sat, 8 Feb 2025 19:04:17 +0000 (20:04 +0100)
23 files changed:
.gitmodules
install.sh [new file with mode: 0755]
ledger.py [deleted file]
ledgplom [new file with mode: 0755]
plomlib [deleted submodule]
requirements.txt [deleted file]
src/plomlib [new submodule]
src/requirements.txt [new file with mode: 0644]
src/run.py [new file with mode: 0755]
src/templates/_base.tmpl [new file with mode: 0644]
src/templates/_macros.tmpl [new file with mode: 0644]
src/templates/balance.tmpl [new file with mode: 0644]
src/templates/edit_raw.tmpl [new file with mode: 0644]
src/templates/edit_structured.tmpl [new file with mode: 0644]
src/templates/ledger_raw.tmpl [new file with mode: 0644]
src/templates/ledger_structured.tmpl [new file with mode: 0644]
templates/_base.tmpl [deleted file]
templates/_macros.tmpl [deleted file]
templates/balance.tmpl [deleted file]
templates/edit_raw.tmpl [deleted file]
templates/edit_structured.tmpl [deleted file]
templates/ledger_raw.tmpl [deleted file]
templates/ledger_structured.tmpl [deleted file]

index 42cf7f3d2508a43803226b1730a39623b79033b6..4c97e952f35d00e30753b1efd434abc83e5802c1 100644 (file)
@@ -1,3 +1,3 @@
 [submodule "plomlib"]
-       path = plomlib
+       path = src/plomlib
        url = https://plomlompom.com/repos/clone/plomlib
diff --git a/install.sh b/install.sh
new file mode 100755 (executable)
index 0000000..aa84e99
--- /dev/null
@@ -0,0 +1,13 @@
+#!/usr/bin/sh
+set -e
+
+PATH_APP_SHARE=~/.local/share/ledgplom
+PATH_LOCAL_BIN=~/.local/bin
+NAME_EXECUTABLE=ledgplom
+
+mkdir -p "${PATH_APP_SHARE}" "${PATH_LOCAL_BIN}"
+
+cp -r ./src/* "${PATH_APP_SHARE}/"
+cp "${NAME_EXECUTABLE}" "${PATH_LOCAL_BIN}/"
+
+echo "Installed executable to ${PATH_LOCAL_BIN}/${NAME_EXECUTABLE}, app files to ${PATH_APP_SHARE}."
diff --git a/ledger.py b/ledger.py
deleted file mode 100755 (executable)
index 96520b6..0000000
--- a/ledger.py
+++ /dev/null
@@ -1,693 +0,0 @@
-#!/usr/bin/env python3
-"""Viewer for ledger .dat files."""
-from datetime import date as dt_date
-from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
-from os import environ
-from pathlib import Path
-from sys import exit as sys_exit
-from typing import Any, Optional, Self
-from plomlib.web import PlomHttpHandler, PlomHttpServer, PlomQueryMap
-
-
-LEDGER_DAT = environ.get('LEDGER_DAT')
-SERVER_PORT = 8084
-SERVER_HOST = '127.0.0.1'
-PATH_TEMPLATES = Path('templates')
-
-PREFIX_LEDGER = 'ledger_'
-PREFIX_EDIT = 'edit_'
-PREFIX_FILE = 'file_'
-TOK_STRUCT = 'structured'
-TOK_RAW = 'raw'
-EDIT_STRUCT = f'{PREFIX_EDIT}{TOK_STRUCT}'
-EDIT_RAW = f'{PREFIX_EDIT}{TOK_RAW}'
-LEDGER_STRUCT = f'{PREFIX_LEDGER}{TOK_STRUCT}'
-LEDGER_RAW = f'{PREFIX_LEDGER}{TOK_RAW}'
-
-
-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
-
-
-class Wealth():
-    """Collects amounts mapped to currencies."""
-
-    def __init__(self, moneys: Optional[dict[str, Decimal]] = None) -> None:
-        self.moneys = moneys if moneys else {}
-        self._sort_with_euro_up()
-
-    def _sort_with_euro_up(self) -> None:
-        if '€' in self.moneys:
-            temp = {'€': self.moneys['€']}
-            for curr in sorted([c for c in self.moneys if c != '€']):
-                temp[curr] = self.moneys[curr]
-            self.moneys = temp
-
-    def ensure_currencies(self, currencies: set[str]) -> None:
-        """Ensure all of currencies have at least a Decimal(0) entry."""
-        for currency in currencies:
-            if currency not in self.moneys:
-                self.moneys[currency] = Decimal(0)
-        self._sort_with_euro_up()
-
-    def purge_currencies_except(self, currencies: set[str]) -> None:
-        """From .moneys remove currencies except those listed."""
-        self.moneys = {curr: amt for curr, amt in self.moneys.items()
-                       if curr in currencies}
-
-    def _add(self, other: Self, add=True) -> Self:
-        result = self.__class__(self.moneys.copy())
-        result.ensure_currencies(set(other.moneys.keys()))
-        for currency, amount in other.moneys.items():
-            result.moneys[currency] += amount if add else -amount
-        return result
-
-    def __add__(self, other: Self) -> Self:
-        return self._add(other)
-
-    def __sub__(self, other: Self) -> Self:
-        return self._add(other, add=False)
-
-    @property
-    def sink_empty(self) -> bool:
-        """Return if all evens out to zero."""
-        return not bool(self.as_sink.moneys)
-
-    @property
-    def as_sink(self) -> 'Wealth':
-        """Drop zero amounts, invert non-zero ones."""
-        sink = Wealth()
-        for moneys in [Wealth({c: a}) for c, a in self.moneys.items() if a]:
-            sink -= moneys
-        return sink
-
-
-class Account:
-    """Combine name, position in tree of own, and wealth of self + children."""
-
-    def __init__(self, parent: Optional['Account'], basename: str) -> None:
-        self.local_wealth = Wealth()
-        self.basename = basename
-        self.children: list[Self] = []
-        self.parent = parent
-        if self.parent:
-            self.parent.children += [self]
-
-    @property
-    def wealth(self) -> Wealth:
-        """Total of .local_wealth with that of .children."""
-        total = Wealth()
-        total += self.local_wealth
-        for child in self.children:
-            total += child.wealth
-        return total
-
-    @staticmethod
-    def by_paths(acc_names: list[str]) -> dict[str, 'Account']:
-        """From bookings generate dict of all refered Accounts by paths."""
-        paths_to_accs: dict[str, Account] = {}
-        for full_name in acc_names:
-            path = ''
-            for step_name in full_name.split(':'):
-                parent_name = path[:]
-                path = ':'.join([path, step_name]) if path else step_name
-                if path not in paths_to_accs:
-                    paths_to_accs[path] = Account(
-                        paths_to_accs[parent_name] if parent_name else None,
-                        step_name)
-        return paths_to_accs
-
-    @staticmethod
-    def names_over_bookings(bookings: list['Booking']) -> list[str]:
-        """Sorted list of all account names refered to in bookings."""
-        names = set()
-        for booking in bookings:
-            for account_name in booking.account_changes:
-                names.add(account_name)
-        return sorted(list(names))
-
-
-class DatLine(Dictable):
-    """Line of .dat file parsed into comments and machine-readable data."""
-    dictables = {'booking_line', 'code', 'comment', 'error', 'is_intro'}
-
-    def __init__(self, line: str) -> None:
-        self.raw = line[:]
-        halves = [t.rstrip() for t in line.split(';', maxsplit=1)]
-        self.comment = halves[1] if len(halves) > 1 else ''
-        self.code = halves[0]
-        self.booking_line: Optional[BookingLine] = None
-
-    @property
-    def is_intro(self) -> bool:
-        """Return if intro line of a Booking."""
-        return isinstance(self.booking_line, IntroLine)
-
-    @property
-    def booking_id(self) -> int:
-        """If .booking_line, its .booking_id, else -1."""
-        return self.booking.id_ if self.booking else -1
-
-    @property
-    def booking(self) -> Optional['Booking']:
-        """If .booking_line, matching Booking, else None."""
-        return self.booking_line.booking if self.booking_line else None
-
-    @property
-    def error(self) -> str:
-        """Return error if registered on attempt to parse into BookingLine."""
-        return '; '.join(self.booking_line.errors) if self.booking_line else ''
-
-    @property
-    def is_questionable(self) -> bool:
-        """Return whether line be questionable per associated Booking."""
-        return (self.booking_line.booking.is_questionable if self.booking_line
-                else False)
-
-    @property
-    def raw_nbsp(self) -> str:
-        """Return .raw but ensure whitespace as &nbsp;, and at least one."""
-        if not self.raw:
-            return '&nbsp;'
-        return self.raw.replace(' ', '&nbsp;')
-
-
-class BookingLine(Dictable):
-    """Parsed code part of a DatLine belonging to a Booking."""
-
-    def __init__(self, booking: 'Booking') -> None:
-        self.errors: list[str] = []
-        self.booking = booking
-        self.idx = 0
-
-
-class IntroLine(BookingLine):
-    """First line of a Booking, expected to carry date etc."""
-    dictables = {'date', 'target'}
-
-    def __init__(self, booking: 'Booking', code: str) -> None:
-        super().__init__(booking)
-        if code[0].isspace():
-            self.errors += ['intro line indented']
-        toks = code.lstrip().split(maxsplit=1)
-        self.date = toks[0]
-        self.target = toks[1] if len(toks) > 1 else ''
-        if len(toks) == 1:
-            self.errors += ['illegal number of tokens']
-        try:
-            dt_date.fromisoformat(self.date)
-        except ValueError:
-            self.errors += [f'not properly formatted legal date: {self.date}']
-
-
-class TransferLine(BookingLine):
-    """Non-first Booking line, expected to carry value movement."""
-    dictables = {'amount', 'account', 'currency'}
-
-    def __init__(self, booking: 'Booking', code: str, idx: int) -> None:
-        super().__init__(booking)
-        self.idx = idx
-        self.currency = ''
-        self.amount: Optional[Decimal] = None
-        if not code[0].isspace():
-            self.errors += ['transfer line not indented']
-        toks = code.lstrip().split()
-        self.account = toks[0]
-        if len(toks) > 1:
-            self.currency = toks[2] if 3 == len(toks) else '€'
-            try:
-                self.amount = Decimal(toks[1])
-            except DecimalInvalidOperation:
-                self.errors += [f'improper amount value: {toks[1]}']
-        if len(toks) > 3:
-            self.errors += ['illegal number of tokens']
-
-    @property
-    def amount_short(self) -> str:
-        """If no .amount, '', else printed – but if too long, ellipsized."""
-        if self.amount is not None:
-            exp = self.amount.as_tuple().exponent
-            assert isinstance(exp, int)
-            return f'{self.amount:.1f}…' if exp < -2 else f'{self.amount:.2f}'
-        return ''
-
-
-class Booking:
-    """Represents lines of individual booking."""
-    next: Optional[Self]
-    prev: Optional[Self]
-
-    def __init__(self,
-                 id_: int,
-                 booked_lines: list[DatLine],
-                 gap_lines: Optional[list[DatLine]] = None
-                 ) -> None:
-        self.next, self.prev = None, None
-        self.id_, self.booked_lines = id_, booked_lines[:]
-        self._gap_lines = gap_lines[:] if gap_lines else []
-        # parse booked_lines into Intro- and TransferLines
-        self.intro_line = IntroLine(self, self.booked_lines[0].code)
-        self._transfer_lines = [
-                TransferLine(self, b_line.code, i+1) for i, b_line
-                in enumerate(self.booked_lines[1:])]
-        self.booked_lines[0].booking_line = self.intro_line
-        for i, b_line in enumerate(self._transfer_lines):
-            self.booked_lines[i + 1].booking_line = b_line
-        # calculate .account_changes
-        changes = Wealth()
-        sink_account = None
-        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:
-                if sink_account:
-                    transfer_line.errors += ['too many sinks']
-                sink_account = transfer_line.account
-                continue
-            change = Wealth({transfer_line.currency: transfer_line.amount})
-            self.account_changes[transfer_line.account] += change
-            changes += change
-        if sink_account:
-            self.account_changes[sink_account] += changes.as_sink
-        elif not changes.sink_empty:
-            self._transfer_lines[-1].errors += ['needed sink missing']
-
-    def recalc_prev_next(self, bookings: list[Self]) -> None:
-        """Assuming .id_ to be index in bookings, link prev + next Bookings."""
-        self.prev = bookings[self.id_ - 1] if self.id_ > 0 else None
-        if self.prev:
-            self.prev.next = self
-        self.next = (bookings[self.id_ + 1] if self.id_ + 1 < len(bookings)
-                     else None)
-        if self.next:
-            self.next.prev = self
-
-    @property
-    def gap_lines(self) -> list[DatLine]:
-        """Return ._gap_lines or, if empty, list of one empty DatLine."""
-        return self._gap_lines if self._gap_lines else [DatLine('')]
-
-    @gap_lines.setter
-    def gap_lines(self, gap_lines=list[DatLine]) -> None:
-        self._gap_lines = gap_lines[:]
-
-    @property
-    def gap_lines_copied(self) -> list[DatLine]:
-        """Return new DatLines generated from .raw's of .gap_lines."""
-        return [DatLine(dat_line.raw) for dat_line in self.gap_lines]
-
-    @property
-    def booked_lines_copied(self) -> list[DatLine]:
-        """Return new DatLines generated from .raw's of .booked_lines."""
-        return [DatLine(dat_line.raw) for dat_line in self.booked_lines]
-
-    @property
-    def target(self) -> str:
-        """Return main other party for transaction."""
-        return self.intro_line.target
-
-    @property
-    def date(self) -> str:
-        """Return Booking's day's date."""
-        return self.intro_line.date
-
-    def can_move(self, up: bool) -> bool:
-        """Whether movement rules would allow self to move up or down."""
-        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):
-            return False
-        return True
-
-    @property
-    def is_questionable(self) -> bool:
-        """Whether lines count any errors."""
-        for _ in [bl for bl in [self.intro_line] + self._transfer_lines
-                  if bl.errors]:
-            return True
-        return False
-
-    def apply_to_account_dict(self, acc_dict: dict[str, Account]) -> None:
-        """To account dictionary of expected keys, apply .account_changes."""
-        for acc_name, wealth in self.account_changes.items():
-            acc_dict[acc_name].local_wealth += wealth
-
-
-class Handler(PlomHttpHandler):
-    """"Handles HTTP requests."""
-    mapper = PlomQueryMap
-
-    def _send_rendered(self, tmpl_name: str, ctx: dict[str, Any]) -> None:
-        self.send_rendered(Path(f'{tmpl_name}.tmpl'), ctx)
-
-    def do_POST(self) -> None:
-        """"Route POST requests to respective handlers."""
-        # pylint: disable=invalid-name
-        redir_target = Path(self.path)
-        if (file_prefixed := self.postvars.keys_prefixed(PREFIX_FILE)):
-            self.post_file_action(file_prefixed[0])
-        elif (self.pagename.startswith(PREFIX_EDIT)
-              and self.postvars.first('apply')):
-            redir_target = self.post_edit()
-        elif self.pagename.startswith(PREFIX_LEDGER):
-            redir_target = self.post_ledger_action()
-        self.redirect(redir_target)
-
-    def post_file_action(self, file_prefixed: str) -> None:
-        """Based on file_prefixed name, trigger .server.load or .save."""
-        if file_prefixed == f'{PREFIX_FILE}load':
-            self.server.load()
-        elif file_prefixed == f'{PREFIX_FILE}save':
-            self.server.save()
-
-    def post_edit(self) -> Path:
-        """Based on postvars, edit targeted Booking."""
-        booking = self.server.bookings[int(self.path_toks[2])]
-        new_lines = []
-        if self.pagename == EDIT_STRUCT:
-            line_keys = self.postvars.keys_prefixed('line_')
-            lineno_to_inputs: dict[int, list[str]] = {}
-            for key in line_keys:
-                toks = key.split('_', maxsplit=2)
-                lineno = int(toks[1])
-                if lineno not in lineno_to_inputs:
-                    lineno_to_inputs[lineno] = []
-                lineno_to_inputs[lineno] += [toks[2]]
-            indent = '  '
-            for lineno, input_names in lineno_to_inputs.items():
-                data = ''
-                comment = self.postvars.first(f'line_{lineno}_comment')
-                for name in input_names:
-                    input_ = self.postvars.first(f'line_{lineno}_{name}'
-                                                 ).strip()
-                    if name == 'date':
-                        data = input_
-                    elif name == 'target':
-                        data += f' {input_}'
-                    elif name == 'error':
-                        data = f'{indent}{input_}'
-                    elif name == 'account':
-                        data = f'{indent}{input_}'
-                    elif name in {'amount', 'currency'}:
-                        data += f'  {input_}'
-                new_lines += [
-                    DatLine(f'{data} ; {comment}' if comment else data)]
-        else:  # edit_raw
-            new_lines += [DatLine(line) for line
-                          in self.postvars.first('booking').splitlines()]
-        new_id = self.server.rewrite_booking(booking.id_, new_lines)
-        return Path('/bookings').joinpath(f'{new_id}')
-
-    def post_ledger_action(self) -> Path:
-        """Based on trigger postvar call .server.(move|copy)_booking."""
-        keys_prefixed = self.postvars.keys_prefixed(PREFIX_LEDGER)
-        action, id_str, dir_ = keys_prefixed[0].split('_', maxsplit=3)[1:]
-        id_ = int(id_str)
-        if action == 'move':
-            id_ = self.server.move_booking(id_, dir_ == 'up')
-            return Path(self.path).joinpath(f'#{id_}')
-        id_ = self.server.copy_booking(id_, dir_ == 'to_end')
-        return Path(EDIT_STRUCT).joinpath(f'{id_}')
-
-    def do_GET(self) -> None:
-        """"Route GET requests to respective handlers."""
-        # pylint: disable=invalid-name
-        if self.pagename == 'bookings':
-            self.redirect(
-                Path('/').joinpath(EDIT_STRUCT).joinpath(self.path_toks[2]))
-            return
-        ctx = {'tainted': self.server.tainted, 'path': self.path}
-        if self.pagename == 'balance':
-            self.get_balance(ctx)
-        elif self.pagename.startswith(PREFIX_EDIT):
-            self.get_edit(ctx, self.pagename == EDIT_RAW)
-        elif self.pagename.startswith(PREFIX_LEDGER):
-            self.get_ledger(ctx, self.pagename == LEDGER_RAW)
-        else:
-            self.get_ledger(ctx, False)
-
-    def get_balance(self, ctx) -> None:
-        """Display tree of calculated Accounts over .bookings[:up_incl+1]."""
-        id_ = int(self.params.first('up_incl') or '-1')
-        to_balance = (self.server.bookings[:id_ + 1] if id_ >= 0
-                      else self.server.bookings)
-        valid = 0 == len([b for b in to_balance if b.is_questionable])
-        acc_dict = Account.by_paths(Account.names_over_bookings(to_balance))
-        for booking in to_balance:
-            booking.apply_to_account_dict(acc_dict)
-        ctx['roots'] = [ac for ac in acc_dict.values() if not ac.parent]
-        ctx['valid'] = valid
-        ctx['booking'] = self.server.bookings[id_]
-        self._send_rendered('balance', ctx)
-
-    def get_edit(self, ctx, raw: bool) -> None:
-        """Display edit form for individual Booking."""
-        id_ = int(self.path_toks[2])
-        booking = self.server.bookings[id_]
-        acc_names = Account.names_over_bookings(self.server.bookings)
-        to_balance = self.server.bookings[:id_ + 1]
-        accounts_after = Account.by_paths(acc_names)
-        accounts_before = Account.by_paths(acc_names)
-        for b in to_balance:
-            if b != booking:
-                b.apply_to_account_dict(accounts_before)
-            b.apply_to_account_dict(accounts_after)
-        observed_tree: list[dict[str, Any]] = []
-        for full_name in sorted(booking.account_changes.keys()):
-            parent_children: list[dict[str, Any]] = observed_tree
-            path = ''
-            for step_name in full_name.split(':'):
-                path = ':'.join([path, step_name]) if path else step_name
-                for child in [n for n in parent_children if path == n['name']]:
-                    parent_children = child['children']
-                    continue
-                wealth_before = accounts_before[path].wealth
-                wealth_after = accounts_after[path].wealth
-                diff = {c: a for c, a in (wealth_after - wealth_before
-                                          ).moneys.items()
-                        if a != 0}
-                if diff:
-                    displayed_currencies = set(diff.keys())
-                    for wealth in wealth_before, wealth_after:
-                        wealth.ensure_currencies(displayed_currencies)
-                        wealth.purge_currencies_except(displayed_currencies)
-                    node: dict[str, Any] = {
-                            'name': path,
-                            'wealth_before': wealth_before.moneys,
-                            'wealth_diff': diff,
-                            'wealth_after': wealth_after.moneys,
-                            'children': []}
-                    parent_children += [node]
-                    parent_children = node['children']
-        ctx['id'] = id_
-        ctx['dat_lines'] = [dl if raw else dl.as_dict
-                            for dl in booking.booked_lines]
-        ctx['valid'] = 0 == len([b for b in to_balance if b.is_questionable])
-        ctx['roots'] = observed_tree
-        if not raw:
-            ctx['all_accounts'] = acc_names
-        self._send_rendered(EDIT_RAW if raw else EDIT_STRUCT, ctx)
-
-    def get_ledger(self, ctx: dict[str, Any], raw: bool) -> None:
-        """Display ledger of all Bookings."""
-        ctx['dat_lines'] = self.server.dat_lines
-        self._send_rendered(LEDGER_RAW if raw else LEDGER_STRUCT, ctx)
-
-
-class Server(PlomHttpServer):
-    """Extends parent by loading .dat file into database for Handler."""
-    bookings: list[Booking]
-    dat_lines: list[DatLine]
-    initial_gap_lines: list[DatLine]
-
-    def __init__(self, path_dat: Path, *args, **kwargs) -> None:
-        super().__init__(PATH_TEMPLATES, (SERVER_HOST, SERVER_PORT), Handler)
-        self._path_dat = path_dat
-        self.load()
-
-    def load(self) -> None:
-        """Read into ledger file at .path_dat."""
-        self.dat_lines = [
-            DatLine(line)
-            for line in self._path_dat.read_text(encoding='utf8').splitlines()]
-        self.last_save_hash = self._hash_dat_lines()
-        booked_lines: list[DatLine] = []
-        gap_lines: list[DatLine] = []
-        booking: Optional[Booking] = None
-        self.bookings, self.initial_gap_lines, last_date = [], [], ''
-        for dat_line in self.dat_lines + [DatLine('')]:
-            if dat_line.code:
-                if gap_lines:
-                    if booking:
-                        booking.gap_lines = gap_lines[:]
-                    else:
-                        self.initial_gap_lines = gap_lines[:]
-                    gap_lines.clear()
-                booked_lines += [dat_line]
-            else:
-                if booked_lines:
-                    booking = Booking(len(self.bookings), booked_lines[:])
-                    if last_date > booking.date:
-                        booking.intro_line.errors += [
-                                'date < previous valid date']
-                    else:
-                        last_date = booking.date
-                    self.bookings += [booking]
-                    booked_lines.clear()
-                gap_lines += [dat_line]
-        for booking in self.bookings:
-            booking.recalc_prev_next(self.bookings)
-        if booking:
-            booking.gap_lines = gap_lines[:-1]
-
-    def save(self) -> None:
-        """Save current state to ._path_dat."""
-        self._path_dat.write_text(
-            '\n'.join([line.raw for line in self.dat_lines]), encoding='utf8')
-        self.load()
-
-    def _hash_dat_lines(self) -> int:
-        return hash(tuple(dl.raw for dl in self.dat_lines))
-
-    @property
-    def tainted(self) -> bool:
-        """If .dat_lines different to those of last .load()."""
-        return self._hash_dat_lines() != self.last_save_hash
-
-    def _recalc_dat_lines(self) -> None:
-        self.dat_lines = self.initial_gap_lines[:]
-        for booking in self.bookings:
-            self.dat_lines += booking.booked_lines
-            self.dat_lines += booking.gap_lines
-
-    def _move_booking(self, idx_from, idx_to) -> None:
-        moving = self.bookings[idx_from]
-        if idx_from >= idx_to:                   # moving upward, deletion must
-            del self.bookings[idx_from]          # precede insertion to keep
-        self.bookings[idx_to:idx_to] = [moving]  # deletion index, downwards
-        if idx_from < idx_to:                    # the other way around keeps
-            del self.bookings[idx_from]          # insertion index
-        min_idx, max_idx = min(idx_from, idx_to), max(idx_from, idx_to)
-        for idx, booking in enumerate(self.bookings[min_idx:max_idx + 1]):
-            booking.id_ = min_idx + idx
-            booking.recalc_prev_next(self.bookings)
-
-    def move_booking(self, old_id: int, up: bool) -> int:
-        """Move Booking of old_id one step up or downwards"""
-        new_id = old_id + (-1 if up else 1)
-        self._move_booking(old_id,                     # moving down implies
-                           new_id + (0 if up else 1))  # jumping over next item
-        self._recalc_dat_lines()
-        return new_id
-
-    def rewrite_booking(self, old_id: int, new_lines: list[DatLine]) -> int:
-        """Rewrite Booking with new_lines, move if changed date."""
-        old_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():
-                booked_start = i
-            elif booked_start >= 0 and not line.code.strip():
-                gap_start_found = True
-            if not gap_start_found:
-                booked_end += 1
-            elif line.code.strip():
-                new_lines[i] = DatLine(f'; {line.code}')
-        before_gap = new_lines[:booked_start]
-        new_booked_lines = (new_lines[booked_start:booked_end]
-                            if booked_start > -1 else [])
-        after_gap = old_booking.gap_lines_copied + new_lines[booked_end:]
-        if not new_booked_lines:
-            del self.bookings[old_id]
-            for booking in self.bookings[old_id:]:
-                booking.id_ -= 1
-            summed_gap = before_gap + after_gap
-            if old_booking.id_ == 0:
-                self.initial_gap_lines += summed_gap
-            else:
-                assert old_booking.prev is not None
-                old_booking.prev.gap_lines += summed_gap
-            for neighbour in old_booking.prev, old_booking.next:
-                if neighbour:
-                    neighbour.recalc_prev_next(self.bookings)
-            self._recalc_dat_lines()
-            return old_id if old_id < len(self.bookings) else 0
-        new_date = new_booked_lines[0].code.lstrip().split(maxsplit=1)[0]
-        if new_date == old_booking.date:
-            new_booking = Booking(old_id, new_booked_lines, after_gap)
-            self.bookings[old_id] = new_booking
-            new_booking.recalc_prev_next(self.bookings)
-        else:
-            i_booking = self.bookings[0]
-            new_idx = None
-            while i_booking.next:
-                if not i_booking.prev and i_booking.date > new_date:
-                    new_idx = i_booking.id_
-                    break
-                if i_booking.next.date > new_date:
-                    break
-                i_booking = i_booking.next
-            if new_idx is None:
-                new_idx = i_booking.id_ + 1
-            # ensure that, if we land in group of like-dated Bookings, we
-            # land on the edge closest to our last position
-            if i_booking.date == new_date and old_id < i_booking.id_:
-                new_idx = [b for b in self.bookings
-                           if b.date == new_date][0].id_
-            new_booking = Booking(new_idx, new_booked_lines, after_gap)
-            self.bookings[old_id] = new_booking
-            self._move_booking(old_id, new_idx)
-        if new_booking.id_ == 0:
-            self.initial_gap_lines += before_gap
-        else:
-            assert new_booking.prev is not None
-            new_booking.prev.gap_lines += before_gap
-        self._recalc_dat_lines()
-        return new_booking.id_
-
-    def copy_booking(self, id_: int, to_end: bool) -> int:
-        """Add copy of Booking of id_ to_end of ledger, or after copied."""
-        copied = self.bookings[id_]
-        new_id = len(self.bookings) if to_end else copied.id_ + 1
-        if to_end:
-            intro_comment = copied.booked_lines[0].comment
-            intro = DatLine(
-                f'{dt_date.today().isoformat()} {copied.target}'
-                + (f' ; {intro_comment}' if intro_comment else ''))
-            new_booking = Booking(new_id,
-                                  [intro] + copied.booked_lines_copied[1:],
-                                  copied.gap_lines_copied)
-            self.bookings += [new_booking]
-        else:
-            new_booking = Booking(new_id,
-                                  copied.booked_lines_copied,
-                                  copied.gap_lines_copied)
-            self.bookings[new_id:new_id] = [new_booking]
-            for b in self.bookings[new_id + 1:]:
-                b.id_ += 1
-        new_booking.recalc_prev_next(self.bookings)
-        self._recalc_dat_lines()
-        return new_id
-
-
-if __name__ == "__main__":
-    if not LEDGER_DAT:
-        print("LEDGER_DAT environment variable not set.")
-        sys_exit(1)
-    Server(Path(LEDGER_DAT)).serve()
diff --git a/ledgplom b/ledgplom
new file mode 100755 (executable)
index 0000000..59a02b7
--- /dev/null
+++ b/ledgplom
@@ -0,0 +1,17 @@
+#!/usr/bin/sh
+set -e
+
+PATH_APP_SHARE=~/.local/share/ledgplom
+PATH_VENV="${PATH_APP_SHARE}/venv"
+
+python3 -m venv "${PATH_VENV}"
+. "${PATH_VENV}/bin/activate"
+
+if [ "$1" = "install_deps" ]; then
+    echo "Checking dependencies."
+    pip3 install -r "${PATH_APP_SHARE}/requirements.txt"
+    exit 0
+fi
+
+export PYTHONPATH="${PATH_APP_SHARE}:${PYTHONPATH}"
+python3 "${PATH_APP_SHARE}/run.py" $@
diff --git a/plomlib b/plomlib
deleted file mode 160000 (submodule)
index dee7c0f..0000000
--- a/plomlib
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit dee7c0f6218e6bdd07b477dc5d9e4b5540ffcf4a
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644 (file)
index 7cb100d..0000000
+++ /dev/null
@@ -1 +0,0 @@
-Jinja2==3.1.5
diff --git a/src/plomlib b/src/plomlib
new file mode 160000 (submodule)
index 0000000..dee7c0f
--- /dev/null
@@ -0,0 +1 @@
+Subproject commit dee7c0f6218e6bdd07b477dc5d9e4b5540ffcf4a
diff --git a/src/requirements.txt b/src/requirements.txt
new file mode 100644 (file)
index 0000000..7cb100d
--- /dev/null
@@ -0,0 +1 @@
+Jinja2==3.1.5
diff --git a/src/run.py b/src/run.py
new file mode 100755 (executable)
index 0000000..6f09306
--- /dev/null
@@ -0,0 +1,701 @@
+#!/usr/bin/env python3
+"""Viewer and editor for ledger .dat files."""
+
+# included libs
+from datetime import date as dt_date
+from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
+from os import environ
+from pathlib import Path
+from sys import exit as sys_exit
+from typing import Any, Optional, Self
+# might need module installation(s)
+try:
+    from plomlib.web import PlomHttpHandler, PlomHttpServer, PlomQueryMap
+except ModuleNotFoundError as e:
+    print('FAIL: Missing module(s), please run with "install_deps" argument.')
+    print(e)
+    sys_exit(1)
+
+
+LEDGER_DAT = environ.get('LEDGER_DAT')
+SERVER_PORT = 8084
+SERVER_HOST = '127.0.0.1'
+PATH_TEMPLATES = Path('templates')
+
+PREFIX_LEDGER = 'ledger_'
+PREFIX_EDIT = 'edit_'
+PREFIX_FILE = 'file_'
+TOK_STRUCT = 'structured'
+TOK_RAW = 'raw'
+EDIT_STRUCT = f'{PREFIX_EDIT}{TOK_STRUCT}'
+EDIT_RAW = f'{PREFIX_EDIT}{TOK_RAW}'
+LEDGER_STRUCT = f'{PREFIX_LEDGER}{TOK_STRUCT}'
+LEDGER_RAW = f'{PREFIX_LEDGER}{TOK_RAW}'
+
+
+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
+
+
+class Wealth():
+    """Collects amounts mapped to currencies."""
+
+    def __init__(self, moneys: Optional[dict[str, Decimal]] = None) -> None:
+        self.moneys = moneys if moneys else {}
+        self._sort_with_euro_up()
+
+    def _sort_with_euro_up(self) -> None:
+        if '€' in self.moneys:
+            temp = {'€': self.moneys['€']}
+            for curr in sorted([c for c in self.moneys if c != '€']):
+                temp[curr] = self.moneys[curr]
+            self.moneys = temp
+
+    def ensure_currencies(self, currencies: set[str]) -> None:
+        """Ensure all of currencies have at least a Decimal(0) entry."""
+        for currency in currencies:
+            if currency not in self.moneys:
+                self.moneys[currency] = Decimal(0)
+        self._sort_with_euro_up()
+
+    def purge_currencies_except(self, currencies: set[str]) -> None:
+        """From .moneys remove currencies except those listed."""
+        self.moneys = {curr: amt for curr, amt in self.moneys.items()
+                       if curr in currencies}
+
+    def _add(self, other: Self, add=True) -> Self:
+        result = self.__class__(self.moneys.copy())
+        result.ensure_currencies(set(other.moneys.keys()))
+        for currency, amount in other.moneys.items():
+            result.moneys[currency] += amount if add else -amount
+        return result
+
+    def __add__(self, other: Self) -> Self:
+        return self._add(other)
+
+    def __sub__(self, other: Self) -> Self:
+        return self._add(other, add=False)
+
+    @property
+    def sink_empty(self) -> bool:
+        """Return if all evens out to zero."""
+        return not bool(self.as_sink.moneys)
+
+    @property
+    def as_sink(self) -> 'Wealth':
+        """Drop zero amounts, invert non-zero ones."""
+        sink = Wealth()
+        for moneys in [Wealth({c: a}) for c, a in self.moneys.items() if a]:
+            sink -= moneys
+        return sink
+
+
+class Account:
+    """Combine name, position in tree of own, and wealth of self + children."""
+
+    def __init__(self, parent: Optional['Account'], basename: str) -> None:
+        self.local_wealth = Wealth()
+        self.basename = basename
+        self.children: list[Self] = []
+        self.parent = parent
+        if self.parent:
+            self.parent.children += [self]
+
+    @property
+    def wealth(self) -> Wealth:
+        """Total of .local_wealth with that of .children."""
+        total = Wealth()
+        total += self.local_wealth
+        for child in self.children:
+            total += child.wealth
+        return total
+
+    @staticmethod
+    def by_paths(acc_names: list[str]) -> dict[str, 'Account']:
+        """From bookings generate dict of all refered Accounts by paths."""
+        paths_to_accs: dict[str, Account] = {}
+        for full_name in acc_names:
+            path = ''
+            for step_name in full_name.split(':'):
+                parent_name = path[:]
+                path = ':'.join([path, step_name]) if path else step_name
+                if path not in paths_to_accs:
+                    paths_to_accs[path] = Account(
+                        paths_to_accs[parent_name] if parent_name else None,
+                        step_name)
+        return paths_to_accs
+
+    @staticmethod
+    def names_over_bookings(bookings: list['Booking']) -> list[str]:
+        """Sorted list of all account names refered to in bookings."""
+        names = set()
+        for booking in bookings:
+            for account_name in booking.account_changes:
+                names.add(account_name)
+        return sorted(list(names))
+
+
+class DatLine(Dictable):
+    """Line of .dat file parsed into comments and machine-readable data."""
+    dictables = {'booking_line', 'code', 'comment', 'error', 'is_intro'}
+
+    def __init__(self, line: str) -> None:
+        self.raw = line[:]
+        halves = [t.rstrip() for t in line.split(';', maxsplit=1)]
+        self.comment = halves[1] if len(halves) > 1 else ''
+        self.code = halves[0]
+        self.booking_line: Optional[BookingLine] = None
+
+    @property
+    def is_intro(self) -> bool:
+        """Return if intro line of a Booking."""
+        return isinstance(self.booking_line, IntroLine)
+
+    @property
+    def booking_id(self) -> int:
+        """If .booking_line, its .booking_id, else -1."""
+        return self.booking.id_ if self.booking else -1
+
+    @property
+    def booking(self) -> Optional['Booking']:
+        """If .booking_line, matching Booking, else None."""
+        return self.booking_line.booking if self.booking_line else None
+
+    @property
+    def error(self) -> str:
+        """Return error if registered on attempt to parse into BookingLine."""
+        return '; '.join(self.booking_line.errors) if self.booking_line else ''
+
+    @property
+    def is_questionable(self) -> bool:
+        """Return whether line be questionable per associated Booking."""
+        return (self.booking_line.booking.is_questionable if self.booking_line
+                else False)
+
+    @property
+    def raw_nbsp(self) -> str:
+        """Return .raw but ensure whitespace as &nbsp;, and at least one."""
+        if not self.raw:
+            return '&nbsp;'
+        return self.raw.replace(' ', '&nbsp;')
+
+
+class BookingLine(Dictable):
+    """Parsed code part of a DatLine belonging to a Booking."""
+
+    def __init__(self, booking: 'Booking') -> None:
+        self.errors: list[str] = []
+        self.booking = booking
+        self.idx = 0
+
+
+class IntroLine(BookingLine):
+    """First line of a Booking, expected to carry date etc."""
+    dictables = {'date', 'target'}
+
+    def __init__(self, booking: 'Booking', code: str) -> None:
+        super().__init__(booking)
+        if code[0].isspace():
+            self.errors += ['intro line indented']
+        toks = code.lstrip().split(maxsplit=1)
+        self.date = toks[0]
+        self.target = toks[1] if len(toks) > 1 else ''
+        if len(toks) == 1:
+            self.errors += ['illegal number of tokens']
+        try:
+            dt_date.fromisoformat(self.date)
+        except ValueError:
+            self.errors += [f'not properly formatted legal date: {self.date}']
+
+
+class TransferLine(BookingLine):
+    """Non-first Booking line, expected to carry value movement."""
+    dictables = {'amount', 'account', 'currency'}
+
+    def __init__(self, booking: 'Booking', code: str, idx: int) -> None:
+        super().__init__(booking)
+        self.idx = idx
+        self.currency = ''
+        self.amount: Optional[Decimal] = None
+        if not code[0].isspace():
+            self.errors += ['transfer line not indented']
+        toks = code.lstrip().split()
+        self.account = toks[0]
+        if len(toks) > 1:
+            self.currency = toks[2] if 3 == len(toks) else '€'
+            try:
+                self.amount = Decimal(toks[1])
+            except DecimalInvalidOperation:
+                self.errors += [f'improper amount value: {toks[1]}']
+        if len(toks) > 3:
+            self.errors += ['illegal number of tokens']
+
+    @property
+    def amount_short(self) -> str:
+        """If no .amount, '', else printed – but if too long, ellipsized."""
+        if self.amount is not None:
+            exp = self.amount.as_tuple().exponent
+            assert isinstance(exp, int)
+            return f'{self.amount:.1f}…' if exp < -2 else f'{self.amount:.2f}'
+        return ''
+
+
+class Booking:
+    """Represents lines of individual booking."""
+    next: Optional[Self]
+    prev: Optional[Self]
+
+    def __init__(self,
+                 id_: int,
+                 booked_lines: list[DatLine],
+                 gap_lines: Optional[list[DatLine]] = None
+                 ) -> None:
+        self.next, self.prev = None, None
+        self.id_, self.booked_lines = id_, booked_lines[:]
+        self._gap_lines = gap_lines[:] if gap_lines else []
+        # parse booked_lines into Intro- and TransferLines
+        self.intro_line = IntroLine(self, self.booked_lines[0].code)
+        self._transfer_lines = [
+                TransferLine(self, b_line.code, i+1) for i, b_line
+                in enumerate(self.booked_lines[1:])]
+        self.booked_lines[0].booking_line = self.intro_line
+        for i, b_line in enumerate(self._transfer_lines):
+            self.booked_lines[i + 1].booking_line = b_line
+        # calculate .account_changes
+        changes = Wealth()
+        sink_account = None
+        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:
+                if sink_account:
+                    transfer_line.errors += ['too many sinks']
+                sink_account = transfer_line.account
+                continue
+            change = Wealth({transfer_line.currency: transfer_line.amount})
+            self.account_changes[transfer_line.account] += change
+            changes += change
+        if sink_account:
+            self.account_changes[sink_account] += changes.as_sink
+        elif not changes.sink_empty:
+            self._transfer_lines[-1].errors += ['needed sink missing']
+
+    def recalc_prev_next(self, bookings: list[Self]) -> None:
+        """Assuming .id_ to be index in bookings, link prev + next Bookings."""
+        self.prev = bookings[self.id_ - 1] if self.id_ > 0 else None
+        if self.prev:
+            self.prev.next = self
+        self.next = (bookings[self.id_ + 1] if self.id_ + 1 < len(bookings)
+                     else None)
+        if self.next:
+            self.next.prev = self
+
+    @property
+    def gap_lines(self) -> list[DatLine]:
+        """Return ._gap_lines or, if empty, list of one empty DatLine."""
+        return self._gap_lines if self._gap_lines else [DatLine('')]
+
+    @gap_lines.setter
+    def gap_lines(self, gap_lines=list[DatLine]) -> None:
+        self._gap_lines = gap_lines[:]
+
+    @property
+    def gap_lines_copied(self) -> list[DatLine]:
+        """Return new DatLines generated from .raw's of .gap_lines."""
+        return [DatLine(dat_line.raw) for dat_line in self.gap_lines]
+
+    @property
+    def booked_lines_copied(self) -> list[DatLine]:
+        """Return new DatLines generated from .raw's of .booked_lines."""
+        return [DatLine(dat_line.raw) for dat_line in self.booked_lines]
+
+    @property
+    def target(self) -> str:
+        """Return main other party for transaction."""
+        return self.intro_line.target
+
+    @property
+    def date(self) -> str:
+        """Return Booking's day's date."""
+        return self.intro_line.date
+
+    def can_move(self, up: bool) -> bool:
+        """Whether movement rules would allow self to move up or down."""
+        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):
+            return False
+        return True
+
+    @property
+    def is_questionable(self) -> bool:
+        """Whether lines count any errors."""
+        for _ in [bl for bl in [self.intro_line] + self._transfer_lines
+                  if bl.errors]:
+            return True
+        return False
+
+    def apply_to_account_dict(self, acc_dict: dict[str, Account]) -> None:
+        """To account dictionary of expected keys, apply .account_changes."""
+        for acc_name, wealth in self.account_changes.items():
+            acc_dict[acc_name].local_wealth += wealth
+
+
+class Handler(PlomHttpHandler):
+    """"Handles HTTP requests."""
+    mapper = PlomQueryMap
+
+    def _send_rendered(self, tmpl_name: str, ctx: dict[str, Any]) -> None:
+        self.send_rendered(Path(f'{tmpl_name}.tmpl'), ctx)
+
+    def do_POST(self) -> None:
+        """"Route POST requests to respective handlers."""
+        # pylint: disable=invalid-name
+        redir_target = Path(self.path)
+        if (file_prefixed := self.postvars.keys_prefixed(PREFIX_FILE)):
+            self.post_file_action(file_prefixed[0])
+        elif (self.pagename.startswith(PREFIX_EDIT)
+              and self.postvars.first('apply')):
+            redir_target = self.post_edit()
+        elif self.pagename.startswith(PREFIX_LEDGER):
+            redir_target = self.post_ledger_action()
+        self.redirect(redir_target)
+
+    def post_file_action(self, file_prefixed: str) -> None:
+        """Based on file_prefixed name, trigger .server.load or .save."""
+        if file_prefixed == f'{PREFIX_FILE}load':
+            self.server.load()
+        elif file_prefixed == f'{PREFIX_FILE}save':
+            self.server.save()
+
+    def post_edit(self) -> Path:
+        """Based on postvars, edit targeted Booking."""
+        booking = self.server.bookings[int(self.path_toks[2])]
+        new_lines = []
+        if self.pagename == EDIT_STRUCT:
+            line_keys = self.postvars.keys_prefixed('line_')
+            lineno_to_inputs: dict[int, list[str]] = {}
+            for key in line_keys:
+                toks = key.split('_', maxsplit=2)
+                lineno = int(toks[1])
+                if lineno not in lineno_to_inputs:
+                    lineno_to_inputs[lineno] = []
+                lineno_to_inputs[lineno] += [toks[2]]
+            indent = '  '
+            for lineno, input_names in lineno_to_inputs.items():
+                data = ''
+                comment = self.postvars.first(f'line_{lineno}_comment')
+                for name in input_names:
+                    input_ = self.postvars.first(f'line_{lineno}_{name}'
+                                                 ).strip()
+                    if name == 'date':
+                        data = input_
+                    elif name == 'target':
+                        data += f' {input_}'
+                    elif name == 'error':
+                        data = f'{indent}{input_}'
+                    elif name == 'account':
+                        data = f'{indent}{input_}'
+                    elif name in {'amount', 'currency'}:
+                        data += f'  {input_}'
+                new_lines += [
+                    DatLine(f'{data} ; {comment}' if comment else data)]
+        else:  # edit_raw
+            new_lines += [DatLine(line) for line
+                          in self.postvars.first('booking').splitlines()]
+        new_id = self.server.rewrite_booking(booking.id_, new_lines)
+        return Path('/bookings').joinpath(f'{new_id}')
+
+    def post_ledger_action(self) -> Path:
+        """Based on trigger postvar call .server.(move|copy)_booking."""
+        keys_prefixed = self.postvars.keys_prefixed(PREFIX_LEDGER)
+        action, id_str, dir_ = keys_prefixed[0].split('_', maxsplit=3)[1:]
+        id_ = int(id_str)
+        if action == 'move':
+            id_ = self.server.move_booking(id_, dir_ == 'up')
+            return Path(self.path).joinpath(f'#{id_}')
+        id_ = self.server.copy_booking(id_, dir_ == 'to_end')
+        return Path(EDIT_STRUCT).joinpath(f'{id_}')
+
+    def do_GET(self) -> None:
+        """"Route GET requests to respective handlers."""
+        # pylint: disable=invalid-name
+        if self.pagename == 'bookings':
+            self.redirect(
+                Path('/').joinpath(EDIT_STRUCT).joinpath(self.path_toks[2]))
+            return
+        ctx = {'tainted': self.server.tainted, 'path': self.path}
+        if self.pagename == 'balance':
+            self.get_balance(ctx)
+        elif self.pagename.startswith(PREFIX_EDIT):
+            self.get_edit(ctx, self.pagename == EDIT_RAW)
+        elif self.pagename.startswith(PREFIX_LEDGER):
+            self.get_ledger(ctx, self.pagename == LEDGER_RAW)
+        else:
+            self.get_ledger(ctx, False)
+
+    def get_balance(self, ctx) -> None:
+        """Display tree of calculated Accounts over .bookings[:up_incl+1]."""
+        id_ = int(self.params.first('up_incl') or '-1')
+        to_balance = (self.server.bookings[:id_ + 1] if id_ >= 0
+                      else self.server.bookings)
+        valid = 0 == len([b for b in to_balance if b.is_questionable])
+        acc_dict = Account.by_paths(Account.names_over_bookings(to_balance))
+        for booking in to_balance:
+            booking.apply_to_account_dict(acc_dict)
+        ctx['roots'] = [ac for ac in acc_dict.values() if not ac.parent]
+        ctx['valid'] = valid
+        ctx['booking'] = self.server.bookings[id_]
+        self._send_rendered('balance', ctx)
+
+    def get_edit(self, ctx, raw: bool) -> None:
+        """Display edit form for individual Booking."""
+        id_ = int(self.path_toks[2])
+        booking = self.server.bookings[id_]
+        acc_names = Account.names_over_bookings(self.server.bookings)
+        to_balance = self.server.bookings[:id_ + 1]
+        accounts_after = Account.by_paths(acc_names)
+        accounts_before = Account.by_paths(acc_names)
+        for b in to_balance:
+            if b != booking:
+                b.apply_to_account_dict(accounts_before)
+            b.apply_to_account_dict(accounts_after)
+        observed_tree: list[dict[str, Any]] = []
+        for full_name in sorted(booking.account_changes.keys()):
+            parent_children: list[dict[str, Any]] = observed_tree
+            path = ''
+            for step_name in full_name.split(':'):
+                path = ':'.join([path, step_name]) if path else step_name
+                for child in [n for n in parent_children if path == n['name']]:
+                    parent_children = child['children']
+                    continue
+                wealth_before = accounts_before[path].wealth
+                wealth_after = accounts_after[path].wealth
+                diff = {c: a for c, a in (wealth_after - wealth_before
+                                          ).moneys.items()
+                        if a != 0}
+                if diff:
+                    displayed_currencies = set(diff.keys())
+                    for wealth in wealth_before, wealth_after:
+                        wealth.ensure_currencies(displayed_currencies)
+                        wealth.purge_currencies_except(displayed_currencies)
+                    node: dict[str, Any] = {
+                            'name': path,
+                            'wealth_before': wealth_before.moneys,
+                            'wealth_diff': diff,
+                            'wealth_after': wealth_after.moneys,
+                            'children': []}
+                    parent_children += [node]
+                    parent_children = node['children']
+        ctx['id'] = id_
+        ctx['dat_lines'] = [dl if raw else dl.as_dict
+                            for dl in booking.booked_lines]
+        ctx['valid'] = 0 == len([b for b in to_balance if b.is_questionable])
+        ctx['roots'] = observed_tree
+        if not raw:
+            ctx['all_accounts'] = acc_names
+        self._send_rendered(EDIT_RAW if raw else EDIT_STRUCT, ctx)
+
+    def get_ledger(self, ctx: dict[str, Any], raw: bool) -> None:
+        """Display ledger of all Bookings."""
+        ctx['dat_lines'] = self.server.dat_lines
+        self._send_rendered(LEDGER_RAW if raw else LEDGER_STRUCT, ctx)
+
+
+class Server(PlomHttpServer):
+    """Extends parent by loading .dat file into database for Handler."""
+    bookings: list[Booking]
+    dat_lines: list[DatLine]
+    initial_gap_lines: list[DatLine]
+
+    def __init__(self, path_dat: Path, *args, **kwargs) -> None:
+        super().__init__(PATH_TEMPLATES, (SERVER_HOST, SERVER_PORT), Handler)
+        self._path_dat = path_dat
+        self.load()
+
+    def load(self) -> None:
+        """Read into ledger file at .path_dat."""
+        self.dat_lines = [
+            DatLine(line)
+            for line in self._path_dat.read_text(encoding='utf8').splitlines()]
+        self.last_save_hash = self._hash_dat_lines()
+        booked_lines: list[DatLine] = []
+        gap_lines: list[DatLine] = []
+        booking: Optional[Booking] = None
+        self.bookings, self.initial_gap_lines, last_date = [], [], ''
+        for dat_line in self.dat_lines + [DatLine('')]:
+            if dat_line.code:
+                if gap_lines:
+                    if booking:
+                        booking.gap_lines = gap_lines[:]
+                    else:
+                        self.initial_gap_lines = gap_lines[:]
+                    gap_lines.clear()
+                booked_lines += [dat_line]
+            else:
+                if booked_lines:
+                    booking = Booking(len(self.bookings), booked_lines[:])
+                    if last_date > booking.date:
+                        booking.intro_line.errors += [
+                                'date < previous valid date']
+                    else:
+                        last_date = booking.date
+                    self.bookings += [booking]
+                    booked_lines.clear()
+                gap_lines += [dat_line]
+        for booking in self.bookings:
+            booking.recalc_prev_next(self.bookings)
+        if booking:
+            booking.gap_lines = gap_lines[:-1]
+
+    def save(self) -> None:
+        """Save current state to ._path_dat."""
+        self._path_dat.write_text(
+            '\n'.join([line.raw for line in self.dat_lines]), encoding='utf8')
+        self.load()
+
+    def _hash_dat_lines(self) -> int:
+        return hash(tuple(dl.raw for dl in self.dat_lines))
+
+    @property
+    def tainted(self) -> bool:
+        """If .dat_lines different to those of last .load()."""
+        return self._hash_dat_lines() != self.last_save_hash
+
+    def _recalc_dat_lines(self) -> None:
+        self.dat_lines = self.initial_gap_lines[:]
+        for booking in self.bookings:
+            self.dat_lines += booking.booked_lines
+            self.dat_lines += booking.gap_lines
+
+    def _move_booking(self, idx_from, idx_to) -> None:
+        moving = self.bookings[idx_from]
+        if idx_from >= idx_to:                   # moving upward, deletion must
+            del self.bookings[idx_from]          # precede insertion to keep
+        self.bookings[idx_to:idx_to] = [moving]  # deletion index, downwards
+        if idx_from < idx_to:                    # the other way around keeps
+            del self.bookings[idx_from]          # insertion index
+        min_idx, max_idx = min(idx_from, idx_to), max(idx_from, idx_to)
+        for idx, booking in enumerate(self.bookings[min_idx:max_idx + 1]):
+            booking.id_ = min_idx + idx
+            booking.recalc_prev_next(self.bookings)
+
+    def move_booking(self, old_id: int, up: bool) -> int:
+        """Move Booking of old_id one step up or downwards"""
+        new_id = old_id + (-1 if up else 1)
+        self._move_booking(old_id,                     # moving down implies
+                           new_id + (0 if up else 1))  # jumping over next item
+        self._recalc_dat_lines()
+        return new_id
+
+    def rewrite_booking(self, old_id: int, new_lines: list[DatLine]) -> int:
+        """Rewrite Booking with new_lines, move if changed date."""
+        old_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():
+                booked_start = i
+            elif booked_start >= 0 and not line.code.strip():
+                gap_start_found = True
+            if not gap_start_found:
+                booked_end += 1
+            elif line.code.strip():
+                new_lines[i] = DatLine(f'; {line.code}')
+        before_gap = new_lines[:booked_start]
+        new_booked_lines = (new_lines[booked_start:booked_end]
+                            if booked_start > -1 else [])
+        after_gap = old_booking.gap_lines_copied + new_lines[booked_end:]
+        if not new_booked_lines:
+            del self.bookings[old_id]
+            for booking in self.bookings[old_id:]:
+                booking.id_ -= 1
+            summed_gap = before_gap + after_gap
+            if old_booking.id_ == 0:
+                self.initial_gap_lines += summed_gap
+            else:
+                assert old_booking.prev is not None
+                old_booking.prev.gap_lines += summed_gap
+            for neighbour in old_booking.prev, old_booking.next:
+                if neighbour:
+                    neighbour.recalc_prev_next(self.bookings)
+            self._recalc_dat_lines()
+            return old_id if old_id < len(self.bookings) else 0
+        new_date = new_booked_lines[0].code.lstrip().split(maxsplit=1)[0]
+        if new_date == old_booking.date:
+            new_booking = Booking(old_id, new_booked_lines, after_gap)
+            self.bookings[old_id] = new_booking
+            new_booking.recalc_prev_next(self.bookings)
+        else:
+            i_booking = self.bookings[0]
+            new_idx = None
+            while i_booking.next:
+                if not i_booking.prev and i_booking.date > new_date:
+                    new_idx = i_booking.id_
+                    break
+                if i_booking.next.date > new_date:
+                    break
+                i_booking = i_booking.next
+            if new_idx is None:
+                new_idx = i_booking.id_ + 1
+            # ensure that, if we land in group of like-dated Bookings, we
+            # land on the edge closest to our last position
+            if i_booking.date == new_date and old_id < i_booking.id_:
+                new_idx = [b for b in self.bookings
+                           if b.date == new_date][0].id_
+            new_booking = Booking(new_idx, new_booked_lines, after_gap)
+            self.bookings[old_id] = new_booking
+            self._move_booking(old_id, new_idx)
+        if new_booking.id_ == 0:
+            self.initial_gap_lines += before_gap
+        else:
+            assert new_booking.prev is not None
+            new_booking.prev.gap_lines += before_gap
+        self._recalc_dat_lines()
+        return new_booking.id_
+
+    def copy_booking(self, id_: int, to_end: bool) -> int:
+        """Add copy of Booking of id_ to_end of ledger, or after copied."""
+        copied = self.bookings[id_]
+        new_id = len(self.bookings) if to_end else copied.id_ + 1
+        if to_end:
+            intro_comment = copied.booked_lines[0].comment
+            intro = DatLine(
+                f'{dt_date.today().isoformat()} {copied.target}'
+                + (f' ; {intro_comment}' if intro_comment else ''))
+            new_booking = Booking(new_id,
+                                  [intro] + copied.booked_lines_copied[1:],
+                                  copied.gap_lines_copied)
+            self.bookings += [new_booking]
+        else:
+            new_booking = Booking(new_id,
+                                  copied.booked_lines_copied,
+                                  copied.gap_lines_copied)
+            self.bookings[new_id:new_id] = [new_booking]
+            for b in self.bookings[new_id + 1:]:
+                b.id_ += 1
+        new_booking.recalc_prev_next(self.bookings)
+        self._recalc_dat_lines()
+        return new_id
+
+
+if __name__ == "__main__":
+    if not LEDGER_DAT:
+        print("LEDGER_DAT environment variable not set.")
+        sys_exit(1)
+    Server(Path(LEDGER_DAT)).serve()
diff --git a/src/templates/_base.tmpl b/src/templates/_base.tmpl
new file mode 100644 (file)
index 0000000..a8d04d3
--- /dev/null
@@ -0,0 +1,29 @@
+{% import '_macros.tmpl' as macros %}
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<script>
+{% block script %}{% endblock %}
+</script>
+<style>
+body { background-color: white; font-family: sans-serif; }
+#header { position: sticky; top: 0; background-color: #ffffff; }
+tr.alternating:nth-child(odd) { background-color: #dcdcdc; }
+tr.alternating:nth-child(even) { background-color: #ffffff; }
+td { margin: 0; padding: 0; text-align: left; vertical-align: top; }
+input { background-color: transparent; }
+span.warning, table.warning tbody tr td, tr.warning td { background-color: #ff8888; }
+{% block css %}{% endblock %}
+</style>
+</head>
+<body>
+<div id="header">
+<form action="{{path}}" method="POST">
+ledger <a href="/ledger_structured">structured</a> / <a href="/ledger_raw">raw</a> · <a href="/balance">balance</a> · <input type="submit" name="file_load" value="reload" />{% if tainted %} · <span class="warning">unsaved changes: <input type="submit" name="file_save" value="save"></span>{% endif %}
+</form>
+<hr />
+</div>
+{% block content %}{% endblock %}
+</body>
+</html>
diff --git a/src/templates/_macros.tmpl b/src/templates/_macros.tmpl
new file mode 100644 (file)
index 0000000..aef28ae
--- /dev/null
@@ -0,0 +1,163 @@
+{% macro css_td_money() %}
+td.amt { text-align: right }
+td.amt, td.curr { font-family: monospace; font-size: 1.3em; }
+{% endmacro %}
+
+
+{% macro css_td_money_balance() %}
+td.balance.amt { width: 10em; }
+td.balance.curr { width: 3em; }
+{% endmacro %}
+
+
+{% macro css_errors() %}
+td.invalid, tr.warning td.invalid { background-color: #ff0000; }
+{% endmacro %}
+
+
+{% macro css_ledger_index_col() %}
+table.ledger tr > td:first-child { background-color: white; }
+{% endmacro %}
+
+
+{% macro tr_money_balance(amt, curr) %}
+<tr>
+<td class="balance amt">{{amt}}</td>
+<td class="balance curr">{{curr|truncate(4,true,"…")}}</td>
+</tr>
+{% endmacro %}
+
+
+{% macro table_dat_lines(dat_lines, raw) %}
+<form action="/ledger_{% if raw %}raw{% else %}structured{% endif %}" method="POST">
+<table class="ledger">
+{% for dat_line in dat_lines %}
+  <tr class="alternating{% if dat_line.is_questionable %} warning{% endif %}">
+  {% if dat_line.is_intro %}
+    <td id="{{dat_line.booking_id}}"><a href="#{{dat_line.booking_id}}">[#]</a><input type="submit" name="ledger_move_{{dat_line.booking_id}}_up" value="^"{% if not dat_line.booking.can_move(1) %} disabled{% endif %}/></td>
+  {% elif dat_line.booking_line.idx == 1 %}
+    <td><a href="/balance?up_incl={{dat_line.booking_id}}">[b]</a><input type="submit" name="ledger_move_{{dat_line.booking_id}}_down" value="v"{% if not dat_line.booking.can_move(0) %} disabled{% endif %}/></td>
+  {% elif dat_line.booking_line.idx == 2 %}
+    <td><input type="submit" name="ledger_copy_{{dat_line.booking_id}}_here" value="c" /><input type="submit" name="ledger_copy_{{dat_line.booking_id}}_to_end" value="C" /></td>
+  {% else %}
+    <td></td>
+  {% endif %}
+  {% if raw %}
+    <td{% if dat_line.error %} class="invalid"{% endif %}>
+    {% if dat_line.is_intro %}
+      <a href="/bookings/{{dat_line.booking_id}}"/>{{dat_line.raw_nbsp|safe}}</a>
+    {% else %}
+      {{dat_line.raw_nbsp|safe}}
+    {% endif %}
+    </td>
+  {% else %}
+    {% if dat_line.is_intro %}
+      <td class="date {% if dat_line.error %} invalid{% endif %}" colspan=2><a href="/bookings/{{dat_line.booking_id}}">{{dat_line.booking.date}}</a></td>
+      <td{% if dat_line.error %} class="invalid"{% endif %}>{{dat_line.booking.target}}</td>
+      <td>{{dat_line.comment}}</td>
+    {% elif dat_line.error %}
+      <td class="invalid" colspan=3>{{dat_line.code}}</td>
+      <td>{{dat_line.comment}}</td>
+    {% elif dat_line.booking_line %}
+      <td class="amt">{{dat_line.booking_line.amount_short}}</td>
+      <td class="curr">{{dat_line.booking_line.currency|truncate(4,true,"…")}}</td>
+      <td>{{dat_line.booking_line.account}}</td>
+      <td>{{dat_line.comment}}</td>
+    {% else %}
+      <td colspan=2></td><td colspan=2>{{dat_line.comment}}&nbsp;</td>
+    {% endif %}
+  {% endif %}
+  </tr>
+  {% if dat_line.error and not raw %}
+    <tr class="alternating warning">
+    <td></td>
+    <td class="invalid" colspan=3>{{dat_line.error}}</td>
+    <td></td>
+    </tr>
+  {% endif %}
+{% endfor %}
+</table>
+</form>
+{% endmacro %}
+
+
+{% macro taint_js() %}
+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((span) => {
+    let links_text = '';
+    Array.from(span.childNodes).forEach((node) => {
+      links_text += node.textContent + ' ';
+    });
+    span.innerHTML = '';
+    const del = document.createElement("del");
+    span.appendChild(del);
+    del.textContent = links_text;
+  });
+  // 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;
+      });
+  });
+}
+{% endmacro %}
+
+
+{% macro edit_bar(target, id) %}
+<span class="disable_on_change">
+<a href="/bookings/{{id-1}}">prev</a> · <a href="/bookings/{{id+1}}">next</a>
+</span>
+<input class="enable_on_change" type="submit" name="apply" value="apply" disabled />
+<input class="enable_on_change" type="submit" name="revert" value="revert" disabled />
+<span class="disable_on_change">
+<a href="/edit_{{target}}/{{id}}">switch to {{target}}</a> · <a href="/balance?up_incl={{id}}">balance after</a> · <a href="/ledger_structured/#{{id}}">in ledger</a>
+</span>
+<hr />
+{% endmacro %}
+
+
+{% macro booking_balance_account_with_children(account) %}
+<tr class="alternating">
+<td>{{account.name}}{% if account.children %}:{% endif %}</td>
+<td class="money">
+<table>
+{% for curr, amt in account.wealth_before.items() %}
+  {{ tr_money_balance(amt, curr) }}
+{% endfor %}
+</table>
+</td>
+<td class="money">
+<table>
+{% for curr, amt in account.wealth_diff.items() %}
+  {{ tr_money_balance(amt, curr) }}
+{% endfor %}
+</table>
+</td>
+<td class="money">
+<table>
+{% for curr, amt in account.wealth_after.items() %}
+  {{ tr_money_balance(amt, curr) }}
+{% endfor %}
+</table>
+</td>
+</tr>
+{% for child in account.children %}
+  {{ booking_balance_account_with_children(child) }}
+{% endfor %}
+{% endmacro %}
+
+
+{% macro booking_balance(valid, roots) %}
+<hr />
+<table{% if not valid %} class="warning"{% endif %}>
+<tr class="alternating"><th>account</th><th>before</th><th>diff</th><th>after</th></tr>
+{% for root in roots %}
+{{ booking_balance_account_with_children(root) }}
+{% endfor %}
+</table>
+{% endmacro %}
diff --git a/src/templates/balance.tmpl b/src/templates/balance.tmpl
new file mode 100644 (file)
index 0000000..6bbb648
--- /dev/null
@@ -0,0 +1,59 @@
+{% extends '_base.tmpl' %}
+
+
+{% macro account_with_children(account, indent) %}
+  <tr class="alternating">
+  <td class="money">
+  {% if account.wealth.moneys|length == 1 %}
+    <table>
+    {% for curr, amt in account.wealth.moneys.items() %}
+      {{ macros.tr_money_balance(amt, curr) }}
+    {% endfor %}
+    </table>
+  {% else %}
+    <details>
+    <summary>
+    <table>
+    {% for curr, amt in account.wealth.moneys.items() %}
+      {% if 1 == loop.index %}
+        {{ macros.tr_money_balance(amt, curr) }}
+      {% endif %}
+    {% endfor %}
+    </table>
+    </summary>
+    <table>
+    {% for curr, amt in account.wealth.moneys.items() %}
+      {% if 1 < loop.index %}
+        {{ macros.tr_money_balance(amt, curr) }}
+      {% endif %}
+    {% endfor %}
+    </table>
+    </details>
+  {% endif %}
+  </td>
+  <td class="acc"><span class="indent">{% for i in range(indent) %}&nbsp;{% endfor %}</span>{% if account.parent %}:{% endif %}{{account.basename}}{% if account.children %}:{% endif %}</td>
+  </tr>
+  {% for child in account.children %}
+    {{ account_with_children(child, indent=indent+1) }}
+  {% endfor %}
+{% 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 %}
+<p>balance after <a href="/bookings/{{booking.id_}}">booking {{booking.id_}} ({{booking.date}}: {{booking.target}})</a></p>
+<table{% if not valid %} class="warning"{% endif %}>
+{% for root in roots %}
+{{ account_with_children(root, indent=0) }}
+{% endfor %}
+</table>
+{% endblock %}
diff --git a/src/templates/edit_raw.tmpl b/src/templates/edit_raw.tmpl
new file mode 100644 (file)
index 0000000..adfccbc
--- /dev/null
@@ -0,0 +1,24 @@
+{% extends '_base.tmpl' %}
+
+
+{% block css %}
+{{ macros.css_td_money() }}
+{{ macros.css_td_money_balance() }}
+{{ macros.css_errors() }}
+{% endblock %}
+
+
+{% block script %}
+{{ macros.taint_js() }}
+{% endblock %}
+
+
+{% block content %}
+<form action="/edit_raw/{{id}}" method="POST">
+{{ macros.edit_bar("structured", id) }}
+<textarea name="booking" cols=100 rows=100 oninput="taint()">
+{% for dat_line in dat_lines %}{{ dat_line.raw }}
+{% endfor %}</textarea>
+</form>
+{{ macros.booking_balance(valid, roots) }}
+{% endblock %}
diff --git a/src/templates/edit_structured.tmpl b/src/templates/edit_structured.tmpl
new file mode 100644 (file)
index 0000000..8569dea
--- /dev/null
@@ -0,0 +1,175 @@
+{% 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; }
+{% endblock %}
+
+
+{% block script %}
+var dat_lines = {{dat_lines|tojson|safe}};
+
+{{ macros.taint_js() }}
+
+function update_form() {
+  // catch and empty table
+  const table = document.getElementById("dat_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,
+    // 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.disabled = disabled;
+    btn.onclick = function() {
+      let n_lines_jumped = 0;
+      for (let i = 0; i < table.rows.length; i++) {
+        const row = table.rows[i];
+        if (row.classList.contains('warning')) {
+          n_lines_jumped++;
+          continue;
+        };
+        for (const input of table.rows[i].querySelectorAll('td input')) {
+          const line_to_update = dat_lines[i - n_lines_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.booking_line.date = input.value;
+          } else if (input.name.endsWith('target')) {
+            line_to_update.booking_line.target = input.value;
+          } else if (input.name.endsWith('account')) {
+            line_to_update.booking_line.account = input.value;
+          } else if (input.name.endsWith('amount')) {
+            line_to_update.booking_line.amount = input.value;
+          } else if (input.name.endsWith('currency')) {
+            line_to_update.booking_line.currency = input.value;
+          }
+        }
+      }
+      onclick();
+      taint();
+      update_form();
+    };
+  }
+  function add_td(tr, colspan=1) {
+    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");
+    table.appendChild(tr);
+
+    // add line inputs
+    function setup_input_td(tr, colspan) {
+      const td = add_td(tr, colspan);
+      if (dat_line.error) { td.classList.add("invalid"); };
+      return td;
+    }
+    function add_input(td, name, value, size) {
+      const input = document.createElement("input");
+      td.appendChild(input);
+      input.name = `line_${i}_${name}`;
+      input.value = value.trim();
+      input.size = size;
+      input.oninput = taint;
+      return input;
+    }
+    function add_td_input(name, value, size=20, colspan=1) {
+      return add_input(setup_input_td(tr, colspan), name, value, size);
+    }
+    if (dat_line.is_intro) {
+      const td = setup_input_td(tr, 3);
+      const date_input = add_input(td, 'date', dat_line.booking_line.date, 10)
+      date_input.classList.add('date_input');
+      add_input(td, 'target', dat_line.booking_line.target, 35)
+    } else if (!dat_line.error) {  // i.e. valid TransferLine
+      const acc_input = add_td_input('account', dat_line.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.booking_line.amount == 'None' ? '' : dat_line.booking_line.amount, 12);
+      amt_input.pattern = '^-?[0-9]+(\.[0-9]+)?$';
+      amt_input.classList.add("number_input");
+      // ensure integer amounts at least line up with double-digit decimals
+      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.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 action buttons, with "delete" after some safety distance
+    const td_btns = add_td(tr);
+    add_button(td_btns, '^', i > 1 ? false : true, function() {
+      const prev_line = dat_lines[i-1];
+      dat_lines.splice(i-1, 1);
+      dat_lines.splice(i, 0, prev_line);
+    });
+    add_button(td_btns, 'v', (i && i+1 < dat_lines.length) ? false : true, function() {
+      const next_line = dat_lines[i];
+      dat_lines.splice(i, 1);
+      dat_lines.splice(i+1, 0, next_line);
+    });
+    td_btns.appendChild(document.createTextNode(' · · · '))
+    add_button(td_btns, 'delete',  i > 0 ? false : true, function() { dat_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");
+    }
+  }
+
+  // add "add line" row
+  const tr = document.createElement("tr");
+  table.appendChild(tr);
+  const td = add_td(tr, 5);
+  add_button(td, 'add line', false, function() {
+   new_line = {error: '', comment: '', booking_line: {account: '', amount: '', currency: ''}};
+   dat_lines.push(new_line);
+  });
+
+  // make all rows alternate background color for better readability
+  Array.from(table.rows).forEach((tr) => {
+      tr.classList.add('alternating');
+  });
+}
+
+window.onload = update_form;
+{% endblock %}
+
+
+{% block content %}
+<form action="/edit_structured/{{id}}" method="POST">
+{{ macros.edit_bar("raw", id) }}
+<table id="dat_lines">
+</table>
+</form>
+<datalist id="all_accounts">
+{% for acc in all_accounts %}
+<option value="{{acc}}">{{acc}}</a>
+{% endfor %}
+</datalist>
+{{ macros.booking_balance(valid, roots) }}
+{% endblock %}
diff --git a/src/templates/ledger_raw.tmpl b/src/templates/ledger_raw.tmpl
new file mode 100644 (file)
index 0000000..7f803ab
--- /dev/null
@@ -0,0 +1,14 @@
+{% 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; }
+{% endblock %}
+
+{% block content %}
+{{ macros.table_dat_lines(dat_lines, raw=true) }}
+{% endblock %}
+
diff --git a/src/templates/ledger_structured.tmpl b/src/templates/ledger_structured.tmpl
new file mode 100644 (file)
index 0000000..da1f46f
--- /dev/null
@@ -0,0 +1,15 @@
+{% extends '_base.tmpl' %}
+
+
+{% block css %}
+{{ macros.css_td_money() }}
+{{ macros.css_errors() }}
+{{ macros.css_ledger_index_col() }}
+table.ledger > tbody > tr > td.date, table.ledger > tbody > tr > td:first-child { font-family: monospace; font-size: 1.3em; text-align: center; }
+table.ledger > tbody > tr > td { vertical-align: middle; }
+table.ledger > tbody > tr > td:first-child { white-space: nowrap; }
+{% endblock %}
+
+{% block content %}
+{{ macros.table_dat_lines(dat_lines, raw=false) }}
+{% endblock %}
diff --git a/templates/_base.tmpl b/templates/_base.tmpl
deleted file mode 100644 (file)
index a8d04d3..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-{% import '_macros.tmpl' as macros %}
-<!DOCTYPE html>
-<html>
-<head>
-<meta charset="UTF-8">
-<script>
-{% block script %}{% endblock %}
-</script>
-<style>
-body { background-color: white; font-family: sans-serif; }
-#header { position: sticky; top: 0; background-color: #ffffff; }
-tr.alternating:nth-child(odd) { background-color: #dcdcdc; }
-tr.alternating:nth-child(even) { background-color: #ffffff; }
-td { margin: 0; padding: 0; text-align: left; vertical-align: top; }
-input { background-color: transparent; }
-span.warning, table.warning tbody tr td, tr.warning td { background-color: #ff8888; }
-{% block css %}{% endblock %}
-</style>
-</head>
-<body>
-<div id="header">
-<form action="{{path}}" method="POST">
-ledger <a href="/ledger_structured">structured</a> / <a href="/ledger_raw">raw</a> · <a href="/balance">balance</a> · <input type="submit" name="file_load" value="reload" />{% if tainted %} · <span class="warning">unsaved changes: <input type="submit" name="file_save" value="save"></span>{% endif %}
-</form>
-<hr />
-</div>
-{% block content %}{% endblock %}
-</body>
-</html>
diff --git a/templates/_macros.tmpl b/templates/_macros.tmpl
deleted file mode 100644 (file)
index aef28ae..0000000
+++ /dev/null
@@ -1,163 +0,0 @@
-{% macro css_td_money() %}
-td.amt { text-align: right }
-td.amt, td.curr { font-family: monospace; font-size: 1.3em; }
-{% endmacro %}
-
-
-{% macro css_td_money_balance() %}
-td.balance.amt { width: 10em; }
-td.balance.curr { width: 3em; }
-{% endmacro %}
-
-
-{% macro css_errors() %}
-td.invalid, tr.warning td.invalid { background-color: #ff0000; }
-{% endmacro %}
-
-
-{% macro css_ledger_index_col() %}
-table.ledger tr > td:first-child { background-color: white; }
-{% endmacro %}
-
-
-{% macro tr_money_balance(amt, curr) %}
-<tr>
-<td class="balance amt">{{amt}}</td>
-<td class="balance curr">{{curr|truncate(4,true,"…")}}</td>
-</tr>
-{% endmacro %}
-
-
-{% macro table_dat_lines(dat_lines, raw) %}
-<form action="/ledger_{% if raw %}raw{% else %}structured{% endif %}" method="POST">
-<table class="ledger">
-{% for dat_line in dat_lines %}
-  <tr class="alternating{% if dat_line.is_questionable %} warning{% endif %}">
-  {% if dat_line.is_intro %}
-    <td id="{{dat_line.booking_id}}"><a href="#{{dat_line.booking_id}}">[#]</a><input type="submit" name="ledger_move_{{dat_line.booking_id}}_up" value="^"{% if not dat_line.booking.can_move(1) %} disabled{% endif %}/></td>
-  {% elif dat_line.booking_line.idx == 1 %}
-    <td><a href="/balance?up_incl={{dat_line.booking_id}}">[b]</a><input type="submit" name="ledger_move_{{dat_line.booking_id}}_down" value="v"{% if not dat_line.booking.can_move(0) %} disabled{% endif %}/></td>
-  {% elif dat_line.booking_line.idx == 2 %}
-    <td><input type="submit" name="ledger_copy_{{dat_line.booking_id}}_here" value="c" /><input type="submit" name="ledger_copy_{{dat_line.booking_id}}_to_end" value="C" /></td>
-  {% else %}
-    <td></td>
-  {% endif %}
-  {% if raw %}
-    <td{% if dat_line.error %} class="invalid"{% endif %}>
-    {% if dat_line.is_intro %}
-      <a href="/bookings/{{dat_line.booking_id}}"/>{{dat_line.raw_nbsp|safe}}</a>
-    {% else %}
-      {{dat_line.raw_nbsp|safe}}
-    {% endif %}
-    </td>
-  {% else %}
-    {% if dat_line.is_intro %}
-      <td class="date {% if dat_line.error %} invalid{% endif %}" colspan=2><a href="/bookings/{{dat_line.booking_id}}">{{dat_line.booking.date}}</a></td>
-      <td{% if dat_line.error %} class="invalid"{% endif %}>{{dat_line.booking.target}}</td>
-      <td>{{dat_line.comment}}</td>
-    {% elif dat_line.error %}
-      <td class="invalid" colspan=3>{{dat_line.code}}</td>
-      <td>{{dat_line.comment}}</td>
-    {% elif dat_line.booking_line %}
-      <td class="amt">{{dat_line.booking_line.amount_short}}</td>
-      <td class="curr">{{dat_line.booking_line.currency|truncate(4,true,"…")}}</td>
-      <td>{{dat_line.booking_line.account}}</td>
-      <td>{{dat_line.comment}}</td>
-    {% else %}
-      <td colspan=2></td><td colspan=2>{{dat_line.comment}}&nbsp;</td>
-    {% endif %}
-  {% endif %}
-  </tr>
-  {% if dat_line.error and not raw %}
-    <tr class="alternating warning">
-    <td></td>
-    <td class="invalid" colspan=3>{{dat_line.error}}</td>
-    <td></td>
-    </tr>
-  {% endif %}
-{% endfor %}
-</table>
-</form>
-{% endmacro %}
-
-
-{% macro taint_js() %}
-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((span) => {
-    let links_text = '';
-    Array.from(span.childNodes).forEach((node) => {
-      links_text += node.textContent + ' ';
-    });
-    span.innerHTML = '';
-    const del = document.createElement("del");
-    span.appendChild(del);
-    del.textContent = links_text;
-  });
-  // 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;
-      });
-  });
-}
-{% endmacro %}
-
-
-{% macro edit_bar(target, id) %}
-<span class="disable_on_change">
-<a href="/bookings/{{id-1}}">prev</a> · <a href="/bookings/{{id+1}}">next</a>
-</span>
-<input class="enable_on_change" type="submit" name="apply" value="apply" disabled />
-<input class="enable_on_change" type="submit" name="revert" value="revert" disabled />
-<span class="disable_on_change">
-<a href="/edit_{{target}}/{{id}}">switch to {{target}}</a> · <a href="/balance?up_incl={{id}}">balance after</a> · <a href="/ledger_structured/#{{id}}">in ledger</a>
-</span>
-<hr />
-{% endmacro %}
-
-
-{% macro booking_balance_account_with_children(account) %}
-<tr class="alternating">
-<td>{{account.name}}{% if account.children %}:{% endif %}</td>
-<td class="money">
-<table>
-{% for curr, amt in account.wealth_before.items() %}
-  {{ tr_money_balance(amt, curr) }}
-{% endfor %}
-</table>
-</td>
-<td class="money">
-<table>
-{% for curr, amt in account.wealth_diff.items() %}
-  {{ tr_money_balance(amt, curr) }}
-{% endfor %}
-</table>
-</td>
-<td class="money">
-<table>
-{% for curr, amt in account.wealth_after.items() %}
-  {{ tr_money_balance(amt, curr) }}
-{% endfor %}
-</table>
-</td>
-</tr>
-{% for child in account.children %}
-  {{ booking_balance_account_with_children(child) }}
-{% endfor %}
-{% endmacro %}
-
-
-{% macro booking_balance(valid, roots) %}
-<hr />
-<table{% if not valid %} class="warning"{% endif %}>
-<tr class="alternating"><th>account</th><th>before</th><th>diff</th><th>after</th></tr>
-{% for root in roots %}
-{{ booking_balance_account_with_children(root) }}
-{% endfor %}
-</table>
-{% endmacro %}
diff --git a/templates/balance.tmpl b/templates/balance.tmpl
deleted file mode 100644 (file)
index 6bbb648..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% macro account_with_children(account, indent) %}
-  <tr class="alternating">
-  <td class="money">
-  {% if account.wealth.moneys|length == 1 %}
-    <table>
-    {% for curr, amt in account.wealth.moneys.items() %}
-      {{ macros.tr_money_balance(amt, curr) }}
-    {% endfor %}
-    </table>
-  {% else %}
-    <details>
-    <summary>
-    <table>
-    {% for curr, amt in account.wealth.moneys.items() %}
-      {% if 1 == loop.index %}
-        {{ macros.tr_money_balance(amt, curr) }}
-      {% endif %}
-    {% endfor %}
-    </table>
-    </summary>
-    <table>
-    {% for curr, amt in account.wealth.moneys.items() %}
-      {% if 1 < loop.index %}
-        {{ macros.tr_money_balance(amt, curr) }}
-      {% endif %}
-    {% endfor %}
-    </table>
-    </details>
-  {% endif %}
-  </td>
-  <td class="acc"><span class="indent">{% for i in range(indent) %}&nbsp;{% endfor %}</span>{% if account.parent %}:{% endif %}{{account.basename}}{% if account.children %}:{% endif %}</td>
-  </tr>
-  {% for child in account.children %}
-    {{ account_with_children(child, indent=indent+1) }}
-  {% endfor %}
-{% 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 %}
-<p>balance after <a href="/bookings/{{booking.id_}}">booking {{booking.id_}} ({{booking.date}}: {{booking.target}})</a></p>
-<table{% if not valid %} class="warning"{% endif %}>
-{% for root in roots %}
-{{ account_with_children(root, indent=0) }}
-{% endfor %}
-</table>
-{% endblock %}
diff --git a/templates/edit_raw.tmpl b/templates/edit_raw.tmpl
deleted file mode 100644 (file)
index adfccbc..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block css %}
-{{ macros.css_td_money() }}
-{{ macros.css_td_money_balance() }}
-{{ macros.css_errors() }}
-{% endblock %}
-
-
-{% block script %}
-{{ macros.taint_js() }}
-{% endblock %}
-
-
-{% block content %}
-<form action="/edit_raw/{{id}}" method="POST">
-{{ macros.edit_bar("structured", id) }}
-<textarea name="booking" cols=100 rows=100 oninput="taint()">
-{% for dat_line in dat_lines %}{{ dat_line.raw }}
-{% endfor %}</textarea>
-</form>
-{{ macros.booking_balance(valid, roots) }}
-{% endblock %}
diff --git a/templates/edit_structured.tmpl b/templates/edit_structured.tmpl
deleted file mode 100644 (file)
index 8569dea..0000000
+++ /dev/null
@@ -1,175 +0,0 @@
-{% 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; }
-{% endblock %}
-
-
-{% block script %}
-var dat_lines = {{dat_lines|tojson|safe}};
-
-{{ macros.taint_js() }}
-
-function update_form() {
-  // catch and empty table
-  const table = document.getElementById("dat_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,
-    // 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.disabled = disabled;
-    btn.onclick = function() {
-      let n_lines_jumped = 0;
-      for (let i = 0; i < table.rows.length; i++) {
-        const row = table.rows[i];
-        if (row.classList.contains('warning')) {
-          n_lines_jumped++;
-          continue;
-        };
-        for (const input of table.rows[i].querySelectorAll('td input')) {
-          const line_to_update = dat_lines[i - n_lines_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.booking_line.date = input.value;
-          } else if (input.name.endsWith('target')) {
-            line_to_update.booking_line.target = input.value;
-          } else if (input.name.endsWith('account')) {
-            line_to_update.booking_line.account = input.value;
-          } else if (input.name.endsWith('amount')) {
-            line_to_update.booking_line.amount = input.value;
-          } else if (input.name.endsWith('currency')) {
-            line_to_update.booking_line.currency = input.value;
-          }
-        }
-      }
-      onclick();
-      taint();
-      update_form();
-    };
-  }
-  function add_td(tr, colspan=1) {
-    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");
-    table.appendChild(tr);
-
-    // add line inputs
-    function setup_input_td(tr, colspan) {
-      const td = add_td(tr, colspan);
-      if (dat_line.error) { td.classList.add("invalid"); };
-      return td;
-    }
-    function add_input(td, name, value, size) {
-      const input = document.createElement("input");
-      td.appendChild(input);
-      input.name = `line_${i}_${name}`;
-      input.value = value.trim();
-      input.size = size;
-      input.oninput = taint;
-      return input;
-    }
-    function add_td_input(name, value, size=20, colspan=1) {
-      return add_input(setup_input_td(tr, colspan), name, value, size);
-    }
-    if (dat_line.is_intro) {
-      const td = setup_input_td(tr, 3);
-      const date_input = add_input(td, 'date', dat_line.booking_line.date, 10)
-      date_input.classList.add('date_input');
-      add_input(td, 'target', dat_line.booking_line.target, 35)
-    } else if (!dat_line.error) {  // i.e. valid TransferLine
-      const acc_input = add_td_input('account', dat_line.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.booking_line.amount == 'None' ? '' : dat_line.booking_line.amount, 12);
-      amt_input.pattern = '^-?[0-9]+(\.[0-9]+)?$';
-      amt_input.classList.add("number_input");
-      // ensure integer amounts at least line up with double-digit decimals
-      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.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 action buttons, with "delete" after some safety distance
-    const td_btns = add_td(tr);
-    add_button(td_btns, '^', i > 1 ? false : true, function() {
-      const prev_line = dat_lines[i-1];
-      dat_lines.splice(i-1, 1);
-      dat_lines.splice(i, 0, prev_line);
-    });
-    add_button(td_btns, 'v', (i && i+1 < dat_lines.length) ? false : true, function() {
-      const next_line = dat_lines[i];
-      dat_lines.splice(i, 1);
-      dat_lines.splice(i+1, 0, next_line);
-    });
-    td_btns.appendChild(document.createTextNode(' · · · '))
-    add_button(td_btns, 'delete',  i > 0 ? false : true, function() { dat_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");
-    }
-  }
-
-  // add "add line" row
-  const tr = document.createElement("tr");
-  table.appendChild(tr);
-  const td = add_td(tr, 5);
-  add_button(td, 'add line', false, function() {
-   new_line = {error: '', comment: '', booking_line: {account: '', amount: '', currency: ''}};
-   dat_lines.push(new_line);
-  });
-
-  // make all rows alternate background color for better readability
-  Array.from(table.rows).forEach((tr) => {
-      tr.classList.add('alternating');
-  });
-}
-
-window.onload = update_form;
-{% endblock %}
-
-
-{% block content %}
-<form action="/edit_structured/{{id}}" method="POST">
-{{ macros.edit_bar("raw", id) }}
-<table id="dat_lines">
-</table>
-</form>
-<datalist id="all_accounts">
-{% for acc in all_accounts %}
-<option value="{{acc}}">{{acc}}</a>
-{% endfor %}
-</datalist>
-{{ macros.booking_balance(valid, roots) }}
-{% endblock %}
diff --git a/templates/ledger_raw.tmpl b/templates/ledger_raw.tmpl
deleted file mode 100644 (file)
index 7f803ab..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-{% 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; }
-{% endblock %}
-
-{% block content %}
-{{ macros.table_dat_lines(dat_lines, raw=true) }}
-{% endblock %}
-
diff --git a/templates/ledger_structured.tmpl b/templates/ledger_structured.tmpl
deleted file mode 100644 (file)
index da1f46f..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends '_base.tmpl' %}
-
-
-{% block css %}
-{{ macros.css_td_money() }}
-{{ macros.css_errors() }}
-{{ macros.css_ledger_index_col() }}
-table.ledger > tbody > tr > td.date, table.ledger > tbody > tr > td:first-child { font-family: monospace; font-size: 1.3em; text-align: center; }
-table.ledger > tbody > tr > td { vertical-align: middle; }
-table.ledger > tbody > tr > td:first-child { white-space: nowrap; }
-{% endblock %}
-
-{% block content %}
-{{ macros.table_dat_lines(dat_lines, raw=false) }}
-{% endblock %}