From: Christian Heller Date: Sat, 8 Feb 2025 19:04:17 +0000 (+0100) Subject: Adapt to package installation structure used with ytplom. X-Git-Url: https://plomlompom.com/repos/%7B%7B%20web_path%20%7D%7D/%7B%7Bdb.prefix%7D%7D/booking/%7B%7Bprefix%7D%7D/day?a=commitdiff_plain;h=bcd9eb368806842adba0e11d3c9fa1aa231b3aa7;p=ledgplom Adapt to package installation structure used with ytplom. --- diff --git a/.gitmodules b/.gitmodules index 42cf7f3..4c97e95 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 index 0000000..aa84e99 --- /dev/null +++ b/install.sh @@ -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 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  , and at least one.""" - if not self.raw: - return ' ' - return self.raw.replace(' ', ' ') - - -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 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 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 index 7cb100d..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -Jinja2==3.1.5 diff --git a/src/plomlib b/src/plomlib new file mode 160000 index 0000000..dee7c0f --- /dev/null +++ b/src/plomlib @@ -0,0 +1 @@ +Subproject commit dee7c0f6218e6bdd07b477dc5d9e4b5540ffcf4a diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..7cb100d --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1 @@ +Jinja2==3.1.5 diff --git a/src/run.py b/src/run.py new file mode 100755 index 0000000..6f09306 --- /dev/null +++ b/src/run.py @@ -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  , and at least one.""" + if not self.raw: + return ' ' + return self.raw.replace(' ', ' ') + + +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 index 0000000..a8d04d3 --- /dev/null +++ b/src/templates/_base.tmpl @@ -0,0 +1,29 @@ +{% import '_macros.tmpl' as macros %} + + + + + + + + + +{% block content %}{% endblock %} + + diff --git a/src/templates/_macros.tmpl b/src/templates/_macros.tmpl new file mode 100644 index 0000000..aef28ae --- /dev/null +++ b/src/templates/_macros.tmpl @@ -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) %} + +{{amt}} +{{curr|truncate(4,true,"…")}} + +{% endmacro %} + + +{% macro table_dat_lines(dat_lines, raw) %} +
+ +{% for dat_line in dat_lines %} + + {% if dat_line.is_intro %} + + {% elif dat_line.booking_line.idx == 1 %} + + {% elif dat_line.booking_line.idx == 2 %} + + {% else %} + + {% endif %} + {% if raw %} + + {% if dat_line.is_intro %} + {{dat_line.raw_nbsp|safe}} + {% else %} + {{dat_line.raw_nbsp|safe}} + {% endif %} + + {% else %} + {% if dat_line.is_intro %} + + {{dat_line.booking.target}} + + {% elif dat_line.error %} + + + {% elif dat_line.booking_line %} + + + + + {% else %} + + {% endif %} + {% endif %} + + {% if dat_line.error and not raw %} + + + + + + {% endif %} +{% endfor %} +
[#][b]{{dat_line.booking.date}}{{dat_line.comment}}{{dat_line.code}}{{dat_line.comment}}{{dat_line.booking_line.amount_short}}{{dat_line.booking_line.currency|truncate(4,true,"…")}}{{dat_line.booking_line.account}}{{dat_line.comment}}{{dat_line.comment}} 
{{dat_line.error}}
+
+{% 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) %} + +prev · next + + + + +switch to {{target}} · balance after · in ledger + +
+{% endmacro %} + + +{% macro booking_balance_account_with_children(account) %} + +{{account.name}}{% if account.children %}:{% endif %} + + +{% for curr, amt in account.wealth_before.items() %} + {{ tr_money_balance(amt, curr) }} +{% endfor %} +
+ + + +{% for curr, amt in account.wealth_diff.items() %} + {{ tr_money_balance(amt, curr) }} +{% endfor %} +
+ + + +{% for curr, amt in account.wealth_after.items() %} + {{ tr_money_balance(amt, curr) }} +{% endfor %} +
+ + +{% for child in account.children %} + {{ booking_balance_account_with_children(child) }} +{% endfor %} +{% endmacro %} + + +{% macro booking_balance(valid, roots) %} +
+ +accountbeforediffafter +{% for root in roots %} +{{ booking_balance_account_with_children(root) }} +{% endfor %} + +{% endmacro %} diff --git a/src/templates/balance.tmpl b/src/templates/balance.tmpl new file mode 100644 index 0000000..6bbb648 --- /dev/null +++ b/src/templates/balance.tmpl @@ -0,0 +1,59 @@ +{% extends '_base.tmpl' %} + + +{% macro account_with_children(account, indent) %} + + + {% if account.wealth.moneys|length == 1 %} + + {% for curr, amt in account.wealth.moneys.items() %} + {{ macros.tr_money_balance(amt, curr) }} + {% endfor %} +
+ {% else %} +
+ + + {% for curr, amt in account.wealth.moneys.items() %} + {% if 1 == loop.index %} + {{ macros.tr_money_balance(amt, curr) }} + {% endif %} + {% endfor %} +
+
+ + {% for curr, amt in account.wealth.moneys.items() %} + {% if 1 < loop.index %} + {{ macros.tr_money_balance(amt, curr) }} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {% for i in range(indent) %} {% endfor %}{% if account.parent %}:{% endif %}{{account.basename}}{% if account.children %}:{% endif %} + + {% 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 %} +

