home · contact · privacy
Move javascript code into separate module files.
authorChristian Heller <c.heller@plomlompom.de>
Mon, 19 Jan 2026 02:38:16 +0000 (03:38 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 19 Jan 2026 02:38:16 +0000 (03:38 +0100)
16 files changed:
src/ledgplom/http.py
src/ledgplom/ledger.py
src/templates/_base.tmpl
src/templates/_macros.tmpl
src/templates/edit_raw.tmpl
src/templates/edit_structured.js [new file with mode: 0644]
src/templates/edit_structured.tmpl
src/templates/taint.js [new file with mode: 0644]
src/tests/empty.balance
src/tests/empty.ledger_raw
src/tests/empty.ledger_structured
src/tests/full.balance
src/tests/full.edit_raw.1
src/tests/full.edit_raw.4
src/tests/full.ledger_raw
src/tests/full.ledger_structured

index e151b0fcbf3c655fe16041488e38999f409fbd25..3d989915faf060fc83e960bbc58b1826413a26ee 100644 (file)
@@ -110,7 +110,9 @@ class _Handler(PlomHttpHandler):
             return
         ctx = {'unsaved_changes': self.server.ledger.tainted,
                'path': self.path}
-        if self.pagename == PAGENAME_BALANCE:
+        if self.pagename.endswith('.js'):
+            self.get_js()
+        elif self.pagename == PAGENAME_BALANCE:
             self.get_balance(ctx)
         elif self.pagename.startswith(_PREFIX_EDIT):
             self.get_edit(ctx, self.pagename == PAGENAME_EDIT_RAW)
@@ -119,6 +121,21 @@ class _Handler(PlomHttpHandler):
         else:
             self.get_ledger(ctx, False)
 
+    def get_js(self) -> None:
+        'Deliver .js module.'
+        ctx = {}
+        if self.pagename == PAGENAME_EDIT_STRUCTURED + '.js':
+            block = self.server.ledger.blocks[int(self.path_toks[2])]
+            ctx['raw_gap_lines'] = [dl.raw for dl in block.gap.lines]
+            ctx['booking_lines'] = (
+                [block.booking.intro_line.as_dict]
+                + [tf_line.as_dict for tf_line in block.booking.transfer_lines]
+                ) if block.booking else []
+        self.send_http(
+            bytes(self.server.jinja.get_template(self.pagename).render(**ctx),
+                  encoding='utf8'),
+            [('Content-Type', 'text/javascript')])
+
     def get_balance(self, ctx) -> None:
         'Display tree of calculated Accounts over blocks up_incl+1.'
         id_str = self.params.first('up_incl')
index 9a1406357b62174f7f75a09330ff8f941c6b7595..fcacf2f5b81d1e0b2858652213f65eb37bfd2e42 100644 (file)
@@ -605,7 +605,7 @@ class Ledger:
         self.last_save_hash = self._hash_dat_lines()
 
     @property
-    def _blocks(self) -> list[_DatBlock]:
+    def blocks(self) -> list[_DatBlock]:
         'Return blocks chain as list.'
         blocks = []
         block = self._blocks_start
@@ -616,9 +616,9 @@ class Ledger:
 
     @property
     def _dat_lines(self) -> list[_DatLine]:
-        'From ._blocks build list of current _DatLines.'
+        'From .blocks build list of current _DatLines.'
         lines = []
-        for block in self._blocks:
+        for block in self.blocks:
             lines += block.lines
         return [_DatLine(line.code, line.comment) for line in lines]
 
@@ -639,7 +639,7 @@ class Ledger:
             for acc_name, desc in dat_line.comment_instructions.items():
                 ensure_accounts(acc_name)
                 accounts[acc_name].desc = desc
-        for block in [b for b in self._blocks if b.booking]:
+        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.diffs_targeted.items():
                 ensure_accounts(acc_name)
@@ -657,7 +657,7 @@ class Ledger:
 
     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]:
+        for block in self.blocks[:block_id]:
             if block.booking:
                 if block.booking.sink_error:
                     return False
@@ -670,11 +670,11 @@ class Ledger:
     @property
     def _has_redundant_empty_lines(self) -> bool:
         'If any gaps have redunant empty lines.'
-        return bool([b for b in self._blocks if b.gap.redundant_empty_lines])
+        return bool([b for b in self.blocks if b.gap.redundant_empty_lines])
 
     def remove_redundant_empty_lines(self) -> None:
-        'From all ._blocks remove redundant empty lines.'
-        for gap in [b.gap for b in self._blocks
+        'From all .blocks remove redundant empty lines.'
+        for gap in [b.gap for b in self.blocks
                     if b.gap.redundant_empty_lines]:
             gap.remove_redundant_empty_lines()
 
@@ -685,7 +685,7 @@ class Ledger:
 
     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 = self.blocks[idx_from]
         block.move(up)
         return block.id_
 
@@ -707,7 +707,7 @@ class Ledger:
                     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]
