home · contact · privacy
Enhance new DatBlock structure and apply to template, general overhaul.
authorChristian Heller <c.heller@plomlompom.de>
Sun, 13 Apr 2025 22:56:45 +0000 (00:56 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Sun, 13 Apr 2025 22:56:45 +0000 (00:56 +0200)
src/ledgplom/http.py
src/ledgplom/ledger.py
src/templates/_base.tmpl
src/templates/_macros.tmpl
src/templates/balance.tmpl
src/templates/edit_raw.tmpl
src/templates/edit_structured.tmpl
src/templates/ledger_raw.tmpl
src/templates/ledger_structured.tmpl

index 65a689426067c817b2ead6f71f5baa6fa3f632d2..a57690b551d0b6bac9de82efe01a4a6456a5a196 100644 (file)
@@ -2,11 +2,10 @@
 
 # standard libs
 from pathlib import Path
-from typing import Any, Optional
+from typing import Any
 # non-standard libs
 from plomlib.web import PlomHttpHandler, PlomHttpServer, PlomQueryMap
-from ledgplom.ledger import (
-        Account, BookingLine, DatLine, IntroLine, Ledger, TransferLine)
+from ledgplom.ledger import Account, DatBlock, DEFAULT_INDENT, Ledger
 
 
 _SERVER_PORT = 8084
@@ -15,11 +14,11 @@ _PATH_TEMPLATES = Path('templates')
 _PREFIX_LEDGER = 'ledger_'
 _PREFIX_EDIT = 'edit_'
 _PREFIX_FILE = 'file_'
-_TOK_STRUCT = 'structured'
+_TOK_STRUCTURED = 'structured'
 _TOK_RAW = 'raw'
-_PAGENAME_EDIT_STRUCT = f'{_PREFIX_EDIT}{_TOK_STRUCT}'
+_PAGENAME_EDIT_STRUCTURED = f'{_PREFIX_EDIT}{_TOK_STRUCTURED}'
 _PAGENAME_EDIT_RAW = f'{_PREFIX_EDIT}{_TOK_RAW}'
-_PAGENAME_LEDGER_STRUCT = f'{_PREFIX_LEDGER}{_TOK_STRUCT}'
+_PAGENAME_LEDGER_STRUCTURED = f'{_PREFIX_LEDGER}{_TOK_STRUCTURED}'
 _PAGENAME_LEDGER_RAW = f'{_PREFIX_LEDGER}{_TOK_RAW}'
 
 
@@ -61,9 +60,9 @@ class _Handler(PlomHttpHandler):
 
     def post_edit(self) -> Path:
         """Based on postvars, edit targeted Booking."""
-        booking = self.server.ledger.bookings[int(self.path_toks[2])]
+        old_id = int(self.path_toks[2])
         new_lines = []
-        if self.pagename == _PAGENAME_EDIT_STRUCT:
+        if self.pagename == _PAGENAME_EDIT_STRUCTURED:
             line_keys = self.postvars.keys_prefixed('line_')
             lineno_to_inputs: dict[int, list[str]] = {}
             for key in line_keys:
@@ -73,49 +72,38 @@ class _Handler(PlomHttpHandler):
                     lineno_to_inputs[lineno] = []
                 lineno_to_inputs[lineno] += [toks[2]]
             for lineno, input_names in lineno_to_inputs.items():
-                line_d = {key: self.postvars.first(f'line_{lineno}_{key}')
+                inputs = {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'])]
+                if 0 == lineno:
+                    code = f'{inputs["date"]} {inputs["target"]}'
                 else:
-                    new_lines += [DatLine(line_d['error'], line_d['comment'],
-                                          add_indent=True)]
-        else:  # edit_raw
-            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}')
+                    code = f'{DEFAULT_INDENT}{inputs["account"]} ' +\
+                            f'{inputs["amount"]} {inputs["currency"]}'
+                new_lines += [f'{code} ; {inputs["comment"]}']
+        new_lines += self.postvars.first('raw_lines').splitlines()
+        new_id = self.server.ledger.rewrite_block(old_id, new_lines)
+        return Path('/').joinpath(self.pagename).joinpath(f'{new_id}')
 
     def post_ledger_action(self) -> Path:
         """Call .server.ledger.(move|copy|add_empty_new)_booking."""
         if 'add_booking' in self.postvars.as_dict:
-            id_ = self.server.ledger.add_empty_booking()
+            id_ = self.server.ledger.add_empty_block()
         else:
             keys_prefixed = self.postvars.keys_prefixed(_PREFIX_LEDGER)
             action, id_str = keys_prefixed[0].split('_', maxsplit=2)[1:]
             id_ = int(id_str)
             if action.startswith('move'):
-                id_ = self.server.ledger.move_booking(id_, action == 'moveup')
-                return Path(self.path).joinpath(f'#{id_}')
-            id_ = self.server.ledger.copy_booking(id_)
-        return Path(_PAGENAME_EDIT_STRUCT).joinpath(f'{id_}')
+                id_ = self.server.ledger.move_block(id_, action == 'moveup')
+                return Path(self.path).joinpath(f'#block_{id_}')
+            id_ = self.server.ledger.copy_block(id_)
+        return Path(self.path).joinpath(f'#block_{id_}')
 
     def do_GET(self) -> None:
         """"Route GET requests to respective handlers."""
         # pylint: disable=invalid-name
-        if self.pagename == 'bookings':
+        if self.pagename == 'blocks':
             self.redirect(
-                Path('/').joinpath(_PAGENAME_EDIT_STRUCT
+                Path('/').joinpath(_PAGENAME_EDIT_STRUCTURED
                                    ).joinpath(self.path_toks[2]))
             return
         ctx = {'tainted': self.server.ledger.tainted, 'path': self.path}
@@ -129,66 +117,76 @@ class _Handler(PlomHttpHandler):
             self.get_ledger(ctx, False)
 
     def get_balance(self, ctx) -> None:
-        """Display tree of calculated Accounts over .bookings[:up_incl+1]."""
+        """Display tree of calculated Accounts over blocks up_incl+1."""
         id_ = int(self.params.first('up_incl')
-                  or str(len(self.server.ledger.bookings) - 1))
+                  or str(len(self.server.ledger.blocks) - 1))
         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['valid'] = self.server.ledger.blocks_valid_up_incl(id_)
+        ctx['block'] = self.server.ledger.blocks[id_]
         ctx['path_up_incl'] = f'{self.path_toks[1]}?up_incl='
         self._send_rendered('balance', ctx)
 
     def get_edit(self, ctx, raw: bool) -> None:
         """Display edit form for individual Booking."""
+
+        def make_balance_roots(b: DatBlock) -> list[dict[str, Any]]:
+            acc_changes = b.booking.account_changes if b.booking else {}
+            observed_tree: list[dict[str, Any]] = []
+            for full_path in sorted(acc_changes.keys()):
+                parent_children: list[dict[str, Any]] = observed_tree
+                for path, _ in Account.path_to_steps(full_path):
+                    already_registered = False
+                    for child in [n for n in parent_children
+                                  if path == n['name']]:
+                        parent_children = child['children']
+                        already_registered = True
+                        break
+                    if already_registered:
+                        continue
+                    pre = self.server.ledger.accounts[path].get_wealth(id_ - 1)
+                    post = self.server.ledger.accounts[path].get_wealth(id_)
+                    targeted = full_path == path
+                    diff = {
+                        cur: amt for cur, amt in (post - pre).moneys.items()
+                        if amt != 0
+                        or (targeted and cur in acc_changes[full_path].moneys)}
+                    if diff or targeted:
+                        displayed_currs = set(diff.keys())
+                        for wealth in pre, post:
+                            wealth.ensure_currencies(displayed_currs)
+                            wealth.purge_currencies_except(displayed_currs)
+                        node: dict[str, Any] = {
+                                'name': path,
+                                'direct_target': targeted,
+                                'wealth_before': pre.moneys,
+                                'wealth_diff': diff,
+                                'wealth_after': post.moneys,
+                                'children': []}
+                        parent_children += [node]
+                        parent_children = node['children']
+            return observed_tree
+
         id_ = int(self.path_toks[2])
-        booking = self.server.ledger.bookings[id_]
-        observed_tree: list[dict[str, Any]] = []
-        for full_path in sorted(booking.account_changes.keys()):
-            parent_children: list[dict[str, Any]] = observed_tree
-            for path, _ in Account.path_to_steps(full_path):
-                already_registered = False
-                for child in [n for n in parent_children if path == n['name']]:
-                    parent_children = child['children']
-                    already_registered = True
-                    break
-                if already_registered:
-                    continue
-                before = self.server.ledger.accounts[path].get_wealth(id_ - 1)
-                after = self.server.ledger.accounts[path].get_wealth(id_)
-                direct_target = full_path == path
-                diff = {
-                    cur: amt for cur, amt in (after - before).moneys.items()
-                    if amt != 0
-                    or (direct_target
-                        and cur in booking.account_changes[full_path].moneys)}
-                if diff or direct_target:
-                    displayed_currencies = set(diff.keys())
-                    for wealth in before, after:
-                        wealth.ensure_currencies(displayed_currencies)
-                        wealth.purge_currencies_except(displayed_currencies)
-                    node: dict[str, Any] = {
-                            'name': path,
-                            'direct_target': direct_target,
-                            'wealth_before': before.moneys,
-                            'wealth_diff': diff,
-                            'wealth_after': after.moneys,
-                            'children': []}
-                    parent_children += [node]
-                    parent_children = node['children']
-        ctx['roots'] = observed_tree
-        ctx['id'] = id_
-        ctx['dat_lines'] = [dl if raw else dl.as_dict for dl in booking.lines]
-        ctx['sink_error'] = booking.sink_error
-        ctx['valid'] = self.server.ledger.bookings_valid_up_incl(id_)
-        if not raw:
+        block = self.server.ledger.blocks[id_]
+        ctx['block'] = block
+        ctx['valid'] = self.server.ledger.blocks_valid_up_incl(id_)
+        ctx['roots'] = make_balance_roots(block)
+        if raw:
+            self._send_rendered(_PAGENAME_EDIT_RAW, ctx)
+        else:
+            ctx['raw_gap_lines'] = [dl.raw for dl in block.gap.lines]
             ctx['all_accounts'] = sorted(self.server.ledger.accounts.keys())
-        self._send_rendered(
-                _PAGENAME_EDIT_RAW if raw else _PAGENAME_EDIT_STRUCT, ctx)
+            ctx['booking_lines'] = []
+            if block.booking:
+                ctx['booking_lines'] += [block.booking.intro_line.as_dict]
+                ctx['booking_lines'] += [tf_line.as_dict for tf_line
+                                         in block.booking.transfer_lines]
+            self._send_rendered(_PAGENAME_EDIT_STRUCTURED, ctx)
 
     def get_ledger(self, ctx: dict[str, Any], raw: bool) -> None:
         """Display ledger of all Bookings."""
-        ctx['dat_lines'] = self.server.ledger.dat_lines
+        ctx['blocks'] = self.server.ledger.blocks
         self._send_rendered(
-                _PAGENAME_LEDGER_RAW if raw else _PAGENAME_LEDGER_STRUCT, ctx)
+            _PAGENAME_LEDGER_RAW if raw else _PAGENAME_LEDGER_STRUCTURED, ctx)
index 552987bc5e4033490ea9ae5bada7e6fee93e95d3..669a8ba6dc800b41f6ccb24371f38a61b29e72d3 100644 (file)
@@ -5,29 +5,14 @@ from abc import ABC, abstractmethod
 from datetime import date as dt_date
 from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
 from pathlib import Path
-from typing import Any, Iterator, Optional, Self
+from typing import Any, Generic, Iterator, Optional, Self, TypeVar
 
 
-_PREFIX_DEF = '#def '
-_DEFAULT_INDENT = 2 * ' '
-
+TypeDatLine = TypeVar('TypeDatLine', bound='_DatLine')
 
-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
+_PREFIX_DEF = '#def '
+DEFAULT_INDENT = 2 * ' '
 
 
 class _Wealth():
@@ -127,36 +112,44 @@ class Account:
             yield rebuilt_path, step_name
 
 
-class DatLine(_Dictable):
+class _DatLine:
     """Line of .dat file parsed into comments and machine-readable data."""
-    dictables = {'booked', 'code', 'comment', 'error', 'is_intro'}
 
     def __init__(
             self,
             code: str = '',
             comment: str = '',
-            add_indent: bool = False
             ) -> None:
+        self._code_read = code
         self.comment = comment
-        self.code = f'{_DEFAULT_INDENT}{code}' if add_indent else code
-        self.raw = self.code + ' ; '.join([''] + [s for s in [self.comment]
-                                                  if s])
-        self.booking: Optional['_Booking'] = None
-        self.booked: Optional[BookingLine] = None
-        self.prev: Optional[DatLine] = None
 
-    def copy_unbooked(self) -> 'DatLine':
-        """Create DatLine of .code and .comment, but no _Booking ties yet."""
-        return DatLine(self.code, self.comment)
+    @property
+    def code(self) -> str:
+        """Return collected code (re-generate by subclasses for writing)."""
+        return self._code_read
+
+    @property
+    def raw(self) -> str:
+        """Return as how to be written in .dat file's text content."""
+        return self.code + ' ; '.join([''] + [s for s in [self.comment] if s])
+
+    def copy(self) -> Self:
+        """Create new _DatLine of same .code and .comment."""
+        return self.__class__(self.code, self.comment)
 
     @classmethod
     def from_raw(cls, line: str) -> Self:
-        """Parse line into new DatLine."""
+        """Parse line into new _DatLine."""
         halves = [t.rstrip() for t in line.split(';', maxsplit=1)]
         comment = halves[1].lstrip() if len(halves) > 1 else ''
         code = halves[0]
         return cls(code, comment)
 
+    @classmethod
+    def from_subclass(cls, line: '_DatLine') -> Self:
+        """Devolve from subclassed line into cls."""
+        return cls(line.code, line.comment)
+
     @property
     def comment_instructions(self) -> dict[str, str]:
         """Parse .comment into Account modification instructions."""
@@ -170,264 +163,201 @@ class DatLine(_Dictable):
             instructions[account_name] = desc
         return instructions
 
-    @property
-    def comment_in_ledger(self) -> str:
-        """What to show in structured ledger view (no instructions)."""
-        return '' if len(self.comment_instructions) > 0 else self.comment
 
-    @property
-    def is_intro(self) -> bool:
-        """Return if intro line of a _Booking."""
-        return isinstance(self.booked, IntroLine)
-
-    @property
-    def booking_id(self) -> int:
-        """If .booking, its .booking_id, else -1."""
-        return self.booking.id_ if self.booking else -1
-
-    @property
-    def error(self) -> str:
-        """Return error if registered on attempt to parse into BookingLine."""
-        return '; '.join(self.booked.errors) if self.booked else ''
-
-    @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 _DatLineSubclass(_DatLine, ABC):
 
+    @classmethod
+    @abstractmethod
+    def from_dat(cls, dat_line: '_DatLine') -> Self:
+        """Evolve from mere dat_line into subclass."""
 
-class _LinesBlock:
-    """Represents either _Booking or a gap between bookings."""
 
-    def __init__(self, lines: list[DatLine]) -> None:
-        self.lines = lines
-        self._next_block: Optional[_LinesBlock] = None
-        self._prev_block: Optional[_LinesBlock] = None
+class _GapLine(_DatLineSubclass):
 
-    @property
-    def _block_id(self) -> int:
-        count = -1
-        block_iterated: Optional[_LinesBlock] = self
-        while block_iterated:
-            block_iterated = block_iterated.prev_block
-            count += 1
-        return count
-
-    @property
-    def start_block(self) -> '_LinesBlock':
-        """For chain surrounding self, get first item."""
-        block_iterated = self
-        while True:
-            if not block_iterated.prev_block:
-                return block_iterated
-            block_iterated = block_iterated.prev_block
+    @classmethod
+    def from_dat(cls, dat_line: _DatLine) -> Self:
+        return cls('', dat_line.comment)
 
-    @property
-    def last_block(self) -> '_LinesBlock':
-        """For chain surrounding self, get last item."""
-        block_iterated = self
-        while True:
-            if not block_iterated.next_block:
-                return block_iterated
-            block_iterated = block_iterated.next_block
-
-    def _neighbor_block(
-            self,
-            new_this_block: Optional['_LinesBlock'],
-            this: str,
-            that: str,
-            ) -> None:
-        if (old_this_block := getattr(self, f'_{this}_block')):
-            setattr(old_this_block, f'_{that}_block', None)
-        if new_this_block:
-            if (new_this_that := getattr(new_this_block, f'_{that}_block')):
-                setattr(new_this_that, f'_{this}_block', None)
-            setattr(new_this_block, f'_{that}_block', self)
-        setattr(self, f'_{this}_block', new_this_block)
 
-    @property
-    def next_block(self) -> Optional['_LinesBlock']:
-        """Next block in chain."""
-        return self._next_block
+class _BookingLine(_DatLineSubclass):
+    """Parsed _DatLine belonging to a _Booking."""
+    dictables = {'comment', 'errors'}
 
-    @next_block.setter
-    def next_block(self, new_next_block: Optional['_LinesBlock']) -> None:
-        self._neighbor_block(new_next_block, 'next', 'prev')
+    def __init__(self, comment, errors: Optional[list[str]]) -> None:
+        super().__init__('', comment)
+        self._errors: list[str] = errors if errors else []
 
     @property
-    def prev_block(self) -> Optional['_LinesBlock']:
-        """Prev block in chain."""
-        return self._prev_block
-
-    @prev_block.setter
-    def prev_block(self, new_prev_block: Optional['_LinesBlock']) -> None:
-        self._neighbor_block(new_prev_block, 'prev', 'next')
-
-
-class BookingLine(_Dictable, ABC):
-    """Parsed code part of a DatLine belonging to a _Booking."""
-
-    def __init__(self, idx: int, errors: Optional[list[str]] = None) -> None:
-        self.idx = idx
-        self._errors: list[str] = errors if errors else []
+    def as_dict(self) -> dict[str, Any]:
+        """Return as JSON-ready dict attributes listed in .dictables."""
+        def to_dictable(value):
+            if isinstance(value, (str, int)):
+                return value
+            if hasattr(value, 'as_dict'):
+                return value.as_dict
+            if isinstance(value, list):
+                return [to_dictable(v) for v in value]
+            return str(value)
+        d = {}
+        for name in self.dictables:
+            d[name] = to_dictable(getattr(self, name))
+        return d
 
     @property
     def errors(self) -> list[str]:
-        """Return ._errors, allowing sub-classes to add entries dynamically."""
-        return self._errors
-
-    def add_error(self, msg: str) -> None:
-        """Store message to display with line's dynamic .errors."""
-        self._errors += [msg]
-
-    @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)
+        """Return collected errors (subclasses may add dynamic ones)."""
+        return self._errors[:]
 
 
-class IntroLine(BookingLine):
+class _IntroLine(_BookingLine):
     """First line of a _Booking, expected to carry date etc."""
