home · contact · privacy
When re-writing Booking, move within order according to date if changed.
authorChristian Heller <c.heller@plomlompom.de>
Sun, 2 Feb 2025 18:07:37 +0000 (19:07 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Sun, 2 Feb 2025 18:07:37 +0000 (19:07 +0100)
ledger.py
templates/_base.tmpl
templates/_macros.tmpl
templates/balance.tmpl

index f179efeb87e5271bea6bb1565532908a368e7788..dcf1c4da5b130ccecefec69246bc7b6d3bb70cdf 100755 (executable)
--- a/ledger.py
+++ b/ledger.py
@@ -206,19 +206,26 @@ class TransferLine(BookingLine):
 
 class Booking:
     """Represents lines of individual booking."""
-    # pylint: disable=too-few-public-methods
+    next: Optional[Self]
+    prev: Optional[Self]
 
     def __init__(self,
                  id_: int,
-                 dat_lines: list[DatLine],
-                 prev_booking: Optional[Self]
+                 booked_lines: list[DatLine],
+                 gap_lines: Optional[list[DatLine]] = None
                  ) -> None:
-        self.intro = IntroLine(self, dat_lines[0].code)
-        dat_lines[0].booking_line = self.intro
-        self._transfer_lines = []
-        for i, dat_line in enumerate(dat_lines[1:]):
-            dat_line.booking_line = TransferLine(self, dat_line.code, i + 1)
-            self._transfer_lines += [dat_line.booking_line]
+        self.next, self.prev = None, None
+        self.id_, self.booked_lines = id_, booked_lines[:]
+        self._gap_lines = gap_lines[:] if gap_lines else []
+        # parse booked_lines into Intro- and TransferLines
+        self.intro_line = IntroLine(self, self.booked_lines[0].code)
+        self._transfer_lines = [
+                TransferLine(self, b_line.code, i+1) for i, b_line
+                in enumerate(self.booked_lines[1:])]
+        self.booked_lines[0].booking_line = self.intro_line
+        for i, b_line in enumerate(self._transfer_lines):
+            self.booked_lines[i + 1].booking_line = b_line
+        # calculate .account_changes
         changes = Wealth()
         sink_account = None
         self.account_changes: dict[str, Wealth] = {}
@@ -238,27 +245,58 @@ class Booking:
             self.account_changes[sink_account] += changes.as_sink
         elif not changes.sink_empty:
             self._transfer_lines[-1].errors += ['needed sink missing']
-        self.id_ = id_
-        self.dat_lines = dat_lines
-        self.prev = prev_booking
+
+    def recalc_prev_next(self, bookings: list[Self]) -> None:
+        """Assuming .id_ to be index in bookings, link prev + next Bookings."""
+        self.prev = bookings[self.id_ - 1] if self.id_ > 0 else None
         if self.prev:
             self.prev.next = self
-        self.next: Optional[Self] = None
+        self.next = (bookings[self.id_ + 1] if self.id_ + 1 < len(bookings)
+                     else None)
+        if self.next:
+            self.next.prev = self
+
+    @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('')]
+
+    @gap_lines.setter
+    def gap_lines(self, gap_lines=list[DatLine]) -> None:
+        self._gap_lines = gap_lines[:]
+
+    @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]
+
+    @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]
+
+    @property
+    def target(self) -> str:
+        """Return main other party for transaction."""
+        return self.intro_line.target
+
+    @property
+    def date(self) -> str:
+        """Return Booking's day's date."""
+        return self.intro_line.date
 
     def can_move(self, up: bool) -> bool:
         """Whether movement rules would allow self to move up or down."""
-        if (up and ((not self.prev)
-                    or self.prev.intro.date != self.intro.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.intro.date != self.intro.date)):
+        if (not up) and ((not self.next) or self.next.date != self.date):
             return False
         return True
 
     @property
     def is_questionable(self) -> bool:
         """Whether lines count any errors."""
