From 1e47710da184388afa04a77047b14c08c279efb4 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Mon, 20 Jan 2025 10:42:46 +0100 Subject: [PATCH] Initial server code and template commit. --- ledger.py | 122 +++++++++++++++++++++++++++++++++++++++++++ templates/_base.tmpl | 17 ++++++ templates/index.tmpl | 33 ++++++++++++ templates/raw.tmpl | 22 ++++++++ 4 files changed, 194 insertions(+) create mode 100755 ledger.py create mode 100644 templates/_base.tmpl create mode 100644 templates/index.tmpl create mode 100644 templates/raw.tmpl diff --git a/ledger.py b/ledger.py new file mode 100755 index 0000000..746d8a3 --- /dev/null +++ b/ledger.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Viewer for ledger .dat files.""" +from decimal import Decimal +from os import environ +from pathlib import Path +from sys import exit as sys_exit +from typing import Optional +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') + + +class DatLine: + """Line of .dat file parsed into comments and machine-readable data.""" + + 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.type = 'no_data' + self.booking: Optional[Booking] = None + if self.code: + self.type = 'invalid' + if self.code[0].isspace(): + toks = self.code.lstrip().split() + self.acc = toks[0] + if 1 == len(toks): + self.amt, self.curr = '', '' + self.type = 'value' + elif 3 == len(toks): + amt_dec = Decimal(toks[1]) + exp = amt_dec.as_tuple().exponent + assert isinstance(exp, int) + self.amt = (f'{amt_dec:.1f}…' if exp < -2 + else f'{amt_dec:.2f}') + self.curr = toks[2] + self.type = 'value' + else: + self.type = 'intro' + + @property + def is_empty(self) -> bool: + """Return if both .code and .comment are empty.""" + return not bool(self.code or self.comment) + + @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 Booking: + """Represents lines of individual booking.""" + # pylint: disable=too-few-public-methods + + def __init__(self, id_: int, idx_start_end: tuple[int, int]) -> None: + self.id_ = id_ + self.idx_start, self.idx_end = idx_start_end + + +class Handler(PlomHttpHandler): + # pylint: disable=missing-class-docstring + mapper = PlomQueryMap + + def do_POST(self) -> None: + # pylint: disable=invalid-name,missing-function-docstring + self.redirect(Path('')) + + def do_GET(self) -> None: + # pylint: disable=invalid-name,missing-function-docstring + if self.pagename == 'booking': + b = self.server.bookings[int(self.params.first('idx'))] + dat_lines = self.server.dat_lines[b.idx_start:b.idx_end] + self.send_rendered(Path('index.tmpl'), {'dat_lines': dat_lines}) + elif self.pagename == 'raw': + self.send_rendered(Path('raw.tmpl'), + {'dat_lines': self.server.dat_lines}) + else: + self.send_rendered(Path('index.tmpl'), + {'dat_lines': self.server.dat_lines_sans_empty}) + + +class Server(PlomHttpServer): + """Extends parent by loading .dat file into database for Handler.""" + + def __init__(self, path_dat: Path, *args, **kwargs) -> None: + super().__init__(PATH_TEMPLATES, (SERVER_HOST, SERVER_PORT), Handler) + self.dat_lines = [ + DatLine(line) + for line in path_dat.read_text(encoding='utf8').splitlines()] + self.bookings: list[Booking] = [] + code_lines = [dl.code for dl in self.dat_lines] + in_booking = False + last_booking_start = -1 + for idx, code in enumerate(code_lines + ['']): + if in_booking and not code: + in_booking = False + self.bookings += [Booking(len(self.bookings), + (last_booking_start, idx))] + self.dat_lines[last_booking_start].booking = self.bookings[-1] + elif code and not in_booking: + in_booking = True + last_booking_start = idx + + @property + def dat_lines_sans_empty(self) -> list[DatLine]: + """Return only those .data_lines with .code or .comment.""" + return [dl for dl in self.dat_lines if not dl.is_empty] + + +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/templates/_base.tmpl b/templates/_base.tmpl new file mode 100644 index 0000000..8e6de41 --- /dev/null +++ b/templates/_base.tmpl @@ -0,0 +1,17 @@ + + + + + + + +home · raw +
+{% block content %}{% endblock %} + + diff --git a/templates/index.tmpl b/templates/index.tmpl new file mode 100644 index 0000000..ca1e096 --- /dev/null +++ b/templates/index.tmpl @@ -0,0 +1,33 @@ +{% extends '_base.tmpl' %} + +{% block css %} +td.amt { text-align: right } +td.amt, td.curr { font-family: monospace; font-size: 1.3em; } +td.curr { text-align: center; } +{% endblock %} + +{% block content %} +
+
+
+ +{% for l in dat_lines %} + {% if "intro" == l.type and loop.index > 1 %}{% endif %} + + {% if l.type == "intro" %} + + {% else %} + + {% endif %} + {% if l.type == "value" %} + + {% elif l.type == "intro" %} + + {% else %} + + {% endif %} + + +{% endfor %} +
 
#{{l.amt}}{{l.curr|truncate(4,true,"…")}}{{l.acc}}{{l.code}}{{l.code}}{{l.comment}}
+{% endblock %} diff --git a/templates/raw.tmpl b/templates/raw.tmpl new file mode 100644 index 0000000..34f69d1 --- /dev/null +++ b/templates/raw.tmpl @@ -0,0 +1,22 @@ +{% extends '_base.tmpl' %} + +{% block css %} +table { font-family: monospace; } +{% endblock %} + +{% block content %} + +{% for l in dat_lines %} + + {% if l.type == "intro" %} + + + {% else %} + + + {% endif %} + +{% endfor %} +
#{{l.raw_nbsp}}{{l.raw_nbsp}}
+{% endblock %} + -- 2.30.2