-    dictables = {'date', 'target'}
+    dictables = {'date', 'target'} | _BookingLine.dictables
 
     def __init__(
             self,
             date: str,
             target: str,
+            comment: str = '',
             errors: Optional[list[str]] = None,
-            booking: Optional['_Booking'] = None
             ) -> None:
-        super().__init__(0, errors)
+        super().__init__(comment, errors)
         self.target = target
         self.date = date
-        if not IntroLine._date_valid(self.date):
-            self._errors += [f'not properly formatted legal date: {self.date}']
-        self._booking = booking
-
-    @staticmethod
-    def _date_valid(date: str) -> bool:
-        try:
-            dt_date.fromisoformat(date)
-        except ValueError:
-            return False
-        return True
 
     @property
     def errors(self) -> list[str]:
-        errors = self._errors[:]
-        if (self._booking and self._booking.prev
-                and IntroLine._date_valid(self._booking.date)
-                and IntroLine._date_valid(self._booking.prev.date)
-                and self._booking.prev.date > self._booking.date):
-            errors += ['date < previous valid date']
+        errors = super().errors
+        try:
+            dt_date.fromisoformat(self.date)
+        except ValueError:
+            errors += [f'not properly formatted legal date: {self.date}']
+        if not self.target:
+            errors += ['target empty']
         return errors
 
     @classmethod
