From b7094f728efc2d75b7fd175cc783b2bab123156f Mon Sep 17 00:00:00 2001 From: nanobot Date: Tue, 3 Nov 2026 15:02:50 -0600 Subject: [PATCH] fix(notify): group counters instead of padding columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushover renders the notification body in a proportional font, so the space-padded label/value columns never lined up on a phone — the body collapsed into a ragged flat list with no visual grouping. The html variant was worse: the padding sat inside the HTML, so it collapsed to a single space. Counters are now grouped two per line and joined with a middle dot, which survives any font. The body drops from 8 lines to 5 and no longer restates the outcome the title already carries. - replace _notify_row/LABEL_WIDTH/VALUE_WIDTH with _stat_row - add docs/notify-preview.py, which renders the real build_summary() output as a phone-width PNG so layout changes can be reviewed visually - tests: assert no space padding, two counters per row, and that the body does not repeat the title --- README.md | 41 +++++--- config.toml.example | 4 +- docs/notify-preview.png | Bin 0 -> 57488 bytes docs/notify-preview.py | 214 ++++++++++++++++++++++++++++++++++++++++ qlogging.py | 59 +++++++---- test_qbitmaid.py | 46 ++++++--- 6 files changed, 315 insertions(+), 49 deletions(-) create mode 100644 docs/notify-preview.png create mode 100644 docs/notify-preview.py diff --git a/README.md b/README.md index 5ff74af..220c2f5 100755 --- a/README.md +++ b/README.md @@ -137,35 +137,46 @@ Pushover and Apprise share one summary builder, so both report the same numbers. The title states the outcome, the body gives the scanned total, the actionable counters, then the tracker split. -`format = "plain"` (default) produces aligned text: +`format = "plain"` (default) produces a compact, grouped summary: ``` qbit-maid: 7 torrents removed Scanned 42 torrents in 12s -Removed 7 -Orphaned 4 -Ignored 5 -Premature 3 +Removed 7 · Orphaned 4 +Ignored 5 · Premature 3 -Protected 10 -Non-protected 20 +Protected 10 · Non-protected 20 ``` -`format = "html"` bolds the labels and counts using Pushover's HTML subset -(`html=1` is set automatically). Apprise has no HTML support, so it always -receives the plain body. +Counters are grouped two per line and joined with a middle dot rather than +padded into columns. Pushover renders the body in a proportional font, so +space-padded columns collapse into a ragged list on a phone — the separator +survives any font. -Setting `use_emoji = true` switches both styles to `emoji label: value` rows: +`format = "html"` bolds the labels using Pushover's HTML subset (`html=1` is +set automatically). Apprise has no HTML support, so it always receives the +plain body. + +Setting `use_emoji = true` prefixes each counter with an emoji marker: ``` -🗑️ Removed: 7 -👻 Orphaned: 4 -⏭️ Ignored: 5 -🐣 Premature: 3 +🗑️ Removed 7 · 👻 Orphaned 4 +⏭️ Ignored 5 · 🐣 Premature 3 + +🛡️ Protected 10 · 🌐 Non-protected 20 ``` +To preview the result without sending a push, render the real +`build_summary()` output as a phone-width mockup: + +``` +python3 docs/notify-preview.py /tmp/notify.png +``` + +Pass `--mock` to render the design sketches instead of the live output. + Other options: - `include_hostname` — add a `Host:` line, useful when several instances notify diff --git a/config.toml.example b/config.toml.example index 2c75394..a1a7df9 100755 --- a/config.toml.example +++ b/config.toml.example @@ -26,7 +26,9 @@ use_pushover = false po_key = "" po_token = "" # Notification formatting -# format: "plain" (aligned text) or "html" (bold labels, Pushover HTML) +# format: "plain" (grouped text) or "html" (bold labels, Pushover HTML) +# Counters are grouped two per line and joined with a middle dot, because +# Pushover uses a proportional font and padded columns do not align. format = "plain" use_emoji = false include_hostname = false diff --git a/docs/notify-preview.png b/docs/notify-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..b5f2d48a6d90d7af8f6f601ef0eabe8ddd2c6115 GIT binary patch literal 57488 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 Ogu&C*&t;ucLK6T80F}4^ literal 0 HcmV?d00001 diff --git a/docs/notify-preview.py b/docs/notify-preview.py new file mode 100644 index 0000000..5fca4b0 --- /dev/null +++ b/docs/notify-preview.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Render notification layouts as a phone-width PNG mockup. + +Pushover renders the body in a proportional font, so space-padded columns do +not line up on a phone. These mockups use a proportional font on purpose so +the preview matches what the user actually sees. + +By default the cards are rendered from the real ``build_summary()`` output, so +the preview is ground truth rather than a hand-written approximation. Pass +``--mock`` to render the built-in candidate sketches instead. + +Usage: python3 docs/notify-preview.py [--mock] [out.png] +""" +import datetime +import html +import os +import subprocess +import sys +import tempfile +from collections import Counter + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +STATS = { + "total": 42, + "removed": 7, + "orphaned": 4, + "ignored": 5, + "premature": 3, + "protected": 10, + "non_protected": 20, + "runtime": "12s", +} + +EMOJI = { + "removed": "\U0001F5D1\uFE0F", + "orphaned": "\U0001F47B", + "ignored": "\u23ED\uFE0F", + "premature": "\U0001F423", + "protected": "\U0001F6E1\uFE0F", + "non_protected": "\U0001F310", +} + + +def b(text): + return f"{text}" + + +# --- candidate layouts ----------------------------------------------------- +# Each returns (title, body_html). Body uses Pushover's HTML subset. + +def current_table(s): + """Today's layout: space-padded columns.""" + rows = [ + ("Removed", s["removed"]), + ("Orphaned", s["orphaned"]), + ("Ignored", s["ignored"]), + ("Premature", s["premature"]), + ] + tail = [("Protected", s["protected"]), ("Non-protected", s["non_protected"])] + out = [f"Scanned {s['total']} torrents in {s['runtime']}", ""] + out += [f"{b(lbl)}{' ' * max(14 - len(lbl), 1)}{val:>4}" for lbl, val in rows] + out.append("") + out += [f"{b(lbl)}{' ' * max(14 - len(lbl), 1)}{val:>4}" for lbl, val in tail] + return "qbit-maid: 7 torrents removed", "\n".join(out) + + +def compact_inline(s): + """One line per group, middle-dot separated.""" + out = [ + f"Scanned {s['total']} torrents in {s['runtime']}", + "", + f"{b('Removed')} {s['removed']} \u00b7 {b('Orphaned')} {s['orphaned']}", + f"{b('Ignored')} {s['ignored']} \u00b7 {b('Premature')} {s['premature']}", + "", + f"{b('Protected')} {s['protected']} \u00b7 {b('Non-protected')} {s['non_protected']}", + ] + return "qbit-maid: 7 torrents removed", "\n".join(out) + + +def emoji_rows(s): + """Emoji-led rows, no padding.""" + out = [f"Scanned {s['total']} torrents in {s['runtime']}", ""] + for key, label in (("removed", "Removed"), ("orphaned", "Orphaned"), + ("ignored", "Ignored"), ("premature", "Premature")): + out.append(f"{EMOJI[key]} {b(label)} {s[key]}") + out.append("") + for key, label in (("protected", "Protected"), ("non_protected", "Non-protected")): + out.append(f"{EMOJI[key]} {b(label)} {s[key]}") + return "qbit-maid: 7 torrents removed", "\n".join(out) + + +def hero_line(s): + """Actionable numbers up front, everything else compact below.""" + out = [ + f"{b(str(s['removed']))} removed \u00b7 {b(str(s['orphaned']))} orphaned", + f"{s['total']} torrents scanned in {s['runtime']}", + "", + f"Ignored {s['ignored']} \u00b7 Premature {s['premature']}", + f"Protected {s['protected']} \u00b7 Non-protected {s['non_protected']}", + ] + return "qbit-maid: 7 torrents removed", "\n".join(out) + + +def emoji_inline(s): + """Compact inline, emoji instead of bold labels.""" + out = [ + f"Scanned {s['total']} torrents in {s['runtime']}", + "", + f"{EMOJI['removed']} {s['removed']} removed \u00b7 {EMOJI['orphaned']} {s['orphaned']} orphaned", + f"{EMOJI['ignored']} {s['ignored']} ignored \u00b7 {EMOJI['premature']} {s['premature']} premature", + "", + f"{EMOJI['protected']} {s['protected']} protected \u00b7 {EMOJI['non_protected']} {s['non_protected']} public", + ] + return "qbit-maid: 7 torrents removed", "\n".join(out) + + +MOCK_CANDIDATES = [ + ("A \u2014 current (padded table)", current_table), + ("B \u2014 compact inline", compact_inline), + ("C \u2014 emoji rows", emoji_rows), + ("D \u2014 hero line", hero_line), + ("E \u2014 emoji inline", emoji_inline), +] + + +class Sample: + """Minimal stand-in for the Qbt object, holding only what notifiers read.""" + + def __init__(self, **overrides): + self.config = {"pushover": {}} + self.total_torrents = STATS["total"] + self.torrent_hash_delete_list = ["a"] * STATS["removed"] + self.up_tor_counter = STATS["orphaned"] + self.ignored_counter = STATS["ignored"] + self.preme_tor_counter = STATS["premature"] + self.tracker_protected_tag = "ipt" + self.tracker_non_protected_tag = "public" + self.c = Counter({"ipt": STATS["protected"], + "public": STATS["non_protected"]}) + self.delete_torrents = True + self.st = datetime.datetime(2026, 10, 27, 9, 54, 0) + self.et = datetime.datetime(2026, 10, 27, 9, 54, 12) + for key, value in overrides.items(): + setattr(self, key, value) + + +def real_candidates(): + """Render the actual build_summary() output for each supported style.""" + from qlogging import build_summary + + out = [] + for style in ("plain", "html"): + for emoji in (False, True): + title, body = build_summary( + Sample(), style=style, use_emoji=emoji, include_hostname=False + ) + label = f"{style}" + (" + emoji" if emoji else "") + out.append((label, title, body)) + return out + + +def card(name, title, body): + body_html = "
".join(body.split("\n")) + return f""" +

