fix(notify): group counters instead of padding columns
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
This commit is contained in:
1 parent
881765cdf5
commit
b7094f728e
6 files changed
+315
-49
No files matched your search
@@ -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
|
||||
|
||||
+3
-1
@@ -26,7 +26,9 @@ use_pushover = false
|
||||
po_key = "<key>"
|
||||
po_token = "<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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -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"<b>{text}</b>"
|
||||
|
||||
|
||||
# --- 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 = "<br>".join(body.split("\n"))
|
||||
return f"""
|
||||
<div class="card">
|
||||
<div class="app">PUSHOVER \u00b7 {html.escape(name)}</div>
|
||||
<div class="title">{html.escape(title)}</div>
|
||||
<div class="body">{body_html}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
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"""<!doctype html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
body {{ margin:0; padding:18px; background:#1b1d21;
|
||||
font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }}
|
||||
.grid {{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }}
|
||||
.card {{ background:#2b2f36; border-radius:14px; padding:14px 16px;
|
||||
box-shadow:0 2px 10px rgba(0,0,0,.45); }}
|
||||
.app {{ font-size:10px; letter-spacing:.14em; color:#8b929c; margin-bottom:6px; }}
|
||||
.title {{ font-size:15px; font-weight:600; color:#f2f4f7; margin-bottom:8px; }}
|
||||
.body {{ font-size:14px; line-height:1.45; color:#d7dbe0; white-space:normal; }}
|
||||
.body b {{ color:#ffffff; }}
|
||||
</style></head><body><div class="grid">{cards}</div></body></html>
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
+38
-21
@@ -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}<b>{label}</b>: {value}"
|
||||
return f"{prefix}{label}: {value}"
|
||||
if style == "html":
|
||||
pad = " " * max(LABEL_WIDTH - len(label), 1)
|
||||
return f"<b>{label}</b>{pad}{value:>{VALUE_WIDTH}}"
|
||||
return f"{label:<{LABEL_WIDTH}}{value:>{VALUE_WIDTH}}"
|
||||
parts.append(f"{prefix}<b>{label}</b> {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)
|
||||
|
||||
|
||||
+34
-12
@@ -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 <b>42</b> torrents in <b>12s</b>", body)
|
||||
self.assertIn("<b>Removed</b>", body)
|
||||
self.assertIn("<b>Non-protected</b>", body)
|
||||
self.assertIn("<b>Removed</b> 7", body)
|
||||
self.assertIn("<b>Non-protected</b> 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 <b>Removed</b>: 7", html)
|
||||
self.assertIn("\U0001F5D1\uFE0F <b>Removed</b> 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")
|
||||
|
||||
Reference in new issue
Block a user