-        for _ in [bl for bl in [self.intro] + self._transfer_lines
+        for _ in [bl for bl in [self.intro_line] + self._transfer_lines
                   if bl.errors]:
             return True
         return False
@@ -270,28 +308,14 @@ class Handler(PlomHttpHandler):
 
     def do_POST(self) -> None:
         # pylint: disable=invalid-name,missing-function-docstring
-        if self.pagename.startswith('edit_'):
-            id_ = int(self.path_toks[2])
-        if 'reload_file' in self.postvars.as_dict:
-            self.server.load()
-        elif 'save_file' in self.postvars.as_dict:
-            self.server.save()
-        elif self.pagename.startswith('ledger_'):
-            for key in self.postvars.keys_prefixed('move_'):
-                toks = key.split('_')
-                id_ = int(toks[1])
-                self.server.move_booking(id_, up=toks[2] == 'up')
-                self.redirect(Path(self.pagename).joinpath(f'#{id_}'))
-                return
-            for key in self.postvars.keys_prefixed('copy_'):
-                toks = key.split('_', maxsplit=2)
-                id_ = int(toks[1])
-                new_id = self.server.copy_booking(int(toks[1]),
-                                                  to_end=toks[2] == 'to_end')
-                self.redirect(Path('/bookings').joinpath(f'{new_id}'))
-                return
-        elif self.pagename == 'edit_structured':
-            if self.postvars.first('apply'):
+        prefix_file, prefix_ledger = 'file_', 'ledger_'
+        if (file_prefixed := self.postvars.keys_prefixed(prefix_file)):
+            getattr(self.server, file_prefixed[0][len(prefix_file):])()
+        elif (self.pagename.startswith('edit_')
+              and self.postvars.first('apply')):
+            booking = self.server.bookings[int(self.path_toks[2])]
+            new_lines = []
+            if self.pagename == 'edit_structured':
                 line_keys = self.postvars.keys_prefixed('line_')
                 lineno_to_inputs: dict[int, list[str]] = {}
                 for key in line_keys:
@@ -300,33 +324,38 @@ class Handler(PlomHttpHandler):
                     if lineno not in lineno_to_inputs:
                         lineno_to_inputs[lineno] = []
                     lineno_to_inputs[lineno] += [toks[2]]
-                new_dat_lines = []
                 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}')
+                        input_ = self.postvars.first(f'line_{lineno}_{name}'
+                                                     ).strip()
                         if name == 'date':
                             data = input_
                         elif name == 'target':
