[submodule "plomlib"]
- path = plomlib
+ path = src/plomlib
url = https://plomlompom.com/repos/clone/plomlib
--- /dev/null
+#!/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}."
+++ /dev/null
-#!/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()
--- /dev/null
+#!/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" $@
+++ /dev/null
-Subproject commit dee7c0f6218e6bdd07b477dc5d9e4b5540ffcf4a
+++ /dev/null
-Jinja2==3.1.5
--- /dev/null
+Subproject commit dee7c0f6218e6bdd07b477dc5d9e4b5540ffcf4a
--- /dev/null
+Jinja2==3.1.5
--- /dev/null
+#!/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()
--- /dev/null
+{% 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>
--- /dev/null
+{% 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}} </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 %}
--- /dev/null
+{% 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) %} {% 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 %}
--- /dev/null
+{% 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 %}
--- /dev/null
+{% 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 %}
--- /dev/null
+{% 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 %}
+
--- /dev/null
+{% 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 %}
+++ /dev/null
-{% 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>
+++ /dev/null
-{% 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}} </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 %}
+++ /dev/null
-{% 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) %} {% 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 %}
+++ /dev/null
-{% 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 %}
+++ /dev/null
-{% 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 %}
+++ /dev/null
-{% 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 %}
-
+++ /dev/null
-{% 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 %}