-    def from_code(cls, code: str, booking: '_Booking') -> Self:
-        """Parse from ledger file line code part, assume booking context."""
+    def from_dat(cls, dat_line: _DatLine) -> Self:
         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, booking)
-
-    def to_code(self) -> str:
+        if dat_line.code[0].isspace():
+            errors += ['(intro line indented)']
+        toks = dat_line.code.lstrip().split(maxsplit=1)
+        return cls(toks[0], toks[1] if len(toks) > 1 else '',
+                   dat_line.comment, errors)
+
+    @property
+    def code(self) -> str:
         return f'{self.date} {self.target}'
 
 
-class TransferLine(BookingLine):
+class _TransferLine(_BookingLine):
     """Non-first _Booking line, expected to carry value movement."""
-    dictables = {'amount', 'account', 'currency'}
+    dictables = {'amount', 'account', 'currency'} | _BookingLine.dictables
 
     def __init__(
             self,
             account: str,
-            amount: Optional[Decimal],
+            amount_str: str,
             currency: str,
-            errors: Optional[list[str]] = None,
-            idx: int = -1
+            comment: str = '',
+            errors: Optional[list[str]] = None
             ) -> None:
-        super().__init__(idx, errors)
+        super().__init__(comment, errors)
         self.account = account
-        self.amount = amount
+        self._amount_str = amount_str
         self.currency = currency
 
+    @property
+    def amount(self) -> Optional[Decimal] | str:
+        """Decimal if amount known, None if not, str if un-decimable."""
+        if not self._amount_str:
+            return None
+        try:
+            return Decimal(self._amount_str)
+        except DecimalInvalidOperation:
+            return self._amount_str
+
+    @property
+    def errors(self) -> list[str]:
+        errors = super().errors
+        if isinstance(self.amount, str):
+            errors += [f'improper amount value: {self.amount}']
+        if len(self.currency.split()) > 1:
+            errors += ['improper number of tokens']
+        return errors
+
     @classmethod
-    def from_code_at_idx(cls, code: str, idx: int) -> Self:
-        """Parse from ledger file line code part, assign in-Booking index."""
+    def from_dat(cls, dat_line: _DatLine) -> Self:
         errors = []
         currency: str = ''
-        amount: Optional[Decimal] = None
-        if not code[0].isspace():
-            errors += ['transfer line not indented']
-        toks = code.lstrip().split()
+        if not dat_line.code[0].isspace():
+            errors += ['(transfer line not indented)']
+        toks = dat_line.code.lstrip().split(maxsplit=2)
+        amount_str = toks[1] if len(toks) > 1 else ''
         if len(toks) > 1:
             currency = toks[2] if 3 == len(toks) else '€'
-            try:
-                amount = Decimal(toks[1])
-            except DecimalInvalidOperation:
-                errors += [f'improper amount value: {toks[1]}']
-        if len(toks) > 3:
-            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:
+        return cls(toks[0], amount_str, currency, dat_line.comment, errors)
+
+    @property
+    def code(self) -> str:
+        code = f'{DEFAULT_INDENT}{self.account}'
+        if self.amount is not None:
             code += f'  {self.amount} {self.currency}'
         return code
 
     @property
     def amount_short(self) -> str:
-        """If no .amount, '', else printed – but if too long, ellipsized."""
-        if self.amount is not None:
+        """If decimal .amount, print ellipsized if too long, else directly."""
+        if isinstance(self.amount, Decimal):
             exp = self.amount.as_tuple().exponent
             assert isinstance(exp, int)
             return f'{self.amount:.1f}…' if exp < -2 else f'{self.amount:.2f}'
+        if isinstance(self.amount, str):
+            return self.amount
         return ''
 
 
-class _Booking(_LinesBlock):
-    """Represents lines of individual booking."""
+class _LinesBlock(Generic[TypeDatLine]):
+
+    def __init__(self, lines: Optional[list[TypeDatLine]] = None) -> None:
+        self._lines = lines if lines else []
+
+    @property
+    def lines(self) -> list[TypeDatLine]:
+        """Return collected lines."""
+        return self._lines
+
+    def copy(self) -> Self:
+        """Re-create via .lines' copy()."""
+        return self.__class__([line.copy() for line in self.lines])
+
+
+class _Gap(_LinesBlock[_GapLine]):
+
+    def add(self, lines: list[_GapLine]) -> None:
+        """Grow self by lines."""
+        self._lines += lines
+
 
-    def __init__(self, lines: list[DatLine]) -> None:
+class _Booking(_LinesBlock[_BookingLine]):
+
+    def __init__(self, lines: list[_BookingLine]) -> None:
         super().__init__(lines)
-        self.parse()
-
-    def parse(self) -> None:
-        """Parse .lines for BookingLine structures, .account_changes"""
-        for line in self.lines:
-            line.booking = self
-        self.intro_line = IntroLine.from_code(self.lines[0].code, self)
-        self._transfer_lines = [
-                TransferLine.from_code_at_idx(b_line.code, i+1)
-                for i, b_line in enumerate(self.lines[1:])]
-        self.lines[0].booked = self.intro_line
-        for i, b_line in enumerate(self._transfer_lines):
-            self.lines[i + 1].booked = b_line
         changes = _Wealth()
         sink_account = None
         self.sink_error = ''
         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:
+        for tf_line in [tl for tl in self.transfer_lines if not tl.errors]:
+            if tf_line.account not in self.account_changes:
+                self.account_changes[tf_line.account] = _Wealth()
+            if tf_line.amount is None:
                 if sink_account:
                     self.sink_error = 'too many sinks'
                 else:
-                    sink_account = transfer_line.account
+                    sink_account = tf_line.account
                 continue
-            change = _Wealth({transfer_line.currency: transfer_line.amount})
-            self.account_changes[transfer_line.account] += change
+            assert isinstance(tf_line.amount, Decimal)
+            change = _Wealth({tf_line.currency: tf_line.amount})
+            self.account_changes[tf_line.account] += change
             changes += change
         if sink_account:
             self.account_changes[sink_account] += changes.as_sink
@@ -435,51 +365,114 @@ class _Booking(_LinesBlock):
             self.sink_error = 'needed sink missing'
 
     @property
-    def id_(self):
-        """Index in chain of bookings."""
-        return self._block_id // 2
-
-    @staticmethod
-    def _cast(item: Optional[_LinesBlock]) -> Optional['_Booking']:
-        assert isinstance(item, (_Booking, type(None)))
-        return item
+    def intro_line(self) -> _IntroLine:
+        """Return collected _IntroLine."""
+        assert isinstance(self._lines[0], _IntroLine)
+        return self._lines[0]
 
     @property
-    def next(self) -> Optional['_Booking']:
-        """Next Booking, assuming it's two block steps away."""
-        return self._cast(self.next_block.next_block if self.next_block
-                          else None)
+    def transfer_lines(self) -> list[_TransferLine]:
+        """Return collected _TransferLines."""   # NB: currently no easy way to
+        return self._lines[1:]  # type: ignore  # assert mypy list be of type
 
     @property
-    def prev(self) -> Optional['_Booking']:
-        """Prev Booking, assuming it's two block steps away."""
-        return self._cast(self.prev_block.prev_block if self.prev_block
-                          else None)
-
-    def fix_position(self):
-        """Move around in bookings chain until properly positioned by .date."""
-        while self.prev and self.prev.date > self.date:
-            self.move(up=True)
-        while self.next and self.next.date < self.date:
-            self.move(up=False)
+    def lines(self) -> list[_BookingLine]:
+        return [self.intro_line] + list(self.transfer_lines)
 
     @property
-    def lines_copied(self) -> list[DatLine]:
-        """Return new DatLines generated from .booked_lines."""
-        return [dat_line.copy_unbooked() for dat_line in self.lines]
+    def date(self) -> str:
+        """Chronological position as per .booking, or empty string."""
+        return self.intro_line.date
 
     @property
     def target(self) -> str:
-        """Return main other party for transaction."""
+        """Main other party for transaction."""
         return self.intro_line.target
 
