home · contact · privacy
More internal restructuring.
authorChristian Heller <c.heller@plomlompom.de>
Wed, 19 Mar 2025 20:39:48 +0000 (21:39 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Wed, 19 Mar 2025 20:39:48 +0000 (21:39 +0100)
src/ledgplom/http.py
src/ledgplom/ledger.py

index a704c270d0fd8dac2fd74e12128197f047d6386e..9a10efb048e75928730d91ff2879a459464cebde 100644 (file)
@@ -2,10 +2,11 @@
 
 # standard libs
 from pathlib import Path
-from typing import Any
+from typing import Any, Optional
 # non-standard libs
 from plomlib.web import PlomHttpHandler, PlomHttpServer, PlomQueryMap
-from ledgplom.ledger import Account, DatLine, Ledger
+from ledgplom.ledger import (
+        Account, BookingLine, DatLine, IntroLine, Ledger, TransferLine)
 
 
 _SERVER_PORT = 8084
@@ -71,27 +72,26 @@ class _Handler(PlomHttpHandler):
                 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)]
+                line_d = {key: self.postvars.first(f'line_{lineno}_{key}')
+                          for key in input_names}
+                booking_line: Optional[BookingLine] = None
+                if 'date' in line_d:
+                    booking_line = IntroLine(
+                            line_d['date'],
+                            line_d['target'])
+                elif 'account' in line_d:
+                    booking_line = TransferLine(
+                            line_d['account'],
+                            line_d['amount'],
+                            line_d['currency'])
+                if booking_line:
+                    new_lines += [booking_line.to_dat_line(line_d['comment'])]
+                else:
+                    new_lines += [DatLine(line_d['error'], line_d['comment'],
+                                          add_indent=True)]
         else:  # edit_raw
-            new_lines += [DatLine(line) for line
+            new_lines += [DatLine.from_raw(line) for line
                           in self.postvars.first('booking').splitlines()]
         new_id = self.server.ledger.rewrite_booking(booking.id_, new_lines)
         return Path('/bookings').joinpath(f'{new_id}')
@@ -131,8 +131,9 @@ class _Handler(PlomHttpHandler):
     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')
-        ctx['roots'] = [ac for ac in self.server.ledger.accounts.values()
-                        if not ac.parent]
+        roots = [ac for ac in self.server.ledger.accounts.values()
+                 if not ac.parent]
+        ctx['roots'] = sorted(roots, key=lambda r: r.basename)
         ctx['valid'] = self.server.ledger.bookings_valid_up_incl(id_)
         ctx['booking'] = self.server.ledger.bookings[id_]
         ctx['path_up_incl'] = f'{self.path_toks[1]}?up_incl='
index 762e6c31e319f86e5f4b1125efa5bb4f2e5c57b7..a470a22034003a52d8394ea35d96872e757973d2 100644 (file)
@@ -1,6 +1,7 @@
 """Actual ledger classes."""
 
 # standard libs
+from abc import ABC, abstractmethod
 from datetime import date as dt_date
 from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
 from pathlib import Path
@@ -8,6 +9,7 @@ from typing import Any, Iterator, Optional, Self
 
 
 _PREFIX_DEF = 'def '
+_DEFAULT_INDENT = 2 * ' '
 
 
 class _Dictable:
@@ -130,13 +132,40 @@ class DatLine(_Dictable):
     dictables = {'booked', 'code', 'comment', 'error', 'is_intro'}
     prev_line_empty: bool
 
-    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]
+    def __init__(
+            self,
+            code: str,
+            comment: str,
+            add_indent: bool = False,
+            raw: Optional[str] = None
+            ) -> None:
+        self.comment = comment
+        self.code = f'{_DEFAULT_INDENT}{code}' if add_indent else code
+        if raw:
+            self.raw = raw
+        else:
+            self.raw = self.code
+            if self.comment:
+                self.raw += f' ; {self.comment}'
         self.booking: Optional['_Booking'] = None
