--- /dev/null
+#!/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()
--- /dev/null
+{% 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> </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 %}