+
PUSHOVER \u00b7 {html.escape(name)}
+
{html.escape(title)}
+
{body_html}
+
+ """ + + +def build_html(mock=False): + if mock: + cards = "\n".join(card(n, *fn(STATS)) for n, fn in MOCK_CANDIDATES) + else: + cards = "\n".join(card(n, t, b) for n, t, b in real_candidates()) + return f""" +
{cards}
+""" + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + mock = "--mock" in sys.argv[1:] + out = args[0] if args else "/tmp/notify-preview.png" + workdir = tempfile.mkdtemp(prefix="notify-preview-") + page = os.path.join(workdir, "preview.html") + with open(page, "w", encoding="utf-8") as fh: + fh.write(build_html(mock=mock)) + subprocess.run([ + "docker", "run", "--rm", "-v", f"{workdir}:/w", "-w", "/w", + "--entrypoint", "google-chrome", "dgtlmoon/sockpuppetbrowser:latest", + "--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", + "--hide-scrollbars", "--window-size=880,760", + "--screenshot=/w/preview.png", "file:///w/preview.html", + ], check=True, capture_output=True) + subprocess.run(["cp", os.path.join(workdir, "preview.png"), out], check=True) + print(out) + + +if __name__ == "__main__": + main() diff --git a/qlogging.py b/qlogging.py index 50b7c22..ae3a020 100755 --- a/qlogging.py +++ b/qlogging.py @@ -108,8 +108,10 @@ def get_script_runtime(self): # --------------------------------------------------------------------------- NOTIFY_STYLES = ("plain", "html") DEFAULT_NOTIFY_STYLE = "plain" -LABEL_WIDTH = 14 -VALUE_WIDTH = 4 +# Pushover renders the body in a proportional font, so space-padded columns +# collapse into a ragged list. Rows are therefore joined with a visible +# separator instead of being aligned with spaces. +ROW_SEPARATOR = " \u00b7 " NOTIFY_EMOJI = { "removed": "\U0001F5D1\uFE0F", @@ -211,18 +213,26 @@ def debug_torrent_list(self): self.tl.debug(self.torrent_list) -def _notify_row(label, value, style, use_emoji, key): - """Render one label/value line for the chosen style.""" - emoji = NOTIFY_EMOJI.get(key, "") if use_emoji else "" - if use_emoji: +def _stat_row(pairs, style, use_emoji): + """Render one line of ``Label value`` pairs joined by a middle dot. + + Args: + pairs: Sequence of ``(emoji_key, label, value)`` tuples. + style: ``"plain"`` or ``"html"``. + use_emoji: Prefix each pair with its emoji marker. + + Returns: + A single line of text, e.g. ``Removed 7 \u00b7 Orphaned 4``. + """ + parts = [] + for key, label, value in pairs: + emoji = NOTIFY_EMOJI.get(key, "") if use_emoji else "" prefix = f"{emoji} " if emoji else "" if style == "html": - return f"{prefix}{label}: {value}" - return f"{prefix}{label}: {value}" - if style == "html": - pad = " " * max(LABEL_WIDTH - len(label), 1) - return f"{label}{pad}{value:>{VALUE_WIDTH}}" - return f"{label:<{LABEL_WIDTH}}{value:>{VALUE_WIDTH}}" + parts.append(f"{prefix}{label} {value}") + else: + parts.append(f"{prefix}{label} {value}") + return ROW_SEPARATOR.join(parts) def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False, @@ -230,10 +240,12 @@ def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False, """Build the ``(title, body)`` pair used by every notifier. Result-first layout: the title states what happened, the body gives the - scanned total, the actionable counters, then the tracker split. + scanned total, the actionable counters, then the tracker split. Counters + are grouped two-per-line and separated by a middle dot, because Pushover + renders a proportional font and space-padded columns do not line up. Args: - style: ``"plain"`` for aligned text, ``"html"`` for Pushover HTML. + style: ``"plain"`` for grouped text, ``"html"`` for Pushover HTML. use_emoji: Prefix each row with an emoji marker. include_hostname: Add a ``Host:`` line above the summary. @@ -275,14 +287,19 @@ def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False, lines.append("") removed_label = "Removed" if delete_enabled else "Would remove" - lines.append(_notify_row(removed_label, removed, style, use_emoji, "removed")) - lines.append(_notify_row("Orphaned", stats["orphaned"], style, use_emoji, "orphaned")) - lines.append(_notify_row("Ignored", stats["ignored"], style, use_emoji, "ignored")) - lines.append(_notify_row("Premature", stats["premature"], style, use_emoji, "premature")) + lines.append(_stat_row([ + ("removed", removed_label, removed), + ("orphaned", "Orphaned", stats["orphaned"]), + ], style, use_emoji)) + lines.append(_stat_row([ + ("ignored", "Ignored", stats["ignored"]), + ("premature", "Premature", stats["premature"]), + ], style, use_emoji)) lines.append("") - lines.append(_notify_row("Protected", stats["protected"], style, use_emoji, "protected")) - lines.append(_notify_row("Non-protected", stats["non_protected"], style, - use_emoji, "non_protected")) + lines.append(_stat_row([ + ("protected", "Protected", stats["protected"]), + ("non_protected", "Non-protected", stats["non_protected"]), + ], style, use_emoji)) return title, "\n".join(lines) diff --git a/test_qbitmaid.py b/test_qbitmaid.py index 97e7e46..7d04198 100755 --- a/test_qbitmaid.py +++ b/test_qbitmaid.py @@ -196,13 +196,10 @@ class TestQbitmaid(unittest.TestCase): body, "Scanned 42 torrents in 12s\n" "\n" - "Removed 7\n" - "Orphaned 4\n" - "Ignored 5\n" - "Premature 3\n" + "Removed 7 \u00b7 Orphaned 4\n" + "Ignored 5 \u00b7 Premature 3\n" "\n" - "Protected 10\n" - "Non-protected 20", + "Protected 10 \u00b7 Non-protected 20", ) def test_build_summary_plain_has_no_leading_indent(self): @@ -210,27 +207,52 @@ class TestQbitmaid(unittest.TestCase): for line in body.splitlines(): self.assertFalse(line.startswith(" "), f"unexpected indent: {line!r}") + def test_build_summary_has_no_space_padding(self): + """Pushover uses a proportional font, so padded columns never align.""" + for style in ("plain", "html"): + for emoji in (False, True): + _, body = build_summary(FakeQbt(), style=style, use_emoji=emoji) + for line in body.splitlines(): + stripped = line.replace(" \u00b7 ", "") + self.assertNotIn(" ", stripped, + f"padded column in {style}/{emoji}: {line!r}") + + def test_build_summary_groups_two_counters_per_line(self): + _, body = build_summary(FakeQbt(), style="plain") + rows = [ln for ln in body.splitlines() if "\u00b7" in ln] + self.assertEqual(len(rows), 3) + for row in rows: + self.assertEqual(row.count("\u00b7"), 1, row) + def test_build_summary_html(self): title, body = build_summary(FakeQbt(), style="html") self.assertEqual(title, "qbit-maid: 7 torrents removed") self.assertIn("Scanned 42 torrents in 12s", body) - self.assertIn("Removed", body) - self.assertIn("Non-protected", body) + self.assertIn("Removed 7", body) + self.assertIn("Non-protected 20", body) self.assertNotIn("---", title) def test_build_summary_emoji(self): _, plain = build_summary(FakeQbt(), style="plain", use_emoji=True) - self.assertIn("\U0001F5D1\uFE0F Removed: 7", plain) - self.assertIn("\U0001F47B Orphaned: 4", plain) + self.assertIn("\U0001F5D1\uFE0F Removed 7", plain) + self.assertIn("\U0001F47B Orphaned 4", plain) _, html = build_summary(FakeQbt(), style="html", use_emoji=True) - self.assertIn("\U0001F5D1\uFE0F Removed: 7", html) + self.assertIn("\U0001F5D1\uFE0F Removed 7", html) def test_build_summary_delete_disabled(self): title, body = build_summary(FakeQbt(delete_torrents=False)) self.assertEqual(title, "qbit-maid: 7 torrents would be removed") - self.assertIn("Would remove", body) + self.assertIn("Would remove 7", body) self.assertNotIn("Removed", body) + def test_build_summary_does_not_repeat_title_in_body(self): + """The title already states the outcome; the body must not restate it.""" + title, body = build_summary(FakeQbt(), style="plain") + self.assertNotIn("7 removed", body) + self.assertNotIn("torrents removed", body) + self.assertIn("Removed 7", body) + self.assertTrue(title.endswith("7 torrents removed")) + def test_build_summary_nothing_to_do(self): title, _ = build_summary(FakeQbt(torrent_hash_delete_list=[], up_tor_counter=0)) self.assertEqual(title, "qbit-maid: nothing removed")