-        self.booked: Optional[_BookingLine] = None
+        self.booked: Optional[BookingLine] = None
+
+    @classmethod
+    def new_empty(cls) -> Self:
+        """Create empty DatLine."""
+        return cls('', '', raw='')
+
+    def copy_unbooked(self) -> 'DatLine':
+        """Create DatLine of .code, .comment, .raw, but no Booking ties yet."""
+        return DatLine(self.code, self.comment, raw=self.raw)
+
+    @classmethod
+    def from_raw(cls, line: str) -> Self:
+        """Parse line into new DatLine."""
+        halves = [t.rstrip() for t in line.split(';', maxsplit=1)]
+        comment = halves[1] if len(halves) > 1 else ''
+        code = halves[0]
+        return cls(code, comment, raw=line)
 
     @property
     def comment_instructions(self) -> dict[str, str]:
@@ -159,7 +188,7 @@ class DatLine(_Dictable):
     @property
     def is_intro(self) -> bool:
         """Return if intro line of a _Booking."""
-        return isinstance(self.booked, _IntroLine)
+        return isinstance(self.booked, IntroLine)
 
     @property
     def booking_id(self) -> int:
@@ -168,7 +197,7 @@ class DatLine(_Dictable):
 
     @property
     def error(self) -> str:
-        """Return error if registered on attempt to parse into _BookingLine."""
+        """Return error if registered on attempt to parse into BookingLine."""
         return '; '.join(self.booked.errors) if self.booked else ''
 
     @property
@@ -184,54 +213,96 @@ class DatLine(_Dictable):
         return self.raw.replace(' ', '&nbsp;')
 
 
-class _BookingLine(_Dictable):
+class BookingLine(_Dictable, ABC):
     """Parsed code part of a DatLine belonging to a _Booking."""
 
-    def __init__(self) -> None:
-        self.errors: list[str] = []
-        self.idx = 0
+    def __init__(self, idx: int, errors: Optional[list[str]] = None) -> None:
+        self.idx = idx
+        self.errors: list[str] = errors if errors else []
+
+    @abstractmethod
+    def to_code(self) -> str:
+        """Parse to ledger file line code part."""
 
+    def to_dat_line(self, comment: str = '') -> DatLine:
+        """Make matching DatLine."""
+        return DatLine(self.to_code(), comment)
 
-class _IntroLine(_BookingLine):
+
+class IntroLine(BookingLine):
     """First line of a _Booking, expected to carry date etc."""
     dictables = {'date', 'target'}
 
-    def __init__(self, code: str) -> None:
-        super().__init__()
-        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']
+    def __init__(
+            self,
+            date: str,
+            target: str,
+            errors: Optional[list[str]] = None
+            ) -> None:
+        super().__init__(0, errors)
+        self.target = target
+        self.date = date
         try:
             dt_date.fromisoformat(self.date)
         except ValueError:
             self.errors += [f'not properly formatted legal date: {self.date}']
 
+    @classmethod
+    def from_code(cls, code: str) -> Self:
+        """Parse from ledger file line code part."""
+        errors = []
+        if code[0].isspace():
+            errors += ['intro line indented']
+        toks = code.lstrip().split(maxsplit=1)
+        if len(toks) == 1:
+            errors += ['illegal number of tokens']
+        return cls(toks[0], toks[1] if len(toks) > 1 else '', errors)
 
-class _TransferLine(_BookingLine):
+    def to_code(self) -> str:
+        return f'{self.date} {self.target}'
+
+
+class TransferLine(BookingLine):
     """Non-first _Booking line, expected to carry value movement."""
     dictables = {'amount', 'account', 'currency'}
 
-    def __init__(self, code: str, idx: int) -> None:
-        super().__init__()
-        self.idx = idx
-        self.currency = ''
-        self.amount: Optional[Decimal] = None
+    def __init__(
+            self,
+            account: str,
+            amount: Optional[Decimal],
+            currency: str,
+            errors: Optional[list[str]] = None,
+            idx: int = -1
+            ) -> None:
+        super().__init__(idx, errors)
+        self.account = account
+        self.amount = amount
+        self.currency = currency
+
+    @classmethod
+    def from_code_at_idx(cls, code: str, idx: int) -> Self:
+        """Parse from ledger file line code part, assign in-Booking index."""
+        errors = []
+        currency: str = ''
+        amount: Optional[Decimal] = None
         if not code[0].isspace():
-            self.errors += ['transfer line not indented']
+            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 '€'
+            currency = toks[2] if 3 == len(toks) else '€'
             try:
-                self.amount = Decimal(toks[1])
+                amount = Decimal(toks[1])
             except DecimalInvalidOperation:
-                self.errors += [f'improper amount value: {toks[1]}']
+                errors += [f'improper amount value: {toks[1]}']
         if len(toks) > 3:
-            self.errors += ['illegal number of tokens']
+            errors += ['illegal number of tokens']
+        return cls(toks[0], amount, currency, errors, idx)
+
+    def to_code(self) -> str:
+        code = f'{_DEFAULT_INDENT}{self.account}'
+        if self.amount:
+            code += f'  {self.amount} {self.currency}'
+        return code
 
     @property
     def amount_short(self) -> str:
@@ -254,14 +325,16 @@ class _Booking:
                  gap_lines: Optional[list[DatLine]] = None
                  ) -> None:
         self.next, self.prev = None, None
-        self.id_, self.booked_lines = id_, booked_lines[:]
+        self.id_ = id_
+        self.booked_lines = booked_lines[:]
         self._gap_lines = gap_lines[:] if gap_lines else []
-        # parse booked_lines into Intro- and _TransferLines
+        # parse booked_lines into Intro- and TransferLines
         for line in booked_lines:
             line.booking = self
-        self.intro_line = _IntroLine(self.booked_lines[0].code)
-        self._transfer_lines = [_TransferLine(b_line.code, i+1) for i, b_line
-                                in enumerate(self.booked_lines[1:])]
+        self.intro_line = IntroLine.from_code(self.booked_lines[0].code)
+        self._transfer_lines = [
+                TransferLine.from_code_at_idx(b_line.code, i+1)
+                for i, b_line in enumerate(self.booked_lines[1:])]
         self.booked_lines[0].booked = self.intro_line
         for i, b_line in enumerate(self._transfer_lines):
             self.booked_lines[i + 1].booked = b_line
@@ -299,7 +372,7 @@ class _Booking:
     @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('')]
+        return self._gap_lines if self._gap_lines else [DatLine.new_empty()]
 
     @gap_lines.setter
     def gap_lines(self, gap_lines=list[DatLine]) -> None:
@@ -308,12 +381,12 @@ class _Booking:
     @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]
+        return [dat_line.copy_unbooked() 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]
+        return [dat_line.copy_unbooked() for dat_line in self.booked_lines]
 
     @property
     def target(self) -> str:
@@ -357,13 +430,13 @@ class Ledger:
         """(Re-)read ledger from file at ._path_dat."""
         self.accounts, self.bookings, self.initial_gap_lines = {}, [], []
         self.dat_lines: list[DatLine] = [
-            DatLine(line)
+            DatLine.from_raw(line)
             for line in self._path_dat.read_text(encoding='utf8').splitlines()]
         self.last_save_hash = self._hash_dat_lines()
         booked: list[DatLine] = []
         gap_lines: list[DatLine] = []
         booking: Optional[_Booking] = None
-        for dat_line in self.dat_lines + [DatLine('')]:
+        for dat_line in self.dat_lines + [DatLine.new_empty()]:
             if dat_line.code:
                 if gap_lines:
                     if booking:
@@ -485,7 +558,7 @@ class Ledger:
             if not gap_start_found:  # end index is always after current line,
                 booked_end += 1      # provided we're not yet in the gap
             elif line.code.strip():
-                new_lines[i] = DatLine(f'; {line.code}')
+                new_lines[i] = DatLine.from_raw(f'; {line.code}')
         before_gap = new_lines[:booked_start]
         new_booked_lines = (new_lines[booked_start:booked_end]
                             if booked_start > -1 else [])
@@ -536,11 +609,10 @@ class Ledger:
             dat_lines_transaction: list[DatLine],
             intro_comment: str = ''
             ) -> int:
+        intro = IntroLine(dt_date.today().isoformat(), target)
         booking = _Booking(
             len(self.bookings),
-            [DatLine(f'{dt_date.today().isoformat()} {target}'
-                     + ' ; '.join([''] + [s for s in [intro_comment] if s]))
-             ] + dat_lines_transaction)
+            [intro.to_dat_line(intro_comment)] + dat_lines_transaction)
         self.bookings += [booking]
         self._sync()
         return booking.id_