+        old_block = self.blocks[old_id]
         if not lines_booking:
             if old_block.prev:
                 old_block.prev.gap.add(lines_gap_pre_booking)
@@ -718,7 +718,7 @@ class Ledger:
             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)
+        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
@@ -731,21 +731,21 @@ class Ledger:
         '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
+        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()
+        copy = self.blocks[id_].copy_to_current_date()
         return copy.id_
 
     def view_ctx_balance(self, id_: int = -1) -> dict[str, Any]:
         'All context data relevant for rendering a balance view.'
         if id_ < 0:
-            id_ = len(self._blocks) - 1
+            id_ = len(self.blocks) - 1
         return {'valid': self._blocks_valid_up_incl(id_),
-                'block': self._blocks[id_],
+                'block': self.blocks[id_],
                 'roots': sorted([ac for ac in self._calc_accounts().values()
                                  if not ac.parent],
                                 key=lambda root: root.basename)}
@@ -753,7 +753,7 @@ class Ledger:
     def view_ctx_edit(self, id_: int, raw=True) -> dict[str, Any]:
         'All context data relevant for rendering an edit view.'
         accounts = self._calc_accounts()
-        block = self._blocks[id_]
+        block = self.blocks[id_]
         roots: list[dict[str, Any]] = []
         for full_path in sorted(block.booking.diffs_targeted.keys()
                                 if block.booking else []):
@@ -789,10 +789,6 @@ class Ledger:
         if not raw:
             ctx['raw_gap_lines'] = [dl.raw for dl in block.gap.lines]
             ctx['all_accounts'] = sorted(accounts.keys())
-            ctx['booking_lines'] = (
-                [block.booking.intro_line.as_dict]
-                + [tf_line.as_dict for tf_line in block.booking.transfer_lines]
-                ) if block.booking else []
         return ctx
 
     def view_ctx_ledger(self) -> dict[str, Any]:
index 0d7534f941c696cca22d2fc7fbd7c0e528548fe0..6e600bc10560bd06027c7c9c7023c62b53eeaffa 100644 (file)
@@ -3,10 +3,8 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
 {% block script %}
 {% endblock %}