+    def copy_to_current_date(self) -> Self:
+        """Make copy of same lines but now as date."""
+        copy = self.copy()
+        copy.intro_line.date = dt_date.today().isoformat()
+        return copy
+
+
+class DatBlock:
+    """Unit of lines with optional .booking, and possibly empty .gap."""
+
+    def __init__(
+            self,
+            booking: Optional['_Booking'],
+            gap: Optional['_Gap'] = None
+            ) -> None:
+        self.booking = booking
+        self.gap = gap if gap else _Gap()
+        self._prev: Optional[Self] = None
+        self._next: Optional[Self] = None
+
+    @property
+    def id_(self) -> int:
+        """Return index in chain."""
+        count = -1
+        block_iterated: Optional[DatBlock] = self
+        while block_iterated:
+            block_iterated = block_iterated.prev
+            count += 1
+        return count
+
+    @property
+    def date_error(self) -> str:
+        """If not empty, notify about .date not matching position in chain."""
+        if self.prev and self.prev.date > self.date:
+            return 'date < previous date'
+        return ''
+
+    @property
+    def lines(self) -> list[_DatLine]:
+        """Return .lines of .booking and .gap as list[_DatLine]."""
+        lines = (self.booking.lines if self.booking else []) + self.gap.lines
+        if self.booking and not self.gap.lines:
+            lines += [_GapLine()]
+        return [_DatLine.from_subclass(line) for line in lines]
+
+    def _set_neighbor(
+            self,
+            new_this: Optional[Self],
+            this: str,
+            that: str,
+            ) -> None:
+        if (old_this := getattr(self, f'_{this}')):
+            setattr(old_this, f'_{that}', None)
+        if new_this:
+            if (new_this_that := getattr(new_this, f'_{that}')):
+                setattr(new_this_that, f'_{this}', None)
+            setattr(new_this, f'_{that}', self)
+        setattr(self, f'_{this}', new_this)
+
+    @property
+    def next(self) -> Optional['DatBlock']:
+        """Successor in chain."""
+        return self._next
+
+    @next.setter
+    def next(self, new_next: Optional['DatBlock']) -> None:
+        self._set_neighbor(new_next, 'next', 'prev')
+
+    @property
+    def prev(self) -> Optional['DatBlock']:
+        """Predecessor in chain."""
+        return self._prev
+
+    @prev.setter
+    def prev(self, new_prev: Optional['DatBlock']):
+        self._set_neighbor(new_prev, 'prev', 'next')
+
     @property
     def date(self) -> str:
-        """Return _Booking's day's date."""
-        return self.intro_line.date
+        """Chronological position as per .booking, or empty string."""
+        return self.booking.date if self.booking else ''
 
     def can_move(self, up: bool) -> bool:
-        """Whether movement rules would allow self to move up or down."""
+        """Whether move up/down in chain possible, respecting .date."""
         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):
@@ -487,97 +480,104 @@ class _Booking(_LinesBlock):
         return True
 
     def move(self, up: bool) -> None:
-        """Move self and following gap up or down in line blocks chain."""
+        """Move up/down in chain."""
+        old_prev = self.prev
         old_next = self.next
-        assert self.next_block is not None
         if up:
-            old_prev = self.prev
             assert old_prev is not None
-            assert old_prev.prev_block is not None
-            assert old_prev.next_block is not None
-            old_prev.prev_block.next_block = self
-            self.next_block.next_block = old_prev
-            old_prev.next_block.next_block = old_next
+            if old_prev.prev:
+                old_prev.prev.next = self
+            self.next = old_prev
+            old_prev.next = old_next
         else:
             assert old_next is not None
-            old_prev_block = self.prev_block
-            self.next_block.next_block = old_next.next
-            self.prev_block = old_next.next_block
-            old_next.prev_block = old_prev_block
+            if old_next.next:
+                old_next.next.prev = self
+            self.prev = old_next
+            old_next.prev = old_prev
 
     def drop(self) -> None:
-        """Remove self and following gap from line blocks chain."""
-        assert self.prev_block is not None
-        assert self.next_block is not None
-        self.prev_block.lines += self.next_block.lines
-        self.prev_block.next_block = self.next_block.next_block
+        """Remove from chain."""
+        if self.prev:
+            self.prev.next = self.next
+        elif self.next:
+            self.next.prev = self.prev
 
-    @property
-    def is_questionable(self) -> bool:
-        """Whether lines count any errors, or add up to a .sink_error."""
-        if self.sink_error:
-            return True
-        for _ in [bl for bl in [self.intro_line] + self._transfer_lines
-                  if bl.errors]:
-            return True
-        return False
+    def fix_position(self):
+        """Move around in chain until properly positioned by .date."""
+        while self.prev and self.prev.date > self.date:
+            self.move(up=True)
+        while self.next and self.next.date < self.date:
+            self.move(up=False)
+
+    def replace_with(self, new_block: Self) -> None:
+        """Have new_block take own position."""
+        if self.prev:
+            self.prev.next = new_block
+        if self.next:
+            self.next.prev = new_block
+        new_block.fix_position()
+
+    def copy_to_current_date(self) -> 'DatBlock':
+        """Make copy of same lines but now as date, position accordingly."""
+        copy = DatBlock(
+                self.booking.copy_to_current_date() if self.booking else None,
+                self.gap.copy())
+        if self.next:
+            self.next.prev = copy
+        self.next = copy
+        copy.fix_position()
+        return copy
 
 
 class Ledger:
-    """Collection of DatLines, and Accounts, _Bookings derived from them."""
-    _blocks_start: Optional[_LinesBlock]
+    """Collection of DatBlocks, _Bookings and Accounts derived from them."""
+    _blocks_start: Optional[DatBlock]
 
     def __init__(self, path_dat: Path) -> None:
         self._path_dat = path_dat
         self.load()
 
+    def load(self) -> None:
+        """(Re-)read ledger from file at ._path_dat."""
+        dat_lines: list[_DatLine] = [
+            _DatLine.from_raw(line)
+            for line in self._path_dat.read_text(encoding='utf8').splitlines()]
+        booking_lines: list[_BookingLine] = []
+        new_block = DatBlock(None, _Gap())
+        self._blocks_start = new_block
+        for dat_line in dat_lines:
+            if bool(dat_line.code):
+                if not booking_lines:
+                    booking_lines += [_IntroLine.from_dat(dat_line)]
+                else:
+                    booking_lines += [_TransferLine.from_dat(dat_line)]
+            else:  # enter new gap -> ready to start next block
+                if booking_lines:
+                    new_block.next = DatBlock(_Booking(booking_lines))
+                    new_block = new_block.next
+                    booking_lines = []
+                new_block.gap.add([_GapLine.from_dat(dat_line)])
+        self.last_save_hash = self._hash_dat_lines()
+
     @property
-    def _blocks(self) -> list[_LinesBlock]:
+    def blocks(self) -> list[DatBlock]:
+        """Return blocks chain as list."""
         blocks = []
         block = self._blocks_start
         while block:
             blocks += [block]
-            block = block.next_block
+            block = block.next
         return blocks
 
-    def load(self) -> None:
-        """(Re-)read ledger from file at ._path_dat."""
-        dat_lines: list[DatLine] = [
-            DatLine.from_raw(line)
-            for line in self._path_dat.read_text(encoding='utf8').splitlines()]
-        booked = False
-        self._blocks_start = None
-        block_lines: list[DatLine] = []
-        for dat_line in dat_lines + [DatLine()]:
-            if bool(dat_line.code) != booked:
-                block = (_Booking if booked else _LinesBlock)(block_lines[:])
-                if self._blocks_start:
-                    block.prev_block = self._blocks_start.last_block
-                else:
-                    self._blocks_start = block
-                booked = not booked
-                block_lines.clear()
-            block_lines += [dat_line]
-        if self._blocks_start:
-            _LinesBlock(block_lines[:]).prev_block =\
-                    self._blocks_start.last_block
-        self.last_save_hash = self._hash_dat_lines()
-
     @property
-    def dat_lines(self) -> list[DatLine]:
-        """From ._blocks build list of current DatLines."""
+    def _dat_lines(self) -> list[_DatLine]:
+        """From .blocks build list of current _DatLines."""
         lines = []
-        for block in self._blocks:
+        for block in self.blocks:
             lines += block.lines
-        for i, line in enumerate(lines):
-            line.prev = lines[i - 1] if i > 0 else None
         return lines
 
-    @property
-    def bookings(self) -> list[_Booking]:
-        """Build chain of bookings."""
-        return [block for block in self._blocks if isinstance(block, _Booking)]
-
     @property
     def accounts(self) -> dict[str, Account]:
         """Build mapping of account names to Accounts."""
@@ -592,94 +592,96 @@ class Ledger:
                         step_name)
                 parent_path = path
 
-        for dat_line in self.dat_lines:
+        for dat_line in self._dat_lines:
             for acc_name, desc in dat_line.comment_instructions.items():
                 ensure_accounts(acc_name)
                 accounts[acc_name].desc = desc
-        for booking in self.bookings:
-            for acc_name, wealth in booking.account_changes.items():
+        for block in [b for b in self.blocks if b.booking]:
+            assert block.booking is not None
+            for acc_name, wealth in block.booking.account_changes.items():
                 ensure_accounts(acc_name)
-                accounts[acc_name].add_wealth_diff(booking.id_, wealth)
+                accounts[acc_name].add_wealth_diff(block.id_, wealth)
         return accounts
 
     def save(self) -> None:
         """Save current state to ._path_dat."""
-        text = '\n'.join([line.raw for line in self.dat_lines])
+        text = '\n'.join([line.raw for line in self._dat_lines])
         self._path_dat.write_text(text, encoding='utf8')
         self.load()
 
     def _hash_dat_lines(self) -> int:
-        return hash(tuple(dl.raw for dl in self.dat_lines))
-
-    def bookings_valid_up_incl(self, booking_id: int) -> bool:
-        """If no .is_questionable in self.bookings up to booking_id."""
-        return len([b for b in self.bookings[:booking_id + 1]
-                    if b.is_questionable]
-                   ) < 1
+        return hash(tuple(dl.raw for dl in self._dat_lines))
+
+    def blocks_valid_up_incl(self, block_id: int) -> bool:
+        """Whether nothing questionable about blocks until block_id."""
+        for block in self.blocks[:block_id]:
+            if block.booking:
+                if block.booking.sink_error:
+                    return False
+                if [line for line in block.booking.lines if line.errors]:
+                    return False
+            if block.date_error:
+                return False
+        return True
 
     @property
     def tainted(self) -> bool:
-        """If .dat_lines different to those of last .load()."""
+        """If ._dat_lines different to those of last .load()."""
         return self._hash_dat_lines() != self.last_save_hash
 
-    def move_booking(self, idx_from: int, up: bool) -> int:
-        """Move _Booking of old_id one step up or downwards"""
-        booking = self.bookings[idx_from]
-        booking.move(up)
-        return booking.id_
-
-    def rewrite_booking(self, old_id: int, new_lines: list[DatLine]) -> int:
-        """Rewrite _Booking with new_lines, move if changed date."""
-        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():  # ignore any initial
-                booked_start = i                        # empty lines
-            elif booked_start >= 0 and not line.code.strip():  # past start,
-                gap_start_found = True                         # yet empty? gap
-            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.from_raw(f'; {line.code}')
-        assert booking.prev_block is not None
-        assert booking.next_block is not None
-        booking.prev_block.lines += new_lines[:booked_start]
-        booking.next_block.lines = (new_lines[booked_end:]
-                                    + booking.next_block.lines)
-        booking.lines = (
-                new_lines[booked_start:booked_end] if booked_start > -1
-                else [])
-        if not booking.lines:  # interpret empty posting as deletion request
-            booking.drop()
-            return old_id if old_id < len(self.bookings) else 0
-        booking.parse()
-        booking.fix_position()
-        return booking.id_
-
-    def _add_new_booking(
-            self,
-            target: str,
-            dat_lines_transaction: list[DatLine],
-            intro_comment: str = ''
-            ) -> int:
-        intro = IntroLine(dt_date.today().isoformat(), target)
-        booking = _Booking(
-                [intro.to_dat_line(intro_comment)] + dat_lines_transaction)
-        booking.next_block = _LinesBlock([DatLine()])
-        if self._blocks_start:
-            self._blocks_start.last_block.next_block = booking
-        else:
-            self._blocks_start = booking
-        booking.fix_position()
-        return booking.id_
-
-    def add_empty_booking(self) -> int:
-        """Add new _Booking to end of ledger."""
-        return self._add_new_booking('?', [])
-
-    def copy_booking(self, copied_id: int) -> int:
-        """Add copy of _Booking of copied_id to_end of ledger."""
-        copied = self.bookings[copied_id]
-        return self._add_new_booking(copied.target,
-                                     copied.lines_copied[1:],
-                                     copied.lines[0].comment)
+    def move_block(self, idx_from: int, up: bool) -> int:
+        """Move DatBlock of idx_from step up or downwards"""
+        block = self.blocks[idx_from]
+        block.move(up)
+        return block.id_
+
+    def rewrite_block(self, old_id: int, new_lines: list[str]) -> int:
+        """Rewrite with new_lines, move if changed date."""
+        lines_gap_pre_booking: list[_GapLine] = []
+        lines_booking: list[_BookingLine] = []
+        lines_gap_post_booking: list[_GapLine] = []
+        for dat_line in [_DatLine.from_raw(line) for line in new_lines]:
+            if dat_line.code:
+                if lines_gap_post_booking:
+                    lines_gap_post_booking += [_GapLine.from_dat(dat_line)]
+                elif not lines_booking:
+                    lines_booking += [_IntroLine.from_dat(dat_line)]
+                else:
+                    lines_booking += [_TransferLine.from_dat(dat_line)]
+            else:
+                if not lines_booking:
+                    lines_gap_pre_booking += [_GapLine.from_dat(dat_line)]
+                else:
+                    lines_gap_post_booking += [_GapLine.from_dat(dat_line)]
+        old_block = self.blocks[old_id]
+        if not lines_booking:
+            if old_block.prev:
+                old_block.prev.gap.add(lines_gap_pre_booking)
+                old_block.drop()
+            else:
+                old_block.booking = None
+                old_block.gap.add(lines_gap_pre_booking)
+            return max(0, old_id - 1)
+        new_block = DatBlock(_Booking(lines_booking),
+                             _Gap(lines_gap_post_booking))
+        self.blocks[old_id].replace_with(new_block)
+        if not new_block.prev:
+            self._blocks_start = DatBlock(None, _Gap())
+            self._blocks_start.next = new_block
+            assert new_block.prev is not None
+        if lines_gap_pre_booking:
+            new_block.prev.gap.add(lines_gap_pre_booking)
+        return new_block.id_
+
+    def add_empty_block(self) -> int:
+        """Add new DatBlock of empty _Booking to end of ledger."""
+        new_block = DatBlock(
+                _Booking([_IntroLine(dt_date.today().isoformat(), '?')]))
+        self.blocks[-1].next = new_block
+        new_block.fix_position()
+        return new_block.id_
+
+    def copy_block(self, id_: int) -> int:
+        """Add copy DatBlock of id_ but with current date."""
+        copy = self.blocks[id_].copy_to_current_date()
+        return copy.id_
index a8d04d34c27156b15979c0c360ad21f48c62f277..2c222c16699f52163fd7145104ae73884b635ebe 100644 (file)
@@ -7,23 +7,50 @@
 {% 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; }
+body {
+    {{macros.css_bg_white()}}
+    font-family: sans-serif;
+    text-align: left;
+    margin: 0;
+    padding: 0;
+}
+#header {
+    {{macros.css_bg_white()}}
+    position: sticky;
+    top: 0;
+    padding-left: 0.5em;
+    padding-bottom: 0.25em;
+    border-bottom: 1px solid black;
+}
+table.alternating > tbody > tr:nth-child(odd) {
+    background-color: #dcdcdc;
+}
+table.alternating > tbody > tr:nth-child(even) {
+    {{macros.css_bg_white()}};
+}
+td {
+    vertical-align: top;
+}
+div.bad, td.bad, tr.bad, span.bad {
+    {{macros.css_bg_red()}}
+}
 {% 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 %}
+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="bad">unsaved changes:<input type="submit" name="file_save" value="save"></span>
+{% endif %}
 </form>
-<hr />
 </div>
+
 {% block content %}{% endblock %}
 </body>
 </html>
