# 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
_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}'
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:
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}
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)
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():
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."""
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 , and at least one."""
- if not self.raw:
- return ' '
- return self.raw.replace(' ', ' ')
+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
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):
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."""
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_
{% 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>
-{% 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> </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}} </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 %}
{% 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) %} {% endfor %}</span>{% if account.parent %}:{% endif %}{{account.basename}}{% if account.children %}:{% endif %}</td>
+ <td>
+ <span class="indent">{%
+ for i in range(indent) %} {%
+ 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 %}
{% 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 %}
{% 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();
};
}
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();
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);
}
});
});
}
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();
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) {
}
}
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();
{% 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">
|
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 %}
{% 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}} </td>
+ </tr>
+ {% endfor %}
+{% endfor %}
+</table>
+</form>
{% endblock %}
-
{% 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 }} </td>
+ </tr>
+ {% endfor %}
+{% endfor %}
+</table>
+<input type="submit" name="add_booking" value="add booking" />
+</form>
{% endblock %}