-</script>
 <style>
 html {
     scroll-padding-top: 2em;
@@ -45,7 +43,7 @@ td {
 <body>
 <div id="header">
     <form action="{{ path }}" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>
index 2f01776616bed84fe3afac2816029c6b63beeb9d..f09f619183f82eacef92278f9b66c3cbf02968a0 100644 (file)
@@ -127,75 +127,25 @@ td.direct_target {
 
 
 
-{% macro js_taint() %}
-var suppress_beforeunload = false;
-
-function taint() {
-    // activate buttons "apply", "revert"
-    Array.from(document.getElementsByClassName('enable_on_change')).forEach((el) => {
-        el.disabled = false;
-        el.addEventListener('click', function(e) {
-           suppress_beforeunload = true;
-        });
-    });
-    // deactivate "disable_on_change" span contents
-    function recursive_span_disable(el) {
-        old_nodes = Array.from(el.childNodes);
-        el.innerHTML = '';
-        old_nodes.forEach((node) => {
-            if (node.tagName == 'SPAN') {
-                recursive_span_disable(node);
-                el.appendChild(node);
-            } else if (node.tagName == 'INPUT') {
-                node.disabled = true;
-                el.appendChild(node);
-            } else if (node.tagName == 'A') {
-                const del = document.createElement('del');
-                del.textContent = node.textContent;
-                el.appendChild(del);
-            } else {
-                el.appendChild(node);
-            };
-        });
-    }
-    Array.from(document.getElementsByClassName('disable_on_change')).forEach((el) => {
-        recursive_span_disable(el);
-    });
-    // try to catch user closing or reloading window
-    window.addEventListener('beforeunload', function(e) {
-        if (!suppress_beforeunload) {
-            e.preventDefault();
-            e.returnValue = 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(block, here, there) %}
 <form action="/edit_{{ here }}/{{ block.id_ }}" method="POST">
-<span class="disable_on_change">
+<span class="disable_on_taint">
 {{ conditional_block_nav('/blocks/', 'prev', block) -}}
 {{ conditional_block_nav('/blocks/', 'next', block) -}}
 </span>
-<input class="enable_on_change"
+<input class="enable_on_taint"
        type="submit"
        name="apply"
        value="apply"
        disabled
        />
-<input class="enable_on_change"
+<input class="enable_on_taint"
        type="submit"
        name="revert"
        value="revert"
        disabled
        />
-<span class="disable_on_change">
+<span class="disable_on_taint">
 <a href="/edit_{{ there }}/{{ block.id_ }}">switch to {{ there }}</a>
 ·
 <a href="/balance?up_incl={{ block.id_ }}">balance after</a>
index af80ee0c7a306690e866c75ba0be8c8142bf1abc..f23c1821971b4b32a4949a263e815e577192cb8c 100644 (file)
@@ -10,7 +10,8 @@
 
 
 {% block script %}
-{{ macros.js_taint() }}
+<script type="module" src="/taint.js">
+</script>
 {% endblock %}
 
 
@@ -18,9 +19,9 @@
 {% block content %}
 {{ macros.edit_bar(block, 'raw', 'structured') }}
 <textarea name="raw_lines"
+          class="tainter"
           cols=100
           rows={{ block.lines|length + 1 }}
-          oninput="taint()"
           >
 {% for dat_line in block.lines %}
 {{ dat_line.raw }}
diff --git a/src/templates/edit_structured.js b/src/templates/edit_structured.js
new file mode 100644 (file)
index 0000000..0aa6805
--- /dev/null
@@ -0,0 +1,441 @@
+/*
+global
+document
+window
+*/
+/*
+eslint
+"arrow-body-style": [
+    "error",
+    "as-needed"
+],
+"capitalized-comments": [
+    "error",
+    "never"
+],
+"function-paren-newline": "off",
+"line-comment-position": "off",
+"lines-around-comment": [
+    "error",
+    { "beforeBlockComment": false }
+],
+"max-lines": [
+    "error",
+    {"max": 355, "skipBlankLines": true, "skipComments": true}
+],
+"max-lines-per-function": [
+    "error",
+    238
+],
+"max-params": [
+    "error",
+    4
+],
+"max-statements": [
+    "error",
+    38
+],
+"multiline-comment-style": [
+    "error",
+    "bare-block"
+],
+"multiline-ternary": "off",
+"newline-after-var": "off",
+"newline-before-return": "off",
+"no-inline-comments": "off",
+"no-plusplus": "off",
+"no-ternary": "off",
+"one-var": "off",
+"padded-blocks": [
+    "error",
+    "never"
+],
+*/
+import {
+    taint
+} from "/taint.js";
+
+const
+    IDX_LAST = -1,
+    IDX_PAST_INTRO_LINE = 1,
+    IDX_START = 0,
+    LEN_ACCOUNT = 30,
+    LEN_AMOUNT = 12,
+    LEN_COLSPAN_DEFAULT = 1,
+    LEN_COLSPAN_ERROR = 7,
+    LEN_COMMENT = 40,
+    LEN_CURRENCY = 3,
+    LEN_DATE = 10,
+    LEN_EMPTY = 0,
+    LEN_INTRO_LINE = 3,
+    LEN_LINE_STEP = 0,
+    LEN_TARGET = 37,
+    bookingLines = {{ booking_lines|tojson|safe }},
+    rawGapLines = {{ raw_gap_lines|tojson|safe }};
+
+const newBookingLine = (
+    account = "",
+    amount = "None",
+    currency = ""
+) => ({
+    account,
+    amount,
+    "comment": "",
+    currency,
+    "errors": []
+});
+
+const taintAndUpdateForm = () => {
+    taint();
+    updateForm(); // eslint-disable-line no-use-before-define
+};
+
+const updateForm = () => {
+    const
+        table = document.getElementById("booking_lines"),
+        textarea = document.getElementById("gap_lines");
+
+    // empty and redo gapLines, empty bookingLines table
+    textarea.value = "";
+    rawGapLines.forEach((line) => {
+        textarea.value += `${line}\n`;
+    });
+    table.innerHTML = "";
+
+    // basic helpers
+    const addButton = (
+        parentTd,
+        label,
+        disabled,
+        onclick
+    ) => {
+        /* add button to td to run onclick (after updating bookingLines from
+           from inputs, and followed by calling taint and updateForm) */
+        const btn = document.createElement("button");
+        parentTd.appendChild(btn);
+        btn.textContent = label;
+        btn.type = "button"; // otherwise would act as form submit
+        btn.disabled = disabled;
+        btn.onclick = () => {
+            let nRowsSkipped = LEN_EMPTY; // ignore bookingLines-empty rows
+            table.rows.forEach((row, idx) => {
+                if (row.classList.contains("skip")) {
+                    nRowsSkipped++;
+                    return;
+                }
+                for (const input of row.querySelectorAll("td > input")) {
+                    const
+                        key = input.name.split("_").at(IDX_LAST),
+                        lineToUpdate = bookingLines[idx - nRowsSkipped];
+                    lineToUpdate[key] = input.value;
+                }
+            });
+            onclick();
+            taintAndUpdateForm();
+        };
+    };
+    const addTd = (
+        tr,
+        colspan = LEN_COLSPAN_DEFAULT
+    ) => {
+        const td = document.createElement("td");
+        tr.appendChild(td);
+        td.colSpan = colspan;
+        return td;
+    };
+
+    // work through individual booking lines
+    bookingLines.forEach((
+        bookingLine,
+        idx
+    ) => {
+        const tr = document.createElement("tr");
+        table.appendChild(tr);
+
+        // helpers depending on line-specific variables
+        const setupInputTd = (
+            tr_,
+            colspan
+        ) => {
+            const td = addTd(
+                tr_,
+                colspan
+            );
+            if (bookingLine.errors.length > LEN_EMPTY) {
+                td.classList.add("critical");
+            }
+            return td;
+        };
+        const addInput = (
+            td,
+            name,
+            value,
+            size
+        ) => {
+            const input = document.createElement("input");
+            td.appendChild(input);
+            input.name = `line_${idx}_${name}`;
+            input.value = value.trim();
+            input.size = size;
+            input.oninput = taint;
+            return input;
+        };
+        const addTdInput = (
+            name,
+            value,
+            size,
+            colspan = LEN_COLSPAN_DEFAULT
+        ) => addInput(
+            setupInputTd(
+                tr,
+                colspan
+            ),
+            name,
+            value,
+            size
+        );
+
+        // movement buttons
+        const tdBtnsUpdown = addTd(tr);
+        if (idx > IDX_START) {
+            [
+                {
+                    "earlierIdx": idx - LEN_LINE_STEP,
+                    "enabled": idx > IDX_PAST_INTRO_LINE,
+                    "label": "^"
+                },
+                {
+                    "earlierIdx": idx,
+                    "enabled": idx &&
+                               idx + LEN_LINE_STEP < bookingLines.length,
+                    "label": "v"
+                }
+            ].forEach((kwargs) => addButton(
+                tdBtnsUpdown,
+                kwargs.label,
+                !kwargs.enabled,
+                () => {
+                    const otherLine = bookingLines[kwargs.earlierIdx];
+                    bookingLines.splice(
+                        kwargs.earlierIdx,
+                        LEN_LINE_STEP
+                    );
+                    bookingLines.splice(
+                        kwargs.earlierIdx + LEN_LINE_STEP,
+                        LEN_EMPTY,
+                        otherLine
+                    );
+                }
+            ));
+        }
+
+        // actual input lines
+        if (idx === IDX_START) {
+            const td = setupInputTd(
+                tr,
+                LEN_INTRO_LINE
+            );
+            const dateInput = addInput(
+                td,
+                "date",
+                bookingLine.date,
+                LEN_DATE
+            );
+            dateInput.id = "date_input";
+            addInput(
+                td,
+                "target",
+                bookingLine.target,
+                LEN_TARGET
+            );
+        } else {
+            const accInput = addTdInput(
+                "account",
+                bookingLine.account,
+                LEN_ACCOUNT
+            );
+            accInput.setAttribute(
+                "list",
+                "all_accounts"
+            );
+            accInput.autocomplete = "off";
+            /* not using input[type=number] cuz no minimal step size,
+               therefore regex test instead */
+            const amtInputVal =
+                bookingLine.amount === "None" ? "" : bookingLine.amount;
+            const amtInput = addTdInput(
+                "amount",
+                amtInputVal,
+                LEN_AMOUNT
+            );
+            amtInput.pattern = "^-?[0-9]+(.[0-9]+)?$";
+            amtInput.classList.add("amount");
+            // ensure integer amounts line up with double-digit decimals
+            if (amtInput.value.match("/^-?[0-9]+$/")) {
+                amtInput.value += ".00";
+            }
+            // imply POST handler to set "€" currency if unset, but amount set
+            const currInput = addTdInput(
+                "currency",
+                bookingLine.currency,
+                LEN_CURRENCY
+            );
+            currInput.placeholder = "€";
+        }
+        addTdInput(
+            "comment",
+            bookingLine.comment,
+            LEN_COMMENT
+        );
+
+        // line deletion and addition buttons
+        const tdAddDel = addTd(tr);
+        addButton(
+            tdAddDel,
+            "add new",
+            false,
+            () => bookingLines.splice(
+                idx + LEN_LINE_STEP,
+                LEN_EMPTY,
+                newBookingLine()
+            )
+        );
+        if (idx > IDX_START) {
+            addButton(
+                tdAddDel,
+                "delete",
+                idx <= IDX_START,
+                () => bookingLines.splice(
+                    idx,
+                    LEN_LINE_STEP
+                )
+            );
+        }
+
+        // add error explanation row if necessary
+        if (bookingLine.errors.length > LEN_EMPTY) {
+            tr.classList.add("critical");
+            const trInfo = document.createElement("tr");
+            trInfo.classList.add("skip");
+            table.appendChild(trInfo);
+            const td = addTd(
+                trInfo,
+                LEN_COLSPAN_ERROR
+            );
+            tr.appendChild(document.createElement("td"));
+            td.textContent = `line bad: ${bookingLine.errors}`;
+            trInfo.classList.add("critical");
+        }
+    });
+};
+
+const replace = () => {
+    const
+        replFrom = document.getElementById("replace_from").value,
+        replTo = document.getElementById("replace_to").value;
+    rawGapLines.forEach((line) => line.replaceAll(
+        replFrom,
+        replTo
+    ));
+    bookingLines.forEach((bookingLine) => {
+        Object.keys(bookingLine).
+            filter((key) => key !== "errors").
+            forEach((key) => {
+                bookingLine[key] = bookingLine[key].replaceAll(
+                    replFrom,
+                    replTo
+                );
+            });
+    });
+    taintAndUpdateForm();
+};
+
+const mirror = () => {
+    bookingLines.slice(IDX_PAST_INTRO_LINE).forEach((bookingLine) => {
+        let invertedAmount = "None";
+        if (bookingLine.amount !== "None") {
+            invertedAmount = `-${bookingLine.amount}`;
+            const doubleMinus = "--";
+            if (invertedAmount.startsWith(doubleMinus)) {
+                invertedAmount = invertedAmount.slice(doubleMinus.length);
+            }
+        }
+        bookingLines.push(
+            newBookingLine(
+                "?",
+                invertedAmount,
+                bookingLine.currency
+            )
+        );
+    });
+    taintAndUpdateForm();
+};
+
+const fillSink = () => {
+    const
+        sinkAmountsPerCurrency = {},
+        sinkIndices = [],
+        sumPerCurrency = {};
+    let
+        sinkAccount = "",
+        sinkIndicesIdx = IDX_START;
+    bookingLines.forEach((bookingLine, idx) => {
+        if (idx === IDX_START) {
+            return;
+        }
+        const currency = bookingLine.currency || "€";
+        if (bookingLine.amount === "None") {
+            if (sinkAccount === bookingLine.account || !sinkAccount) {
+                if (!sinkAccount) {
+                    sinkAccount = bookingLine.account;
+                }
+                sinkIndices.push(idx);
+            }
+        } else {
+            if (!Object.hasOwn(
+                sumPerCurrency,
+                currency
+            )) {
+                sumPerCurrency[currency] = 0;
+            }
+            sumPerCurrency[currency] += parseFloat(bookingLine.amount);
+        }
+    });
+    if (!sinkAccount) {
+        sinkAccount = "?";
+    }
+    for (const [
+        currency,
+        amount
+    ] of Object.entries(sumPerCurrency)) {
+        if (amount !== LEN_EMPTY) {
+            sinkAmountsPerCurrency[currency] = -amount;
+        }
+    }
+    for (
+        let idx = IDX_START;
+        idx < Object.keys(sinkAmountsPerCurrency).length - sinkIndices.length;
+        idx++
+    ) {
+        sinkIndices.push(bookingLines.length);
+        bookingLines.push(newBookingLine(sinkAccount));
+    }
+    for (const [
+        currency,
+        amount
+    ] of Object.entries(sinkAmountsPerCurrency)) {
+        const bookingLine = bookingLines[sinkIndices[sinkIndicesIdx]];
+        sinkIndicesIdx++;
+        bookingLine.currency = currency;
+        bookingLine.amount = amount.toString();
+    }
+    taintAndUpdateForm();
+};
+
+window.onload = () => {
+    document.getElementById("btn_mirror").onclick = mirror;
+    document.getElementById("btn_sink").onclick = fillSink;
+    document.getElementById("btn_replace").onclick = replace;
+    updateForm();
+};
+
index 9921fbda8d593e174065d29c9777e20b67bdf3a7..59e52d80ad86d47aff8e3a92e9b0b98509e77bbd 100644 (file)
@@ -18,247 +18,19 @@ input.amount {
 
 
 {% block script %}
-{{ macros.js_taint() }}
-var raw_gap_lines = {{ raw_gap_lines|tojson|safe }};
-var booking_lines = {{ booking_lines|tojson|safe }};
-
-function new_booking_line(account='', amount='None', currency='') {
-    return {
-        errors: [],
-        comment: '',
-        account: account,
-        amount: amount,
-        currency: currency,
-    };
-}
-
-function update_form() {
-    // 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 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 would act as form submit
-        btn.disabled = disabled;
-        btn.onclick = function() {
-            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('skip')) {
-                    n_rows_skipped++;
-                    continue;
-                };
-                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();
-            taint();
-            update_form();
-        };
-    }
-    function add_td(tr, colspan=1) {
-        const td = document.createElement('td');
-        tr.appendChild(td);
-        td.colSpan = colspan;
-        return td;
-    }
-
-    // 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);
-
-        // helpers depending on line-specific variables
-        function setup_input_td(tr, colspan) {
-            const td = add_td(tr, colspan);
-            if (booking_line.errors.length > 0) {
-                td.classList.add('critical');
-            };
-            return td;
-        }
-        function add_input(td, name, value, size) {
-            const input = document.createElement('input');
-            td.appendChild(input);
-            input.name = `line_${i}_${name}`;
-            input.value = value.trim();
-            input.size = size;
-            input.oninput = taint;
-            return input;
-        }
-        function add_td_input(name, value, size=20, colspan=1) {
-            return add_input(setup_input_td(tr, colspan), name, value, size);
-        }
-
-        // movement buttons
-        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 < booking_lines.length}
-            ].forEach((kwargs) => {
-                add_button(td_btns_updown, kwargs.label, ! kwargs.enabled, function() {
-                    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 (i == 0) {
-            const td = setup_input_td(tr, 3);
-            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_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('amount');
-            // ensure integer amounts at least line up with double-digit decimals
-            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', booking_line.currency, 3);
-            curr_input.placeholder = '€';
-        }
-        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() {
-            booking_lines.splice(i + 1, 0, new_booking_line());
-        });
-        if (i > 0) {
-            add_button(td_add_del, 'delete',  i > 0 ? false : true, function() {
-                booking_lines.splice(i, 1);
-            });
-        }
-
-        // add error explanation row if necessary
-        if (booking_line.errors.length > 0) {
-            tr.classList.add('critical');
-            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('critical');
-        }
-    }
-}
-
-function replace() {
-    const from = document.getElementById('replace_from').value;
-    const to = document.getElementById('replace_to').value;
-    raw_gap_lines.forEach((line) => {
-        line.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);
-            }
-        });
-    });
-    taint();
-    update_form();
-}
-
-function mirror() {
-    booking_lines.slice(1).forEach((booking_line) => {
-        let inverted_amount = 'None';
-        if (booking_line.amount != 'None') {
-            inverted_amount = `-${booking_line.amount}`;
-            if (inverted_amount.startsWith('--')) {
-                inverted_amount = inverted_amount.slice(2);
-            }
-        }
-        booking_lines.push(new_booking_line('?', inverted_amount, booking_line.currency));
-    })
-    taint();
-    update_form();
-}
-
-function fill_sink() {
-    let sink_account = '';
-    let sink_indices = [];
-    const sum_per_currency = {};
-    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;
-                }
-                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) {
-        sink_account = '?';
-    }
-    const sink_amounts_per_currency = {};
-    for (const [currency, amount] of Object.entries(sum_per_currency)) {
-        if (amount != 0) {
-            sink_amounts_per_currency[currency] = -amount;
-        }
-    }
-    for (i = 0; i < Object.keys(sink_amounts_per_currency).length - sink_indices.length; i++) {
-        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 booking_line = booking_lines[sink_indices[sink_indices_index]];
-        sink_indices_index++;
-        booking_line.currency = currency;
-        booking_line.amount = amount.toString();
-    }
-    taint();
-    update_form();
-}
-
-window.onload = update_form;
+<script type="module" src="/edit_structured.js/{{ block.id_ }}">
+</script>
 {% endblock %}
 
 
 
 {% block content %}
 {{ macros.edit_bar(block, 'structured', 'raw') }}