balance after booking {{booking.id_}} ({{booking.date}}: {{booking.target}})

+ +{% for root in roots %} +{{ account_with_children(root, indent=0) }} +{% endfor %} + +{% endblock %} diff --git a/src/templates/edit_raw.tmpl b/src/templates/edit_raw.tmpl new file mode 100644 index 0000000..adfccbc --- /dev/null +++ b/src/templates/edit_raw.tmpl @@ -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 %} +
+{{ macros.edit_bar("structured", id) }} + +
+{{ macros.booking_balance(valid, roots) }} +{% endblock %} diff --git a/src/templates/edit_structured.tmpl b/src/templates/edit_structured.tmpl new file mode 100644 index 0000000..8569dea --- /dev/null +++ b/src/templates/edit_structured.tmpl @@ -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 %} +
+{{ macros.edit_bar("raw", id) }} + +
+
+ +{% for acc in all_accounts %} + +{{ macros.booking_balance(valid, roots) }} +{% endblock %} diff --git a/src/templates/ledger_raw.tmpl b/src/templates/ledger_raw.tmpl new file mode 100644 index 0000000..7f803ab --- /dev/null +++ b/src/templates/ledger_raw.tmpl @@ -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 index 0000000..da1f46f --- /dev/null +++ b/src/templates/ledger_structured.tmpl @@ -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 index a8d04d3..0000000 --- a/templates/_base.tmpl +++ /dev/null @@ -1,29 +0,0 @@ -{% import '_macros.tmpl' as macros %} - - - - - - - - - -{% block content %}{% endblock %} - - diff --git a/templates/_macros.tmpl b/templates/_macros.tmpl deleted file mode 100644 index aef28ae..0000000 --- a/templates/_macros.tmpl +++ /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) %} - -{{amt}} -{{curr|truncate(4,true,"…")}} - -{% endmacro %} - - -{% macro table_dat_lines(dat_lines, raw) %} -
- -{% for dat_line in dat_lines %} - - {% if dat_line.is_intro %} - - {% elif dat_line.booking_line.idx == 1 %} - - {% elif dat_line.booking_line.idx == 2 %} - - {% else %} - - {% endif %} - {% if raw %} - - {% if dat_line.is_intro %} - {{dat_line.raw_nbsp|safe}} - {% else %} - {{dat_line.raw_nbsp|safe}} - {% endif %} - - {% else %} - {% if dat_line.is_intro %} - - {{dat_line.booking.target}} - - {% elif dat_line.error %} - - - {% elif dat_line.booking_line %} - - - - - {% else %} - - {% endif %} - {% endif %} - - {% if dat_line.error and not raw %} - - - - - - {% endif %} -{% endfor %} -
[#][b]{{dat_line.booking.date}}{{dat_line.comment}}{{dat_line.code}}{{dat_line.comment}}{{dat_line.booking_line.amount_short}}{{dat_line.booking_line.currency|truncate(4,true,"…")}}{{dat_line.booking_line.account}}{{dat_line.comment}}{{dat_line.comment}} 
{{dat_line.error}}
-
-{% 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) %} - -prev · next - - - - -switch to {{target}} · balance after · in ledger - -
-{% endmacro %} - - -{% macro booking_balance_account_with_children(account) %} - -{{account.name}}{% if account.children %}:{% endif %} - - -{% for curr, amt in account.wealth_before.items() %} - {{ tr_money_balance(amt, curr) }} -{% endfor %} -
- - - -{% for curr, amt in account.wealth_diff.items() %} - {{ tr_money_balance(amt, curr) }} -{% endfor %} -
- - - -{% for curr, amt in account.wealth_after.items() %} - {{ tr_money_balance(amt, curr) }} -{% endfor %} -
- - -{% for child in account.children %} - {{ booking_balance_account_with_children(child) }} -{% endfor %} -{% endmacro %} - - -{% macro booking_balance(valid, roots) %} -
- -accountbeforediffafter -{% for root in roots %} -{{ booking_balance_account_with_children(root) }} -{% endfor %} - -{% endmacro %} diff --git a/templates/balance.tmpl b/templates/balance.tmpl deleted file mode 100644 index 6bbb648..0000000 --- a/templates/balance.tmpl +++ /dev/null @@ -1,59 +0,0 @@ -{% extends '_base.tmpl' %} - - -{% macro account_with_children(account, indent) %} - - - {% if account.wealth.moneys|length == 1 %} - - {% for curr, amt in account.wealth.moneys.items() %} - {{ macros.tr_money_balance(amt, curr) }} - {% endfor %} -
- {% else %} -
- - - {% for curr, amt in account.wealth.moneys.items() %} - {% if 1 == loop.index %} - {{ macros.tr_money_balance(amt, curr) }} - {% endif %} - {% endfor %} -
-
- - {% for curr, amt in account.wealth.moneys.items() %} - {% if 1 < loop.index %} - {{ macros.tr_money_balance(amt, curr) }} - {% endif %} - {% endfor %} -
-
- {% endif %} - - {% for i in range(indent) %} {% endfor %}{% if account.parent %}:{% endif %}{{account.basename}}{% if account.children %}:{% endif %} - - {% 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 %} -

balance after booking {{booking.id_}} ({{booking.date}}: {{booking.target}})

- -{% for root in roots %} -{{ account_with_children(root, indent=0) }} -{% endfor %} - -{% endblock %} diff --git a/templates/edit_raw.tmpl b/templates/edit_raw.tmpl deleted file mode 100644 index adfccbc..0000000 --- a/templates/edit_raw.tmpl +++ /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 %} -
-{{ macros.edit_bar("structured", id) }} - -
-{{ macros.booking_balance(valid, roots) }} -{% endblock %} diff --git a/templates/edit_structured.tmpl b/templates/edit_structured.tmpl deleted file mode 100644 index 8569dea..0000000 --- a/templates/edit_structured.tmpl +++ /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 %} -
-{{ macros.edit_bar("raw", id) }} - -
-
- -{% for acc in all_accounts %} - -{{ macros.booking_balance(valid, roots) }} -{% endblock %} diff --git a/templates/ledger_raw.tmpl b/templates/ledger_raw.tmpl deleted file mode 100644 index 7f803ab..0000000 --- a/templates/ledger_raw.tmpl +++ /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 index da1f46f..0000000 --- a/templates/ledger_structured.tmpl +++ /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 %}