-                            data += f'  {input_}'
+                            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_dat_lines += [
+                    new_lines += [
                         DatLine(f'{data} ; {comment}' if comment else data)]
-                # pylint: disable=possibly-used-before-assignment
-                self.server.rewrite_booking(id_, new_dat_lines)
-        elif self.pagename == 'edit_raw':
-            if self.postvars.first('apply'):
-                new_dat_lines = [
-                        DatLine(line) for line
-                        in self.postvars.first('booking').splitlines()]
-                self.server.rewrite_booking(id_, new_dat_lines)
+            else:  # edit_raw
+                new_lines += [DatLine(line) for line
+                              in self.postvars.first('booking').splitlines()]
+            new_id = self.server.rewrite_booking(booking.id_, new_lines)
+            self.redirect(Path('/bookings').joinpath(f'{new_id}'))
+            return
+        elif self.pagename.startswith(prefix_ledger):
+            action, id_str, dir_ =\
+                self.postvars.keys_prefixed(prefix_ledger)[0].split('_')[1:]
+            new_id = getattr(self.server, f'{action}_booking')(
+                int(id_str), dir_ == 'up' if action == 'move' else 'to_end')
+            self.redirect(Path(self.path).joinpath(f'#{new_id}'))
+            return
         self.redirect(Path(self.path))
 
     def do_GET(self) -> None:
@@ -347,11 +376,12 @@ class Handler(PlomHttpHandler):
             self.redirect(
                     Path('/').joinpath('edit_structured').joinpath(str(id_)))
         elif self.pagename == 'edit_structured':
-            ctx['dat_lines'] = [dl.as_dict for dl
-                                in self.server.bookings[ctx['id']].dat_lines]
+            ctx['dat_lines'] = [
+                    dl.as_dict for dl
+                    in self.server.bookings[ctx['id']].booked_lines]
             self.send_rendered(Path('edit_structured.tmpl'), ctx)
         elif self.pagename == 'edit_raw':
-            ctx['dat_lines'] = self.server.bookings[ctx['id']].dat_lines
+            ctx['dat_lines'] = self.server.bookings[ctx['id']].booked_lines
             self.send_rendered(Path('edit_raw.tmpl'), ctx)
         elif self.pagename == 'ledger_raw':
             self.send_rendered(Path('ledger_raw.tmpl'),
@@ -366,6 +396,7 @@ class Server(PlomHttpServer):
     """Extends parent by loading .dat file into database for Handler."""
     bookings: list[Booking]
     dat_lines: list[DatLine]
+    initial_gap_lines: list[DatLine]
 
     def __init__(self, path_dat: Path, *args, **kwargs) -> None:
         super().__init__(PATH_TEMPLATES, (SERVER_HOST, SERVER_PORT), Handler)
@@ -380,6 +411,12 @@ class Server(PlomHttpServer):
         self.last_save_hash = self._hash_dat_lines()
         self._load_bookings()
 
+    def save(self) -> None:
+        """Save current state to ._path_dat."""
+        self._path_dat.write_text(
+            '\n'.join([line.raw for line in self.dat_lines]), encoding='utf8')
+        self.load()
+
     def _hash_dat_lines(self) -> int:
         return hash(tuple(dl.raw for dl in self.dat_lines))
 
@@ -389,81 +426,118 @@ class Server(PlomHttpServer):
         return self._hash_dat_lines() != self.last_save_hash
 
     def _load_bookings(self) -> None:
-        self.bookings = []
-        booking_lines: list[DatLine] = []
-        last_date = ''
+        booked_lines: list[DatLine] = []
+        gap_lines: list[DatLine] = []
+        booking: Optional[Booking] = None
+        self.bookings, self.initial_gap_lines, last_date = [], [], ''
         for dat_line in self.dat_lines + [DatLine('')]:
             if dat_line.code:
-                booking_lines += [dat_line]
-            elif booking_lines:
-                booking = Booking(
-                        len(self.bookings), booking_lines,
-                        self.bookings[-1] if self.bookings else None)
-                if last_date > booking.intro.date:
-                    booking.intro.errors += ['date < previous valid date']
-                else:
-                    last_date = booking.intro.date
-                self.bookings += [booking]
-                booking_lines = []
-
-    def save(self) -> None:
-        """Save current state to ._path_dat."""
-        self._path_dat.write_text(
-            '\n'.join([line.raw for line in self.dat_lines]), encoding='utf8')
-        self.load()
-
-    def _margin_indices(self, booking: Booking) -> tuple[int, int]:
-        start_idx = self.dat_lines.index(booking.dat_lines[0])
-        end_idx = self.dat_lines.index(booking.dat_lines[-1])
-        return start_idx, end_idx
-
-    def _replace_from_to(self,
-                         start_idx: int,
-                         end_idx: int,
-                         new_lines: list[DatLine]
-                         ) -> None:
-        self.dat_lines = (self.dat_lines[:start_idx]
-                          + new_lines
-                          + self.dat_lines[end_idx+1:])
-        self._load_bookings()
-
-    def move_booking(self, id_: int, up: bool) -> None:
-        """Move Booking of id_ one step up or downwards"""
-        id_other = id_ + (-1 if up else 1)
-        agent = self.bookings[id_]
-        other = self.bookings[id_other]
-        start_agent, end_agent = self._margin_indices(agent)
-        start_other, end_other = self._margin_indices(other)
-        gap_lines = self.dat_lines[(end_other if up else end_agent) + 1:
-                                   start_agent if up else start_other]
-        self._replace_from_to(start_other if up else start_agent,
-                              end_agent if up else end_other,
-                              (agent.dat_lines if up else other.dat_lines)
-                              + gap_lines
-                              + (other.dat_lines if up else agent.dat_lines))
-
-    def rewrite_booking(self, id_: int, new_dat_lines: list[DatLine]) -> None:
-        """Rewrite .dat_lines for Booking of .id_ with new_dat_lines."""
-        self._replace_from_to(*self._margin_indices(self.bookings[id_]),
-                              new_dat_lines)
+                if gap_lines:
+                    if booking:
+                        booking.gap_lines = gap_lines[:]
+                    else:
+                        self.initial_gap_lines = gap_lines[:]
+                    gap_lines.clear()
+                booked_lines += [dat_line]
+            else:
+                if booked_lines:
+                    booking = Booking(len(self.bookings), booked_lines[:])
+                    if last_date > booking.date:
+                        booking.intro_line.errors += [
+                                'date < previous valid date']
+                    else:
+                        last_date = booking.date
+                    self.bookings += [booking]
+                    booked_lines.clear()
+                gap_lines += [dat_line]
+        for booking in self.bookings:
+            booking.recalc_prev_next(self.bookings)
+        if booking:
+            booking.gap_lines = gap_lines[:-1]
+
+    def _recalc_dat_lines(self) -> None:
+        self.dat_lines = self.initial_gap_lines[:]
+        for booking in self.bookings:
+            self.dat_lines += booking.booked_lines
+            self.dat_lines += booking.gap_lines
+
+    def _move_booking(self, idx_from, idx_to) -> None:
+        moving = self.bookings[idx_from]
+        if idx_from >= idx_to:                   # moving upward, deletion must
+            del self.bookings[idx_from]          # precede insertion to keep
+        self.bookings[idx_to:idx_to] = [moving]  # deletion index, downwards
+        if idx_from < idx_to:                    # the other way around keeps
+            del self.bookings[idx_from]          # insertion index
+        min_idx, max_idx = min(idx_from, idx_to), max(idx_from, idx_to)
+        for idx, booking in enumerate(self.bookings[min_idx:max_idx + 1]):
+            booking.id_ = min_idx + idx
+            booking.recalc_prev_next(self.bookings)
+
+    def move_booking(self, old_id: int, up: bool) -> int:
+        """Move Booking of old_id one step up or downwards"""
+        new_id = old_id + (-1 if up else 1)
+        self._move_booking(old_id,                     # moving down implies
+                           new_id + (0 if up else 1))  # jumping over next item
+        self._recalc_dat_lines()
+        return new_id
+
+    def rewrite_booking(self, old_id: int, new_booked_lines: list[DatLine]
+                        ) -> int:
+        """Rewrite Booking with new_booked_lines, move if changed date."""
+        old_booking = self.bookings[old_id]
+        new_date = new_booked_lines[0].code.lstrip().split(maxsplit=1)[0]
+        if new_date == old_booking.date:
+            new_booking = Booking(
+                    old_id, new_booked_lines, old_booking.gap_lines_copied)
+            self.bookings[old_id] = new_booking
+            new_booking.recalc_prev_next(self.bookings)
+        else:
+            i_booking = self.bookings[0]
+            new_idx = None
+            while i_booking.next:
+                if not i_booking.prev and i_booking.date > new_date:
+                    new_idx = i_booking.id_
+                    break
+                if i_booking.next.date > new_date:
+                    break
+                i_booking = i_booking.next
+            if new_idx is None:
+                new_idx = i_booking.id_ + 1
+            # ensure that, if we land in group of like-dated Bookings, we land
+            # on the edge closest to our last position
+            if i_booking.date == new_date and old_id < i_booking.id_:
+                new_idx = [b for b in self.bookings
+                           if b.date == new_date][0].id_
+            new_booking = Booking(
+                    new_idx, new_booked_lines, old_booking.gap_lines_copied)
+            self.bookings[old_id] = new_booking
+            self._move_booking(old_id, new_idx)
+        self._recalc_dat_lines()
+        return new_booking.id_
 
     def copy_booking(self, id_: int, to_end: bool) -> int:
         """Add copy of Booking of id_ to_end of ledger, or after copied."""
         copied = self.bookings[id_]
-        empty_line = DatLine('')
-        if to_end or copied is self.bookings[-1]:
+        new_id = len(self.bookings) if to_end else copied.id_ + 1
+        if to_end:
+            intro_comment = copied.booked_lines[0].comment
             intro = DatLine(
-                f'{dt_date.today().isoformat()} '
-                f'{copied.intro.target} ; {copied.dat_lines[0].comment}')
-            self.dat_lines += [empty_line, intro] + copied.dat_lines[1:]
-            prev_id = self.bookings[-1].id_
+                f'{dt_date.today().isoformat()} {copied.target}'
+                + ' ; {intro_comment}' if intro_comment else '')
+            new_booking = Booking(new_id,
+                                  [intro] + copied.booked_lines_copied[:1],
+                                  copied.gap_lines_copied)
+            self.bookings += [new_booking]
         else:
-            start = self.dat_lines.index(copied.dat_lines[-1])
-            self._replace_from_to(start + 1, start,
-                                  [empty_line] + copied.dat_lines)
-            prev_id = copied.id_
-        self._load_bookings()
-        return prev_id + 1
+            new_booking = Booking(new_id,
+                                  copied.booked_lines_copied,
+                                  copied.gap_lines_copied)
+            self.bookings[new_id:new_id] = [new_booking]
+            for b in self.bookings[new_id + 1:]:
+                b.id_ += 1
+        new_booking.recalc_prev_next(self.bookings)
+        self._recalc_dat_lines()
+        return new_id
 
     @property
     def dat_lines_sans_empty(self) -> list[DatLine]:
index bc15e6bec8f650339e3f6a7430b92fb0f590c7db..15c54cb31698e9190bfd648717e066ee7c0889e5 100644 (file)
@@ -18,7 +18,7 @@ span.warning, table.warning tbody tr td, tr.warning td { background-color: #ff88
 </head>
 <body>
 <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="reload_file" value="reload" />{% if tainted %} · <span class="warning">unsaved changes: <input type="submit" name="save_file" 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="warning">unsaved changes: <input type="submit" name="file_save" value="save"></span>{% endif %}
 </form>
 <hr />
 {% block content %}{% endblock %}
index 6cbeb0102e14a587fb321425600d33cec4095ac2..5cba91aa5bafa6549ea12f62fe6df9fc24c6aa98 100644 (file)
@@ -20,11 +20,11 @@ table.ledger tr > td:first-child { background-color: white; }
   {% endif %}
   <tr class="alternating{% if dat_line.is_questionable %} warning{% endif %}">
   {% if dat_line.is_intro %}
-    <td id="{{dat_line.booking_id}}"><a href="#{{dat_line.booking_id}}">[#]</a><input type="submit" name="move_{{dat_line.booking_id}}_up" value="^"{% if not dat_line.booking.can_move(1) %} disabled{% endif %}/></td>
+    <td id="{{dat_line.booking_id}}"><a href="#{{dat_line.booking_id}}">[#]</a><input type="submit" name="ledger_move_{{dat_line.booking_id}}_up" value="^"{% if not dat_line.booking.can_move(1) %} disabled{% endif %}/></td>
   {% elif dat_line.booking_line.idx == 1 %}
-    <td><a href="/balance?up_incl={{dat_line.booking_id}}">[b]</a><input type="submit" name="move_{{dat_line.booking_id}}_down" value="v"{% if not dat_line.booking.can_move(0) %} disabled{% endif %}/></td>
+    <td><a href="/balance?up_incl={{dat_line.booking_id}}">[b]</a><input type="submit" name="ledger_move_{{dat_line.booking_id}}_down" value="v"{% if not dat_line.booking.can_move(0) %} disabled{% endif %}/></td>
   {% elif dat_line.booking_line.idx == 2 %}
-    <td><input type="submit" name="copy_{{dat_line.booking_id}}_here" value="c" /><input type="submit" name="copy_{{dat_line.booking_id}}_to_end" value="C" /></td>
+    <td><input type="submit" name="ledger_copy_{{dat_line.booking_id}}_here" value="c" /><input type="submit" name="copy_{{dat_line.booking_id}}_to_end" value="C" /></td>
   {% else %}
     <td></td>
   {% endif %}
@@ -38,8 +38,8 @@ table.ledger tr > td:first-child { background-color: white; }
     </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_line.date}}</a></td>
-      <td{% if dat_line.error %} class="invalid"{% endif %}>{{dat_line.booking_line.target}}</td>
+      <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>
     {% elif not dat_line.error %}
       <td class="amt">{{dat_line.booking_line.amount_short}}</td>
       <td class="curr">{{dat_line.booking_line.currency|truncate(4,true,"…")}}</td>
index acf81fc5838ae643d5c922410e1e67cf792b8167..4cde2749d76b8f168105b49702eaa0aaaa4fa8ef 100644 (file)
@@ -62,7 +62,7 @@ span.indent { letter-spacing: 3em; }
 {% endblock css %}
 
 {% block content %}
-<p>balance after <a href="/bookings/{{booking.id_}}">booking {{booking.id_}} ({{booking.intro.date}}: {{booking.intro.target}})</a></p>
+<p>balance after <a href="/bookings/{{booking.id_}}">booking {{booking.id_}} ({{booking.date}}: {{booking.target}})</a></p>
 <table{% if not valid %} class="warning"{% endif %}>
 {% for root in roots %}
 {{ account_with_children(root, indent=0) }}