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 0000000..b5f2d48 Binary files /dev/null and b/docs/notify-preview.png differ 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")