home · contact · privacy
Initial server code and template commit.
authorChristian Heller <c.heller@plomlompom.de>
Mon, 20 Jan 2025 09:42:46 +0000 (10:42 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 20 Jan 2025 09:42:46 +0000 (10:42 +0100)
ledger.py [new file with mode: 0755]
templates/_base.tmpl [new file with mode: 0644]
templates/index.tmpl [new file with mode: 0644]
templates/raw.tmpl [new file with mode: 0644]

diff --git a/ledger.py b/ledger.py
new file mode 100755 (executable)
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 &nbsp;, and at least one."""
+        if not self.raw:
+            return '&nbsp;'
+        return self.raw.replace(' ', '&nbsp;')
+
+
+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 (file)
index 0000000..8e6de41
--- /dev/null
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<style>
+body { background-color: white; font-family: sans-serif; }
+tr:nth-child(odd) { background-color: #dcdcdc; }
+tr.invalid > td { background-color: red; }
+{% block css %}{% endblock %}
+</style>
+</head>
+<body>
+<a href="/">home</a> · <a href="/raw">raw</a>
+<hr />
+{% block content %}{% endblock %}
+</body>
+</html>
diff --git a/templates/index.tmpl b/templates/index.tmpl
new file mode 100644 (file)
index 0000000..ca1e096
--- /dev/null
@@ -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 %}
+<form action="/" method="POST"><input type="submit" value="A" /></form>
+<form action="/" method="GET"><input type="submit" value="B" /></form>
+<hr/>
+<table>
+{% for l in dat_lines %}
+  {% if "intro" == l.type and loop.index > 1 %}<tr><td colspan=5>&nbsp;</td></tr>{% endif %}
+  <tr class="{{l.type}}">
+  {% if l.type == "intro" %}
+    <td id="{{l.booking.id_}}"><a href="#{{l.booking.id_}}">#</a></td>
+  {% else %}
+    <td></td>
+  {% endif %}
+  {% if l.type == "value" %}
+    <td class="amt">{{l.amt}}</td><td class="curr">{{l.curr|truncate(4,true,"…")}}</td><td>{{l.acc}}</td>
+  {% elif l.type == "intro" %}
+    <td class="code" colspan=3><a href="booking?idx={{l.booking.id_}}">{{l.code}}</a></td>
+  {% else %}
+    <td colspan=3>{{l.code}}</td>
+  {% endif %}
+  <td>{{l.comment}}</td>
+  </tr>
+{% endfor %}
+</table>
+{% endblock %}
diff --git a/templates/raw.tmpl b/templates/raw.tmpl
new file mode 100644 (file)
index 0000000..34f69d1
--- /dev/null
@@ -0,0 +1,22 @@
+{% extends '_base.tmpl' %}
+
+{% block css %}
+table { font-family: monospace; }
+{% endblock %}
+
+{% block content %}
+<table>
+{% for l in dat_lines %}
+  <tr class="{{l.type}}">
+  {% if l.type == "intro" %}
+    <td id="{{l.booking.id_}}"><a href="#{{l.booking.id_}}">#</a></td>
+    <td><a href="booking?idx={{l.booking.id_}}"/>{{l.raw_nbsp}}</a></td>
+  {% else %}
+    <td></td>
+    <td>{{l.raw_nbsp}}</td>
+  {% endif %}
+  </tr>
+{% endfor %}
+</table>
+{% endblock %}
+