index 9e8ec3e98fc4e7f9f25d1723fd3dcc83e573b05b..7a374ba8da9a0b7e535b35640d271cd6e7e435ee 100644 (file)
-{% macro css_td_money() %}
-td.amt { text-align: right }
-td.amt, td.curr { font-family: monospace; font-size: 1.3em; }
+{# =====================[ general css ]========================== #}
+
+{% macro css_bg_white() %}background: #ffffff;{% endmacro %}
+{% macro css_bg_red() %}background: #ff6666;{% endmacro %}
+
+
+
+{% macro css_noninput_monospace() %}
+    font-family: monospace;
+    font-size: 1.25em;
 {% endmacro %}
 
 
 
-{% macro css_td_money_balance() %}
-td.balance.amt { width: 10em; }
-td.balance.curr { width: 3em; }
+{% macro css_tabular_money() %}
+td.amount {
+    text-align: right;
+}
+td.amount, td.currency {
+    {{ css_noninput_monospace() }}
+}
 {% endmacro %}
 
 
 
-{% macro css_errors() %}
-span.sink_error, td.invalid, tr.warning td.invalid { background-color: #ff0000; }
+{% macro css_balance() %}
+table.alternating.bad > tbody > tr:nth-child(odd) {
+    {{ css_bg_red() }}
+}
+table.alternating.bad > tbody > tr:nth-child(even) {
+    background-color: #ff8a8a;
+}
+td.balance.amount {
+    width: 10em;
+}
+td.balance.currency {
+    width: 3em;
+}
 {% endmacro %}
 
 
 
-{% macro css_ledger_index_col() %}
-table.ledger tr > td:first-child { text-align: right; }
+
+{# =====================[ general other ]======================== #}
+
+{% macro currency_short(currency) %}{{ currency|truncate(4, true, "…") }}{% endmacro %}
+
+
+
+{% macro conditional_block_nav(path, direction, block) %}
+{% if block[direction] %}
+<a href="{{path}}{{block[direction].id_}}">{{direction}}</a>
+{% else %}
+<del>{{direction}}</del>
+{% endif %}
 {% endmacro %}
 
 
 
-{% macro table_dat_lines_action_button(dat_line, action, label, enabled=true) %}
-<input type="submit" name="ledger_{{action}}_{{dat_line.booking_id}}" value="{{label}}"{% if not enabled %} disabled{% endif %} />
+{# =====================[ for ledger pages ]===================== #}
+
+{% macro css_ledger() %}
+td.block_column {
+    {{css_bg_white()}}
+}
+td.block_column.bad {
+    {{css_bg_red()}}
+}
 {% 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 %}
-  {% if raw or dat_line.code or dat_line.comment_in_ledger %}
 
-    {% if (not raw) and dat_line.prev.raw == "" %}
-      <tr ><td>&nbsp;</td></tr>
-    {% endif %}
-    <tr class="alternating">
-
-    <td{% if dat_line.is_intro %} id="{{dat_line.booking_id}}"{% endif %} {% if dat_line.booking.sink_error %}class="invalid"{% endif %}>
-    {% if dat_line.is_intro %}
-      <a href="#{{dat_line.booking_id}}">[#]</a>
-      {{ table_dat_lines_action_button(dat_line, "moveup", "^", dat_line.booking.can_move(1)) }}
-    {% elif dat_line.booked.idx == 1 %}
-      <a href="/balance?up_incl={{dat_line.booking_id}}">[b]</a>
-      {{ table_dat_lines_action_button(dat_line, "movedown", "v", dat_line.booking.can_move(0)) }}
-    {% elif dat_line.booked.idx == 2 %}
-      {{ table_dat_lines_action_button(dat_line, "copy", "C") }}
-    {% endif %}
-    </td>
 
-    {% 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_in_ledger}}</td>
-      {% elif dat_line.error %}
-        <td class="invalid" colspan=3>{{dat_line.code}}</td>
-        <td>{{dat_line.comment_in_ledger}}</td>
-      {% elif dat_line.booked %}
-        <td class="amt">{{dat_line.booked.amount_short}}</td>
-        <td class="curr">{{dat_line.booked.currency|truncate(4,true,"…")}}</td>
-        <td>{{dat_line.booked.account}}</td>
-        <td>{{dat_line.comment_in_ledger}}</td>
-      {% else %}
-        <td colspan=2></td>
-        <td colspan=2>{{dat_line.comment_in_ledger}}&nbsp;</td>
-      {% endif %}
-    {% endif %}
-    </tr>
+{% macro ledger_block_columns(mode, block) %}
+<tr id="block_{{block.id_}}">
+<td class="block_column {% if block.date_error %}bad{% endif %}" rowspan={{block.lines|length + 1}}>
+<input type="submit" name="ledger_moveup_{{block.id_}}" value="^" {% if not block.can_move(1) %}disabled{% endif %}/><br />
+<input type="submit" name="ledger_movedown_{{block.id_}}" value="v" {% if not block.can_move(0) %}disabled{% endif %}/><br />
+<input type="submit" name="ledger_copy_{{block.id_}}" value="C" />
+</td>
+<td class="block_column {% if block.booking and block.booking.sink_error %}bad{% endif %}" rowspan={{block.lines|length + 1}}>
+[<a href="#block_{{block.id_}}">#</a>]<br />
+[<a href="/balance?up_incl={{block.id_}}">b</a>]<br />
+[<a href="/edit_{{mode}}/{{block.id_}}">e</a>]
+</td>
+</tr >
+{% endmacro %}
 
-    {% if (not raw) and dat_line.error %}
-      <tr class="alternating warning">
-      <td></td>
-      <td class="invalid" colspan=3>{{dat_line.error}}</td>
-      <td></td>
-      </tr>
-    {% endif %}
 
-  {% endif %}
-{% endfor %}
-</table>
-<input type="submit" name="add_booking" value="add Booking" />
-</form>
+
+{# =====================[ for edit pages ]======================= #}
+
+{% macro css_booking_balance() %}
+{{ css_balance() }}
+td.direct_target {
+    font-weight: bold;
+}
 {% endmacro %}
 
 
-{% macro taint_js() %}
+
+{% macro js_taint() %}
 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((el) => {
-      if (el.tagName == 'SPAN') {
-          let links_text = '';
-          Array.from(el.childNodes).forEach((node) => {
-            links_text += node.textContent + ' ';
-          });
-          el.innerHTML = '';
-          const del = document.createElement("del");
-          el.appendChild(del);
-          del.textContent = links_text;
-      } else if (el.type == "button") {
-          el.disabled = true;
-      }
-  });
-  // 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;
-      });
-  });
+    // activate buttons "apply", "revert"
+    Array.from(document.getElementsByClassName('enable_on_change')).forEach((el) => {
+        el.disabled = false;
+    });
+    // deactivate "disable_on_change" links
+    Array.from(document.getElementsByClassName('disable_on_change')).forEach((el) => {
+        if (el.tagName == 'SPAN') {
+            let links_text = '';
+            Array.from(el.childNodes).forEach((node) => {
+                links_text += node.textContent + ' ';
+            });
+            el.innerHTML = '';
+            const del = document.createElement('del');
+            el.appendChild(del);
+            del.textContent = links_text;
+        } else if (el.type == 'button') {
+            el.disabled = true;
+        }
+    });
+    // remove oninput handlers no longer needed (since we only ever go one way)
+    Array.from(document.querySelectorAll('*')
+        ).filter(el => (el.oninput !== null)
+        ).forEach(el => el.oninput = null);
 }
 {% endmacro %}
 
 
 
-{% macro edit_bar(target, id, sink_error) %}
+{% macro edit_bar(block, here, there) %}
+<form action="/edit_{{here}}/{{block.id_}}" method="POST">
 <span class="disable_on_change">
-<a href="/bookings/{{id-1}}">prev</a> · <a href="/bookings/{{id+1}}">next</a>
+{{conditional_block_nav('/blocks/','prev',block)}}
+{{conditional_block_nav('/blocks/','next',block)}}
 </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>
+<a href="/edit_{{there}}/{{block.id_}}">switch to {{there}}</a>
+<a href="/balance?up_incl={{block.id_}}">balance after</a>
+<a href="/ledger_{{here}}/#block_{{block.id_}}">in ledger</a>
 </span>
 <hr />
-{% if sink_error %}
-<span class="sink_error">balancing error: {{ sink_error }}</span>
-<hr />
+{% if block.date_error or (block.booking and block.booking.sink_error) %}
+    <div class="bad">block-wide errors:
+    {{block.date_error}}
+    {% if block.booking %}
+        {% if block.date_error %}– and:{% endif %}
+        {{block.booking.sink_error}}
+    {% endif %}
+    </div>
+    <hr />
 {% endif %}
 {% 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 booking_balance(roots, valid) %}
 {% macro booking_balance_account_with_children(node) %}
-<tr class="alternating">
-<td {% if node.direct_target %}class="direct_target"{% endif %}>{{node.name}}{% if node.children %}:{% endif %}</td>
-<td class="money">
-<table>
-{% for curr, amt in node.wealth_before.items() %}
-  {{ tr_money_balance(amt, curr) }}
-{% endfor %}
-</table>
-</td>
-<td class="money">
-<table>
-{% for curr, amt in node.wealth_diff.items() %}
-  {{ tr_money_balance(amt, curr) }}
-{% endfor %}
-</table>
-</td>
-<td class="money">
-<table>
-{% for curr, amt in node.wealth_after.items() %}
-  {{ tr_money_balance(amt, curr) }}
-{% endfor %}
-</table>
-</td>
-<td></td>
-</tr>
-{% for child in node.children %}
-  {{ booking_balance_account_with_children(child) }}
-{% endfor %}
+    {% macro td_wealth(wealth_dict) %}
+        <td>
+        <table>
+        {% for curr, amt in wealth_dict.items() %}
+            <tr>
+            <td class="balance amount">{{amt}}</td>
+            <td class="balance currency">{{ currency_short(curr) }}</td>
+            </tr>
+        {% endfor %}
+        </table>
+        </td>
+    {% endmacro %}
+    <tr>
+    <td {% if node.direct_target %}class="direct_target"{% endif %}>
+    {{node.name}}{% if node.children %}:{% endif %}
+    </td>
+    {{ td_wealth(node.wealth_before) }}
+    {{ td_wealth(node.wealth_diff) }}
+    {{ td_wealth(node.wealth_after) }}
+    </tr>
+    {% for child in node.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><th>description</th></tr>
+<table class="alternating {% if not valid %}bad{% endif %}">
+<tr>
+<th>account</th>
+<th>before</th>
+<th>diff</th>
+<th>after</th>
+</tr>
 {% for root in roots %}
-{{ booking_balance_account_with_children(root) }}
+    {{ booking_balance_account_with_children(root) }}
 {% endfor %}
 </table>
 {% endmacro %}
index 35f0faecab364d983401adcd536e421d95d76d3b..04bacb25fc1441ec6e425368502a4ce4af7c150c 100644 (file)
 {% extends '_base.tmpl' %}
 
 
-{% macro account_with_children(booking_id, account, indent) %}
-  {% if account.get_wealth(booking_id).moneys|length > 0 %}
-    <tr class="alternating">
+
+{% block css %}
+{{ macros.css_tabular_money() }}
+{{ macros.css_balance() }}
+td.money table {
+    float: left;
+}
+summary::marker {
+    {{ macros.css_noninput_monospace() }}
+}
+summary {
+    list-style-type: "[…]";
+}
+details[open] > summary {
+    list-style-type: "[^]";
+}
+span.indent {
+    letter-spacing: 3em;
+}
+{% endblock css %}
+
+
+
+{% macro account_with_children(block_id, account, indent) %}
+{% macro tr_money_balance(amount, currency) %}
+    <tr>
+    <td class="balance amount">{{amount}}</td>
+    <td class="balance currency">{{ macros.currency_short(curr) }}</td>
+    </tr>
+{% endmacro %}
+{% if account.get_wealth(block_id).moneys|length > 0 %}
+    <tr>
     <td class="money">
-    {% if account.get_wealth(booking_id).moneys|length == 1 %}
-      <table>
-      {% for curr, amt in account.get_wealth(booking_id).moneys.items() %}
-        {{ macros.tr_money_balance(amt, curr) }}
-      {% endfor %}
-      </table>
+    {% if account.get_wealth(block_id).moneys|length == 1 %}
+        <table>
+        {% for currency, amount in account.get_wealth(block_id).moneys.items() %}
+            {{tr_money_balance(amount, currency)}}
+        {% endfor %}
+        </table>
     {% else %}
-      <details>
-      <summary>
-      <table>
-      {% for curr, amt in account.get_wealth(booking_id).moneys.items() %}
-        {% if 1 == loop.index %}
-          {{ macros.tr_money_balance(amt, curr) }}
-        {% endif %}
-      {% endfor %}
-      </table>
-      </summary>
-      <table>
-      {% for curr, amt in account.get_wealth(booking_id).moneys.items() %}
-        {% if 1 < loop.index %}
-          {{ macros.tr_money_balance(amt, curr) }}
-        {% endif %}
-      {% endfor %}
-      </table>
-      </details>
+        <details>
+        <summary>
+        <table>
+        {% for currency, amount in account.get_wealth(block_id).moneys.items() %}
+            {% if 1 == loop.index %}
+                {{tr_money_balance(amount, currency)}}
+            {% endif %}
+        {% endfor %}
+        </table>
+        </summary>
+        <table>
+        {% for currency, amount in account.get_wealth(block_id).moneys.items() %}
+            {% if 1 < loop.index %}
+                {{tr_money_balance(amount, currency)}}
+            {% endif %}
+        {% endfor %}
+        </table>
+        </details>
     {% endif %}
     </td>
-    <td class="acc"><span class="indent">{% for i in range(indent) %}&nbsp;{% endfor %}</span>{% if account.parent %}:{% endif %}{{account.basename}}{% if account.children %}:{% endif %}</td>
+    <td>
+    <span class="indent">{%
+      for i in range(indent) %}&nbsp;{%
+      endfor
+    %}</span>{%
+      if account.parent
+      %}:{%
+      endif
+      %}{{account.basename}}{%
+      if account.children
+      %}:{%
+      endif
+    %}
+    </td>
     <td>{{account.desc}}</td>
     </tr>
     {% for child in account.children %}
-      {{ account_with_children(booking_id, child, indent=indent+1) }}
+        {{account_with_children(block_id, child, indent=indent+1)}}
     {% endfor %}
-  {% endif %}
+{% endif %}
 {% 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>
-<a href="{{path_up_incl}}{{ booking.id_ - 1 }}">prev</a>
-<a href="{{path_up_incl}}{{ booking.id_ + 1 }}">next</a>
+{{macros.conditional_block_nav(path_up_incl,'prev',block)}}
+{{macros.conditional_block_nav(path_up_incl,'next',block)}}
 |
-balance after <a href="/bookings/{{booking.id_}}">booking {{booking.id_}} ({{booking.date}}: {{booking.target}})</a>
+balance after <a href="/blocks/{{block.id_}}">booking {{block.id_}} ({{block.booking.date}}: {{block.booking.target}})</a>
 </p>
-<table{% if not valid %} class="warning"{% endif %}>
+<table class="alternating {% if not valid %}bad{% endif %}">
 {% for root in roots %}
-{{ account_with_children(booking.id_, root, indent=0) }}
+    {{account_with_children(block.id_,root,indent=0)}}
 {% endfor %}
 </table>
 {% endblock %}
index fb7b8043f2445a53ac11126946a27290a2052c36..ac689266e561bfa4156fa4102d43e83bb3f7a59b 100644 (file)
@@ -1,24 +1,25 @@
 {% extends '_base.tmpl' %}
 
 
+
 {% block css %}
-{{ macros.css_td_money() }}
-{{ macros.css_td_money_balance() }}
-{{ macros.css_errors() }}
+{{ macros.css_tabular_money() }}
+{{ macros.css_booking_balance() }}
 {% endblock %}
 
 
+
 {% block script %}
-{{ macros.taint_js() }}
+{{macros.js_taint()}}
 {% endblock %}
 
 
+
 {% block content %}
-<form action="/edit_raw/{{id}}" method="POST">
-{{ macros.edit_bar("structured", id, sink_error) }}
-<textarea name="booking" cols=100 rows=100 oninput="taint()">
-{% for dat_line in dat_lines %}{{ dat_line.raw }}
+{{macros.edit_bar(block,'raw','structured')}}
+<textarea name="raw_lines" cols=100 rows={{block.lines|length + 1}} oninput="taint()">
+{% for dat_line in block.lines %}{{ dat_line.raw }}
 {% endfor %}</textarea>
 </form>
-{{ macros.booking_balance(valid, roots) }}
+{{ macros.booking_balance(roots, valid) }}
 {% endblock %}
index f4942f0926fbb0d3902118636f037d8adbaccb6c..9276e8dead1a9612ecb0d7dcc33fb523db7a6410 100644 (file)
@@ -1,73 +1,70 @@
 {% 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; }
-td.direct_target { font-weight: bold; }
+{{ macros.css_tabular_money() }}
+{{ macros.css_booking_balance() }}
+input.amount {
+    text-align: right;
+    font-family: monospace;
+}
+#date_input {
+    margin-right: 0.3em;
+    font-family: monospace;
+}
 {% endblock %}
 
 
-{% block script %}
-var dat_lines = {{dat_lines|tojson|safe}};
 
-{{ macros.taint_js() }}
+{% block script %}
+{{macros.js_taint()}}
+var raw_gap_lines = {{raw_gap_lines|tojson|safe}};
+var booking_lines = {{booking_lines|tojson|safe}};
 
-function new_dat_line(account='', amount='None', currency='') {
+function new_booking_line(account='', amount='None', currency='') {
     return {
-        error: '',
+        errors: [],
         comment: '',
-        booked: {
-            account: account,
-            amount: amount,
-            currency: currency,
-        }
+        account: account,
+        amount: amount,
+        currency: currency,
     };
 }
 
 function update_form() {
-    // catch and empty table
-    const table = document.getElementById("dat_lines");
-    table.innerHTML = "";
+    // empty and redo gap_lines
+    textarea = document.getElementById('gap_lines');
+    textarea.value = '';
+    raw_gap_lines.forEach((line) => {
+        textarea.value += `${line}\n`;
+    });
+
+    // catch and empty booking lines table
+    const table = document.getElementById('booking_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,
+        // add button to td to run onclick (after updating booking_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.type = 'button';  // otherwise would act as form submit
         btn.disabled = disabled;
         btn.onclick = function() {
-            let n_rows_jumped = 0;  // to ignore table rows not representing dat_lines
+            let n_rows_skipped = 0;  // to ignore table rows not representing booking_lines
             for (let i = 0; i < table.rows.length; i++) {
                 const row = table.rows[i];
-                if (row.classList.contains('warning')) {
-                    n_rows_jumped++;
+                if (row.classList.contains('skip')) {
+                    n_rows_skipped++;
                     continue;
                 };
-                for (const input of table.rows[i].querySelectorAll('td input')) {
-                    const line_to_update = dat_lines[i - n_rows_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.booked.date = input.value;
-                    } else if (input.name.endsWith('target')) {
-                        line_to_update.booked.target = input.value;
-                    } else if (input.name.endsWith('account')) {
-                        line_to_update.booked.account = input.value;
-                    } else if (input.name.endsWith('amount')) {
-                        line_to_update.booked.amount = input.value;
-                    } else if (input.name.endsWith('currency')) {
-                        line_to_update.booked.currency = input.value;
-                    }
+                for (const input of table.rows[i].querySelectorAll('td > input')) {
+                    const line_to_update = booking_lines[i - n_rows_skipped];
+                    const key = input.name.split('_').at(-1);
+                    line_to_update[key] = input.value;
                 }
             }
             onclick();
@@ -76,25 +73,28 @@ function update_form() {
         };
     }
     function add_td(tr, colspan=1) {
-        const td = document.createElement("td");
+        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");
+    // work through individual booking lines
+    for (let i = 0; i < booking_lines.length; i++) {
+        const booking_line = booking_lines[i];
+        const tr = document.createElement('tr');
         table.appendChild(tr);
 
-        // add line inputs
+        // helpers depending on line-specific variables
         function setup_input_td(tr, colspan) {
             const td = add_td(tr, colspan);
-            if (dat_line.error) { td.classList.add("invalid"); };
+            if (booking_line.errors.length > 0) {
+                td.classList.add('bad');
+            };
             return td;
         }
         function add_input(td, name, value, size) {
-            const input = document.createElement("input");
+            const input = document.createElement('input');
             td.appendChild(input);
             input.name = `line_${i}_${name}`;
             input.value = value.trim();
@@ -110,79 +110,74 @@ function update_form() {
         const td_btns_updown = add_td(tr);
         if (i > 0) {
             [{label: '^', earlier_idx: i-1, enabled: i > 1},
-             {label: 'v', earlier_idx: i,   enabled: i && i+1 < dat_lines.length}
+             {label: 'v', earlier_idx: i,   enabled: i && i+1 < booking_lines.length}
             ].forEach((kwargs) => {
                 add_button(td_btns_updown, kwargs.label, ! kwargs.enabled, function() {
-                    const other_line = dat_lines[kwargs.earlier_idx];
-                    dat_lines.splice(kwargs.earlier_idx, 1);
-                    dat_lines.splice(kwargs.earlier_idx + 1, 0, other_line);
+                    const other_line = booking_lines[kwargs.earlier_idx];
+                    booking_lines.splice(kwargs.earlier_idx, 1);
+                    booking_lines.splice(kwargs.earlier_idx + 1, 0, other_line);
                 });
             });
         }
 
         // actual input lines
-        if (dat_line.is_intro) {
+        if (i == 0) {
             const td = setup_input_td(tr, 3);
-            const date_input = add_input(td, 'date', dat_line.booked.date, 10)
-            date_input.classList.add('date_input');
-            add_input(td, 'target', dat_line.booked.target, 37)
-        } else if (!dat_line.error) {  // i.e. valid TransferLine
-            const acc_input = add_td_input('account', dat_line.booked.account, 30);
+            const date_input = add_input(td, 'date', booking_line.date, 10)
+            date_input.id = 'date_input';
+            add_input(td, 'target', booking_line.target, 37)
+        } else {
+            const acc_input = add_td_input('account', 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.booked.amount == 'None' ? '' : dat_line.booked.amount, 12);
+            const amt_input_val = booking_line.amount == 'None' ? '' : booking_line.amount;
+            const amt_input = add_td_input('amount', amt_input_val, 12);
             amt_input.pattern = '^-?[0-9]+(\.[0-9]+)?$';
-            amt_input.classList.add("number_input");
+            amt_input.classList.add('amount');
             // ensure integer amounts at least line up with double-digit decimals
-            if (amt_input.value.match(/^-?[0-9]+$/)) { amt_input.value += '.00'; }
+            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.booked.currency, 3);
+            const curr_input = add_td_input('currency', 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_td_input('comment', booking_line.comment, 40);
 
         // line deletion and addition buttons
         td_add_del = add_td(tr);
-        add_button(td_add_del, 'add new',  false, function() {
-            dat_lines.splice(i + 1, 0, new_dat_line());
+        add_button(td_add_del, 'add new', false, function() {
+            booking_lines.splice(i + 1, 0, new_booking_line());
         });
         if (i > 0) {
             add_button(td_add_del, 'delete',  i > 0 ? false : true, function() {
-                dat_lines.splice(i, 1);
+                booking_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");
+        if (booking_line.errors.length > 0) {
+            tr.classList.add('bad');
+            const tr_info = document.createElement('tr');
+            tr_info.classList.add('skip');
+            table.appendChild(tr_info);
+            const td = add_td(tr_info, 7);
+            tr.appendChild(document.createElement('td'));
+            td.textContent = 'line bad:' + booking_line.errors;
+            tr_info.classList.add('bad');
         }
     }
-
-    // make all rows alternate background color for better readability
-    Array.from(table.rows).forEach((tr) => {
-        tr.classList.add('alternating');
-    });
 }
 
 function replace() {
-    const from = document.getElementById("replace_from").value;
-    const to = document.getElementById("replace_to").value;
-    dat_lines.forEach((dat_line) => {
-        dat_line.comment = dat_line.comment.replaceAll(from, to);
-        if ('code' in dat_line) {
-            dat_line.code = dat_line.code.replaceAll(from, to);
-        }
-        ['date', 'target', 'account', 'amount', 'currency'].forEach((key) => {
-            if (key in dat_line.booked) {
-                dat_line.booked[key] = dat_line.booked[key].replaceAll(from, to);
+    const from = document.getElementById('replace_from').value;
+    const to = document.getElementById('replace_to').value;
+    document.getElementById('gap_lines').replaceAll(from, to);
+    booking_lines.forEach((booking_line) => {
+        Object.keys(booking_line).forEach((key) => {
+            if (key != 'errors') {
+                booking_line[key] = booking_line[key].replaceAll(from, to);
             }
         });
     });
@@ -191,15 +186,15 @@ function replace() {
 }
 
 function mirror() {
-    dat_lines.slice(1).forEach((dat_line) => {
+    booking_lines.slice(1).forEach((booking_line) => {
         let inverted_amount = 'None';
-        if (dat_line.booked.amount != 'None') {
-            inverted_amount = `-${dat_line.booked.amount}`;
+        if (booking_line.amount != 'None') {
+            inverted_amount = `-${booking_line.amount}`;
             if (inverted_amount.startsWith('--')) {
                 inverted_amount = inverted_amount.slice(2);
             }
         }
-        dat_lines.push(new_dat_line('?', inverted_amount, dat_line.booked.currency));
+        booking_lines.push(new_booking_line('?', inverted_amount, booking_line.currency));
     })
     taint();
     update_form();
@@ -209,23 +204,21 @@ function fill_sink() {
     let sink_account = '';
     let sink_indices = [];
     const sum_per_currency = {};
-    for (let i = 0; i < dat_lines.length; i++) {
-        const dat_line = dat_lines[i];
-        if (!dat_line.is_intro && !dat_line.error) {
-            const currency = dat_line.booked.currency || '€';
-            if (dat_line.booked.amount == 'None') {
-                if (sink_account == dat_line.booked.account || !sink_account) {
-                    if (!sink_account) {
-                        sink_account = dat_line.booked.account;
-                    }
-                    sink_indices.push(i);
+    for (let i = 1; i < booking_lines.length; i++) {
+        const booking_line = booking_lines[i];
+        const currency = booking_line.currency || '€';
+        if (booking_line.amount == 'None') {
+            if (sink_account == booking_line.account || !sink_account) {
+                if (!sink_account) {
+                    sink_account = booking_line.account;
                 }
-            } else {
-               if (!Object.hasOwn(sum_per_currency, currency)) {
-                   sum_per_currency[currency] = 0;
-               }
-               sum_per_currency[currency] += parseFloat(dat_line.booked.amount);
+                sink_indices.push(i);
             }
+        } else {
+            if (!Object.hasOwn(sum_per_currency, currency)) {
+                sum_per_currency[currency] = 0;
+            }
+            sum_per_currency[currency] += parseFloat(booking_line.amount);
         }
     }
     if (!sink_account) {
@@ -238,15 +231,15 @@ function fill_sink() {
         }
     }
     for (i = 0; i < Object.keys(sink_amounts_per_currency).length - sink_indices.length; i++) {
-        sink_indices.push(dat_lines.length);
-        dat_lines.push(new_dat_line(sink_account));
+        sink_indices.push(booking_lines.length);
+        booking_lines.push(new_booking_line(sink_account));
     }
     let sink_indices_index = 0;
     for (const [currency, amount] of Object.entries(sink_amounts_per_currency)) {
-        const dat_line = dat_lines[sink_indices[sink_indices_index]];
+        const booking_line = booking_lines[sink_indices[sink_indices_index]];
         sink_indices_index++;
-        dat_line.booked.currency = currency;
-        dat_line.booked.amount = amount.toString();
+        booking_line.currency = currency;
+        booking_line.amount = amount.toString();
     }
     taint();
     update_form();
@@ -256,9 +249,9 @@ window.onload = update_form;
 {% endblock %}
 
 
+
 {% block content %}
-<form action="/edit_structured/{{id}}" method="POST">
-{{ macros.edit_bar("raw", id, sink_error) }}
+{{macros.edit_bar(block,'structured','raw')}}
 <input type="button" onclick="mirror()" value="mirror" class="disable_on_change">
 <input type="button" onclick="fill_sink()" value="fill sink" class="disable_on_change">
 |
@@ -268,13 +261,17 @@ from
 to
 <input id="replace_to" />
 <hr />
-<table id="dat_lines">
+<table id="booking_lines" class="alternating">
 </table>
+<div>Gap:</div>
+<textarea id="gap_lines" name="raw_lines" cols=100 rows={{raw_gap_lines|length + 1}} oninput="taint()">
+</textarea>
 </form>
 <datalist id="all_accounts">
 {% for acc in all_accounts %}
 <option value="{{acc}}">{{acc}}</a>
 {% endfor %}
 </datalist>
-{{ macros.booking_balance(valid, roots) }}
+<hr />
+{{ macros.booking_balance(roots, valid) }}
 {% endblock %}
index 7f803abea0232ce7a4fca371b81c0ffba369d638..3bc1b39a641045f568fdb636445a82e65f5e6ae7 100644 (file)
@@ -1,14 +1,27 @@
 {% 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; }
+{{macros.css_ledger()}}
+table {
+    font-family: monospace;
+}
 {% endblock %}
 
+
+
 {% block content %}
-{{ macros.table_dat_lines(dat_lines, raw=true) }}
+<form action="/ledger_raw" method="POST">
+<table class="alternating">
+{% for block in blocks %}
+    {{macros.ledger_block_columns('raw', block)}}
+    {% for line in block.lines %}
+        <tr>
+        <td {% if line.errors %}class="bad"{% endif %}>{{line.raw}}&nbsp;</td>
+        </tr>
+    {% endfor %}
+{% endfor %}
+</table>
+</form>
 {% endblock %}
-
index da853e56fa1c5d50c0b20a2d8540bfe252f0f0ec..1838c34af65c0a632f3b634cc9afd35254aa59f9 100644 (file)
@@ -1,16 +1,46 @@
 {% extends '_base.tmpl' %}
 
 
+
 {% block css %}
-{{ macros.css_td_money() }}
-{{ macros.css_errors() }}
-{{ macros.css_ledger_index_col() }}
-table.ledger > tbody > tr > td { vertical-align: middle; }
-table.ledger > tbody > tr > td.date, table.ledger > tbody > tr > td:first-child { font-family: monospace; font-size: 1.3em; }
-table.ledger > tbody > tr > td.date { text-align: center; }
-table.ledger > tbody > tr > td:first-child { white-space: nowrap; }
+{{macros.css_ledger()}}
+{{macros.css_tabular_money()}}
+td.amount {
+    text-align: right;
+}
+td.amount, td.currency {
+    vertical-align: bottom;
+}
 {% endblock %}
 
+
+
 {% block content %}
-{{ macros.table_dat_lines(dat_lines, raw=false) }}
+<form action="/ledger_structured" method="POST">
+<table class="alternating">
+{% for block in blocks %}
+    {{macros.ledger_block_columns('structured', block)}}
+    {% if block.booking %}
+        <tr>
+        <td colspan=3 {% if block.booking.intro_line.errors %}class="bad"{% endif %}>{{block.booking.date}} {{block.booking.target}}</td>
+        <td>{{block.booking.intro_line.comment}}</td>
+        </tr>
+        {% for line in block.booking.transfer_lines %}
+            <tr>
+            <td class="amount {% if line.errors %}bad{% endif %}">{{line.amount_short}}</td>
+            <td class="currency {% if line.errors %}bad{% endif %}">{{ macros.currency_short(line.currency) }}</td>
+            <td {% if line.errors %}class="bad"{% endif %}>{{line.account}}</td>
+            <td>{{line.comment}}</td>
+            </tr>
+        {% endfor %}
+    {% endif %}
+    {% for line in block.gap.lines %}
+        <tr>
+        <td colspan=4>{{ line.raw }}&nbsp;</td>
+        </tr>
+    {% endfor %}
+{% endfor %}
+</table>
+<input type="submit" name="add_booking" value="add booking" />
+</form>
 {% endblock %}