-<span class="disable_on_change">
-<input type="button" onclick="mirror()" value="mirror">
-<input type="button" onclick="fill_sink()" value="fill sink">
+<span class="disable_on_taint">
+<input id="btn_mirror" type="button" value="mirror">
+<input id="btn_sink" type="button" value="fill sink">
 |
-<input type="button" onclick="replace()" value="replace string">
+<input id="btn_replace" type="button" value="replace string">
 from
 <input id="replace_from" />
 to
@@ -272,7 +44,7 @@ to
           name="raw_lines"
           cols=100
           rows={{ raw_gap_lines|length + 1 }}
-          oninput="taint()"
+          class="tainter"
           >
 </textarea>
 </form>
diff --git a/src/templates/taint.js b/src/templates/taint.js
new file mode 100644 (file)
index 0000000..92f89dc
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+global
+document
+window
+*/
+/*
+eslint
+"brace-style": [
+    "error",
+    "1tbs",
+    { "allowSingleLine": true }
+],
+"capitalized-comments": [
+    "error",
+    "never"
+],
+"function-paren-newline": "off",
+"max-lines-per-function": [
+    "error",
+    46
+],
+max-statements-per-line: [
+    "error",
+    { "max": 2 }
+],
+"multiline-comment-style": [
+    "error",
+    "bare-block"
+],
+"newline-after-var": "off",
+"padded-blocks": [
+    "error",
+    "never"
+],
+*/
+let suppressBeforeUnload = false;
+
+export const taint = () => {
+    // activate buttons "apply", "revert"
+    Array.from(document.getElementsByClassName("enable_on_taint")).forEach(
+        (el) => {
+            el.disabled = false;
+            el.onclick = () => { suppressBeforeUnload = true; };
+        }
+    );
+    // deactivate "disable_on_taint" span contents
+    const recursiveSpanDisable = (el) => {
+        const oldNodes = Array.from(el.childNodes);
+        el.innerHTML = "";
+        oldNodes.forEach((node) => {
+            if (node.tagName === "SPAN") {
+                recursiveSpanDisable(node);
+                el.appendChild(node);
+            } else if (node.tagName === "INPUT") {
+                node.disabled = true;
+                el.appendChild(node);
+            } else if (node.tagName === "A") {
+                const del = document.createElement("del");
+                del.textContent = node.textContent;
+                el.appendChild(del);
+            } else {
+                el.appendChild(node);
+            }
+        });
+    };
+    Array.from(document.getElementsByClassName("disable_on_taint")).forEach(
+        (el) => recursiveSpanDisable(el)
+    );
+    // try to catch user closing or reloading window
+    window.addEventListener(
+        "beforeunload",
+        (evt) => {
+            if (!suppressBeforeUnload) {
+                evt.preventDefault();
+                evt.returnValue = 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; });
+};
+
+window.onload = () => {
+    Array.from(document.getElementsByClassName("tainter")).forEach(
+        (el) => { el.oninput = taint; }
+    );
+};
+
index cfd8da55efea01c2ec45556a2d7bdae54e72c60f..f4a8f0c360924e7e11cab7d0aae199952534ed99 100644 (file)
@@ -2,8 +2,6 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
-</script>
 <style>
 html {
     scroll-padding-top: 2em;
@@ -73,7 +71,7 @@ span.indent {
 <body>
 <div id="header">
     <form action="" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>
index 6c6665807a2c43a4d3f84a1541946a05a4252b50..1ac17d3649ff1b5c6d9813cf49cd1f1098f0831e 100644 (file)
@@ -2,8 +2,6 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
-</script>
 <style>
 html {
     scroll-padding-top: 2em;
@@ -46,7 +44,7 @@ table {
 <body>
 <div id="header">
     <form action="" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>
index b173595f8756f019f814630b90ca1a092edfac8f..5d6be3041f45bdd08585552fcfeaf20e54327aa0 100644 (file)
@@ -2,8 +2,6 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
-</script>
 <style>
 html {
     scroll-padding-top: 2em;
@@ -51,7 +49,7 @@ td.currency {
 <body>
 <div id="header">
     <form action="" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>
index 3422bb3934b39b76d3b7e4d1aa9b966412f120e1..7344f13504f6b0ee9224cc9e1c74e1b0391b55c9 100644 (file)
@@ -2,8 +2,6 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
-</script>
 <style>
 html {
     scroll-padding-top: 2em;
@@ -73,7 +71,7 @@ span.indent {
 <body>
 <div id="header">
     <form action="" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>
index 8c2a3b125bb76984af2c19d514fe7bc3c55cb497..58aa58875292104a2947270caa010df0d27f6c9e 100644 (file)
@@ -2,53 +2,7 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
-var suppress_beforeunload = false;
-
-function taint() {
-    // activate buttons "apply", "revert"
-    Array.from(document.getElementsByClassName('enable_on_change')).forEach((el) => {
-        el.disabled = false;
-        el.addEventListener('click', function(e) {
-           suppress_beforeunload = true;
-        });
-    });
-    // deactivate "disable_on_change" span contents
-    function recursive_span_disable(el) {
-        old_nodes = Array.from(el.childNodes);
-        el.innerHTML = '';
-        old_nodes.forEach((node) => {
-            if (node.tagName == 'SPAN') {
-                recursive_span_disable(node);
-                el.appendChild(node);
-            } else if (node.tagName == 'INPUT') {
-                node.disabled = true;
-                el.appendChild(node);
-            } else if (node.tagName == 'A') {
-                const del = document.createElement('del');
-                del.textContent = node.textContent;
-                el.appendChild(del);
-            } else {
-                el.appendChild(node);
-            };
-        });
-    }
-    Array.from(document.getElementsByClassName('disable_on_change')).forEach((el) => {
-        recursive_span_disable(el);
-    });
-    // try to catch user closing or reloading window
-    window.addEventListener('beforeunload', function(e) {
-        if (!suppress_beforeunload) {
-            e.preventDefault();
-            e.returnValue = 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);
-}
-
+<script type="module" src="/taint.js">
 </script>
 <style>
 html {
@@ -106,7 +60,7 @@ td.direct_target {
 <body>
 <div id="header">
     <form action="" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>
@@ -115,23 +69,23 @@ td.direct_target {
     </form>
 </div>
 <form action="/edit_raw/1" method="POST">
-<span class="disable_on_change">
+<span class="disable_on_taint">
 <a href="/blocks/0">prev</a>
 <a href="/blocks/2">next</a>
 </span>
-<input class="enable_on_change"
+<input class="enable_on_taint"
        type="submit"
        name="apply"
        value="apply"
        disabled
        />
-<input class="enable_on_change"
+<input class="enable_on_taint"
        type="submit"
        name="revert"
        value="revert"
        disabled
        />
-<span class="disable_on_change">
+<span class="disable_on_taint">
 <a href="/edit_structured/1">switch to structured</a>
 ·
 <a href="/balance?up_incl=1">balance after</a>
@@ -141,9 +95,9 @@ td.direct_target {
 <hr />
 
 <textarea name="raw_lines"
+          class="tainter"
           cols=100
           rows=5
-          oninput="taint()"
           >
 2001-01-01 test ; foo
   foo  10 €
index 6e793eef7977feb907d3619220ebefae14b2e691..bdecd6944d24f7ac0ac24f3c38a1e7a80189d48e 100644 (file)
@@ -2,53 +2,7 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
-var suppress_beforeunload = false;
-
-function taint() {
-    // activate buttons "apply", "revert"
-    Array.from(document.getElementsByClassName('enable_on_change')).forEach((el) => {
-        el.disabled = false;
-        el.addEventListener('click', function(e) {
-           suppress_beforeunload = true;
-        });
-    });
-    // deactivate "disable_on_change" span contents
-    function recursive_span_disable(el) {
-        old_nodes = Array.from(el.childNodes);
-        el.innerHTML = '';
-        old_nodes.forEach((node) => {
-            if (node.tagName == 'SPAN') {
-                recursive_span_disable(node);
-                el.appendChild(node);
-            } else if (node.tagName == 'INPUT') {
-                node.disabled = true;
-                el.appendChild(node);
-            } else if (node.tagName == 'A') {
-                const del = document.createElement('del');
-                del.textContent = node.textContent;
-                el.appendChild(del);
-            } else {
-                el.appendChild(node);
-            };
-        });
-    }
-    Array.from(document.getElementsByClassName('disable_on_change')).forEach((el) => {
-        recursive_span_disable(el);
-    });
-    // try to catch user closing or reloading window
-    window.addEventListener('beforeunload', function(e) {
-        if (!suppress_beforeunload) {
-            e.preventDefault();
-            e.returnValue = 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);
-}
-
+<script type="module" src="/taint.js">
 </script>
 <style>
 html {
@@ -106,7 +60,7 @@ td.direct_target {
 <body>
 <div id="header">
     <form action="" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>
@@ -115,23 +69,23 @@ td.direct_target {
     </form>
 </div>
 <form action="/edit_raw/4" method="POST">
-<span class="disable_on_change">
+<span class="disable_on_taint">
 <a href="/blocks/3">prev</a>
 <del>next</del>
 </span>
-<input class="enable_on_change"
+<input class="enable_on_taint"
        type="submit"
        name="apply"
        value="apply"
        disabled
        />
-<input class="enable_on_change"
+<input class="enable_on_taint"
        type="submit"
        name="revert"
        value="revert"
        disabled
        />
-<span class="disable_on_change">
+<span class="disable_on_taint">
 <a href="/edit_structured/4">switch to structured</a>
 ·
 <a href="/balance?up_incl=4">balance after</a>
@@ -147,9 +101,9 @@ td.direct_target {
 <hr />
 
 <textarea name="raw_lines"
+          class="tainter"
           cols=100
           rows=7
-          oninput="taint()"
           >
 2001-01-01 test
   foo:x  10 €
index b7ed479a8c8c25c1cb51ad640ef580692275cc54..19eb486299b321df21fd88660893ec259329abad 100644 (file)
@@ -2,8 +2,6 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
-</script>
 <style>
 html {
     scroll-padding-top: 2em;
@@ -46,7 +44,7 @@ table {
 <body>
 <div id="header">
     <form action="" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>
index b7681534e29daf7bc19eaa15cd43155cdb1c54b2..7a2c49ca269a66d507fae1bb593bae3aa820e6ef 100644 (file)
@@ -2,8 +2,6 @@
 <html>
 <head>
 <meta charset="UTF-8">
-<script>
-</script>
 <style>
 html {
     scroll-padding-top: 2em;
@@ -51,7 +49,7 @@ td.currency {
 <body>
 <div id="header">
     <form action="" method="POST">
-        <span class="disable_on_change">
+        <span class="disable_on_taint">
             ledger <a href="/ledger_structured">structured</a>
             / <a href="/ledger_raw">raw</a>
             · <a href="/balance">balance</a>