feat(notify): value-first emoji rows
Switch the summary to the layout jblu picked: each counter renders as
"emoji value label", grouped two per line and joined by a middle dot.
Scanned 42 torrents in 12s
🗑️ 7 removed · 👻 4 orphaned
⏭️ 5 ignored · 🐣 3 premature
🛡️ 10 protected · 🌐 20 public
- the value leads, so the numbers are the first thing the eye lands on
- html style now bolds the values instead of the labels
- "Non-protected" reads as "public", matching the non_protected_tag
- use_emoji now defaults to true; false gives the plain variant
- dry runs read "7 would be removed"
This commit is contained in:
1 parent
5d9da0d22b
commit
dfce115fd8
5 files changed
+90
-51
No files matched your search
@@ -137,35 +137,38 @@ 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
|
numbers. The title states the outcome, the body gives the scanned total, the
|
||||||
actionable counters, then the tracker split.
|
actionable counters, then the tracker split.
|
||||||
|
|
||||||
`format = "plain"` (default) produces a compact, grouped summary:
|
The default summary looks like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
qbit-maid: 7 torrents removed
|
qbit-maid: 7 torrents removed
|
||||||
|
|
||||||
Scanned 42 torrents in 12s
|
Scanned 42 torrents in 12s
|
||||||
|
|
||||||
Removed 7 · Orphaned 4
|
🗑️ 7 removed · 👻 4 orphaned
|
||||||
Ignored 5 · Premature 3
|
⏭️ 5 ignored · 🐣 3 premature
|
||||||
|
|
||||||
Protected 10 · Non-protected 20
|
🛡️ 10 protected · 🌐 20 public
|
||||||
```
|
```
|
||||||
|
|
||||||
Counters are grouped two per line and joined with a middle dot rather than
|
Each counter is `emoji value label`, grouped two per line and joined with a
|
||||||
padded into columns. Pushover renders the body in a proportional font, so
|
middle dot. The value leads so the numbers are the first thing the eye lands
|
||||||
space-padded columns collapse into a ragged list on a phone — the separator
|
on, and the separator survives any font — Pushover renders the body in a
|
||||||
survives any font.
|
proportional font, so space-padded columns collapse into a ragged list on a
|
||||||
|
phone.
|
||||||
|
|
||||||
`format = "html"` bolds the labels using Pushover's HTML subset (`html=1` is
|
`format = "html"` bolds the values using Pushover's HTML subset (`html=1` is
|
||||||
set automatically). Apprise has no HTML support, so it always receives the
|
set automatically). Apprise has no HTML support, so it always receives the
|
||||||
plain body.
|
plain body.
|
||||||
|
|
||||||
Setting `use_emoji = true` prefixes each counter with an emoji marker:
|
`use_emoji` defaults to `true`. Set it to `false` for a plain-text variant:
|
||||||
|
|
||||||
```
|
```
|
||||||
🗑️ Removed 7 · 👻 Orphaned 4
|
Scanned 42 torrents in 12s
|
||||||
⏭️ Ignored 5 · 🐣 Premature 3
|
|
||||||
|
|
||||||
🛡️ Protected 10 · 🌐 Non-protected 20
|
7 removed · 4 orphaned
|
||||||
|
5 ignored · 3 premature
|
||||||
|
|
||||||
|
10 protected · 20 public
|
||||||
```
|
```
|
||||||
|
|
||||||
To preview the result without sending a push, render the real
|
To preview the result without sending a push, render the real
|
||||||
|
|||||||
+5
-4
@@ -26,11 +26,12 @@ use_pushover = false
|
|||||||
po_key = "<key>"
|
po_key = "<key>"
|
||||||
po_token = "<token>"
|
po_token = "<token>"
|
||||||
# Notification formatting
|
# Notification formatting
|
||||||
# format: "plain" (grouped text) or "html" (bold labels, Pushover HTML)
|
# format: "plain" (grouped text) or "html" (bold values, Pushover HTML)
|
||||||
# Counters are grouped two per line and joined with a middle dot, because
|
# Counters render as "emoji value label", grouped two per line and joined
|
||||||
# Pushover uses a proportional font and padded columns do not align.
|
# with a middle dot, because Pushover uses a proportional font and padded
|
||||||
|
# columns do not align.
|
||||||
format = "plain"
|
format = "plain"
|
||||||
use_emoji = false
|
use_emoji = true
|
||||||
include_hostname = false
|
include_hostname = false
|
||||||
notify_on_empty = true
|
notify_on_empty = true
|
||||||
# Optional: -2..2. Priority 2 also needs retry/expire (defaults shown).
|
# Optional: -2..2. Priority 2 also needs retry/expire (defaults shown).
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 53 KiB |
+20
-15
@@ -155,7 +155,7 @@ def get_notify_options(self):
|
|||||||
priority = po.get("priority")
|
priority = po.get("priority")
|
||||||
return {
|
return {
|
||||||
"style": style,
|
"style": style,
|
||||||
"use_emoji": bool(po.get("use_emoji", False)),
|
"use_emoji": bool(po.get("use_emoji", True)),
|
||||||
"include_hostname": bool(po.get("include_hostname", False)),
|
"include_hostname": bool(po.get("include_hostname", False)),
|
||||||
"notify_on_empty": bool(po.get("notify_on_empty", True)),
|
"notify_on_empty": bool(po.get("notify_on_empty", True)),
|
||||||
"priority": None if priority is None else int(priority),
|
"priority": None if priority is None else int(priority),
|
||||||
@@ -214,7 +214,10 @@ def debug_torrent_list(self):
|
|||||||
|
|
||||||
|
|
||||||
def _stat_row(pairs, style, use_emoji):
|
def _stat_row(pairs, style, use_emoji):
|
||||||
"""Render one line of ``Label value`` pairs joined by a middle dot.
|
"""Render one line of ``emoji value label`` pairs joined by a middle dot.
|
||||||
|
|
||||||
|
The value leads and the label trails, so the numbers are the first thing
|
||||||
|
the eye lands on even though the font is proportional.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pairs: Sequence of ``(emoji_key, label, value)`` tuples.
|
pairs: Sequence of ``(emoji_key, label, value)`` tuples.
|
||||||
@@ -222,31 +225,33 @@ def _stat_row(pairs, style, use_emoji):
|
|||||||
use_emoji: Prefix each pair with its emoji marker.
|
use_emoji: Prefix each pair with its emoji marker.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A single line of text, e.g. ``Removed 7 \u00b7 Orphaned 4``.
|
A single line of text, e.g.
|
||||||
|
``\U0001F5D1\uFE0F 7 removed \u00b7 \U0001F47B 4 orphaned``.
|
||||||
"""
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
for key, label, value in pairs:
|
for key, label, value in pairs:
|
||||||
emoji = NOTIFY_EMOJI.get(key, "") if use_emoji else ""
|
emoji = NOTIFY_EMOJI.get(key, "") if use_emoji else ""
|
||||||
prefix = f"{emoji} " if emoji else ""
|
prefix = f"{emoji} " if emoji else ""
|
||||||
if style == "html":
|
if style == "html":
|
||||||
parts.append(f"{prefix}<b>{label}</b> {value}")
|
parts.append(f"{prefix}<b>{value}</b> {label}")
|
||||||
else:
|
else:
|
||||||
parts.append(f"{prefix}{label} {value}")
|
parts.append(f"{prefix}{value} {label}")
|
||||||
return ROW_SEPARATOR.join(parts)
|
return ROW_SEPARATOR.join(parts)
|
||||||
|
|
||||||
|
|
||||||
def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False,
|
def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=True,
|
||||||
include_hostname=False):
|
include_hostname=False):
|
||||||
"""Build the ``(title, body)`` pair used by every notifier.
|
"""Build the ``(title, body)`` pair used by every notifier.
|
||||||
|
|
||||||
Result-first layout: the title states what happened, the body gives the
|
Result-first layout: the title states what happened, the body gives the
|
||||||
scanned total, the actionable counters, then the tracker split. Counters
|
scanned total, the actionable counters, then the tracker split. Counters
|
||||||
are grouped two-per-line and separated by a middle dot, because Pushover
|
are grouped two-per-line as ``emoji value label`` and separated by a middle
|
||||||
renders a proportional font and space-padded columns do not line up.
|
dot, because Pushover renders a proportional font and space-padded columns
|
||||||
|
do not line up.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
style: ``"plain"`` for grouped text, ``"html"`` for Pushover HTML.
|
style: ``"plain"`` for grouped text, ``"html"`` for Pushover HTML.
|
||||||
use_emoji: Prefix each row with an emoji marker.
|
use_emoji: Prefix each counter with an emoji marker.
|
||||||
include_hostname: Add a ``Host:`` line above the summary.
|
include_hostname: Add a ``Host:`` line above the summary.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -286,19 +291,19 @@ def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False,
|
|||||||
lines.append(f"Scanned {total} {scanned_noun} in {stats['runtime']}")
|
lines.append(f"Scanned {total} {scanned_noun} in {stats['runtime']}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
removed_label = "Removed" if delete_enabled else "Would remove"
|
removed_label = "removed" if delete_enabled else "would be removed"
|
||||||
lines.append(_stat_row([
|
lines.append(_stat_row([
|
||||||
("removed", removed_label, removed),
|
("removed", removed_label, removed),
|
||||||
("orphaned", "Orphaned", stats["orphaned"]),
|
("orphaned", "orphaned", stats["orphaned"]),
|
||||||
], style, use_emoji))
|
], style, use_emoji))
|
||||||
lines.append(_stat_row([
|
lines.append(_stat_row([
|
||||||
("ignored", "Ignored", stats["ignored"]),
|
("ignored", "ignored", stats["ignored"]),
|
||||||
("premature", "Premature", stats["premature"]),
|
("premature", "premature", stats["premature"]),
|
||||||
], style, use_emoji))
|
], style, use_emoji))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(_stat_row([
|
lines.append(_stat_row([
|
||||||
("protected", "Protected", stats["protected"]),
|
("protected", "protected", stats["protected"]),
|
||||||
("non_protected", "Non-protected", stats["non_protected"]),
|
("non_protected", "public", stats["non_protected"]),
|
||||||
], style, use_emoji))
|
], style, use_emoji))
|
||||||
|
|
||||||
return title, "\n".join(lines)
|
return title, "\n".join(lines)
|
||||||
|
|||||||
+49
-19
@@ -190,16 +190,28 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
self.assertEqual(stats["runtime"], "unknown")
|
self.assertEqual(stats["runtime"], "unknown")
|
||||||
|
|
||||||
def test_build_summary_plain(self):
|
def test_build_summary_plain(self):
|
||||||
title, body = build_summary(FakeQbt(), style="plain")
|
title, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
|
||||||
self.assertEqual(title, "qbit-maid: 7 torrents removed")
|
self.assertEqual(title, "qbit-maid: 7 torrents removed")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
body,
|
body,
|
||||||
"Scanned 42 torrents in 12s\n"
|
"Scanned 42 torrents in 12s\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Removed 7 \u00b7 Orphaned 4\n"
|
"7 removed \u00b7 4 orphaned\n"
|
||||||
"Ignored 5 \u00b7 Premature 3\n"
|
"5 ignored \u00b7 3 premature\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Protected 10 \u00b7 Non-protected 20",
|
"10 protected \u00b7 20 public",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_build_summary_plain_emoji(self):
|
||||||
|
_, body = build_summary(FakeQbt(), style="plain", use_emoji=True)
|
||||||
|
self.assertEqual(
|
||||||
|
body,
|
||||||
|
"Scanned 42 torrents in 12s\n"
|
||||||
|
"\n"
|
||||||
|
"\U0001F5D1\uFE0F 7 removed \u00b7 \U0001F47B 4 orphaned\n"
|
||||||
|
"\u23ED\uFE0F 5 ignored \u00b7 \U0001F423 3 premature\n"
|
||||||
|
"\n"
|
||||||
|
"\U0001F6E1\uFE0F 10 protected \u00b7 \U0001F310 20 public",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_build_summary_plain_has_no_leading_indent(self):
|
def test_build_summary_plain_has_no_leading_indent(self):
|
||||||
@@ -224,33 +236,47 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
self.assertEqual(row.count("\u00b7"), 1, row)
|
self.assertEqual(row.count("\u00b7"), 1, row)
|
||||||
|
|
||||||
|
def test_build_summary_value_leads_label(self):
|
||||||
|
"""The number comes first so it is the first thing the eye lands on."""
|
||||||
|
_, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
|
||||||
|
self.assertIn("7 removed", body)
|
||||||
|
self.assertIn("20 public", body)
|
||||||
|
self.assertNotIn("Removed 7", body)
|
||||||
|
self.assertNotIn("Non-protected", body)
|
||||||
|
|
||||||
def test_build_summary_html(self):
|
def test_build_summary_html(self):
|
||||||
title, body = build_summary(FakeQbt(), style="html")
|
title, body = build_summary(FakeQbt(), style="html")
|
||||||
self.assertEqual(title, "qbit-maid: 7 torrents removed")
|
self.assertEqual(title, "qbit-maid: 7 torrents removed")
|
||||||
self.assertIn("Scanned <b>42</b> torrents in <b>12s</b>", body)
|
self.assertIn("Scanned <b>42</b> torrents in <b>12s</b>", body)
|
||||||
self.assertIn("<b>Removed</b> 7", body)
|
self.assertIn("<b>7</b> removed", body)
|
||||||
self.assertIn("<b>Non-protected</b> 20", body)
|
self.assertIn("<b>20</b> public", body)
|
||||||
self.assertNotIn("---", title)
|
self.assertNotIn("---", title)
|
||||||
|
|
||||||
def test_build_summary_emoji(self):
|
def test_build_summary_emoji(self):
|
||||||
_, plain = build_summary(FakeQbt(), style="plain", use_emoji=True)
|
_, plain = build_summary(FakeQbt(), style="plain", use_emoji=True)
|
||||||
self.assertIn("\U0001F5D1\uFE0F Removed 7", plain)
|
self.assertIn("\U0001F5D1\uFE0F 7 removed", plain)
|
||||||
self.assertIn("\U0001F47B Orphaned 4", plain)
|
self.assertIn("\U0001F47B 4 orphaned", plain)
|
||||||
_, html = build_summary(FakeQbt(), style="html", use_emoji=True)
|
_, html = build_summary(FakeQbt(), style="html", use_emoji=True)
|
||||||
self.assertIn("\U0001F5D1\uFE0F <b>Removed</b> 7", html)
|
self.assertIn("\U0001F5D1\uFE0F <b>7</b> removed", html)
|
||||||
|
|
||||||
|
def test_build_summary_emoji_off_has_no_emoji(self):
|
||||||
|
_, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
|
||||||
|
for marker in ("\U0001F5D1", "\U0001F47B", "\u23ED", "\U0001F423",
|
||||||
|
"\U0001F6E1", "\U0001F310"):
|
||||||
|
self.assertNotIn(marker, body)
|
||||||
|
|
||||||
def test_build_summary_delete_disabled(self):
|
def test_build_summary_delete_disabled(self):
|
||||||
title, body = build_summary(FakeQbt(delete_torrents=False))
|
title, body = build_summary(FakeQbt(delete_torrents=False))
|
||||||
self.assertEqual(title, "qbit-maid: 7 torrents would be removed")
|
self.assertEqual(title, "qbit-maid: 7 torrents would be removed")
|
||||||
self.assertIn("Would remove 7", body)
|
self.assertIn("7 would be removed", 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("7 removed", body)
|
||||||
|
|
||||||
|
def test_build_summary_body_does_not_repeat_title_phrase(self):
|
||||||
|
"""The title carries the outcome; the body must not restate it verbatim."""
|
||||||
|
title, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
|
||||||
self.assertNotIn("torrents removed", body)
|
self.assertNotIn("torrents removed", body)
|
||||||
self.assertIn("Removed 7", body)
|
self.assertNotIn("torrents would be removed", body)
|
||||||
|
self.assertIn("7 removed", body)
|
||||||
self.assertTrue(title.endswith("7 torrents removed"))
|
self.assertTrue(title.endswith("7 torrents removed"))
|
||||||
|
|
||||||
def test_build_summary_nothing_to_do(self):
|
def test_build_summary_nothing_to_do(self):
|
||||||
@@ -276,16 +302,20 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
def test_get_notify_options_defaults(self):
|
def test_get_notify_options_defaults(self):
|
||||||
opts = get_notify_options(FakeQbt())
|
opts = get_notify_options(FakeQbt())
|
||||||
self.assertEqual(opts["style"], "plain")
|
self.assertEqual(opts["style"], "plain")
|
||||||
self.assertFalse(opts["use_emoji"])
|
self.assertTrue(opts["use_emoji"])
|
||||||
self.assertFalse(opts["include_hostname"])
|
self.assertFalse(opts["include_hostname"])
|
||||||
self.assertTrue(opts["notify_on_empty"])
|
self.assertTrue(opts["notify_on_empty"])
|
||||||
self.assertIsNone(opts["priority"])
|
self.assertIsNone(opts["priority"])
|
||||||
self.assertIsNone(opts["sound"])
|
self.assertIsNone(opts["sound"])
|
||||||
|
|
||||||
|
def test_get_notify_options_emoji_can_be_disabled(self):
|
||||||
|
opts = get_notify_options(FakeQbt(config={"pushover": {"use_emoji": False}}))
|
||||||
|
self.assertFalse(opts["use_emoji"])
|
||||||
|
|
||||||
def test_get_notify_options_overrides(self):
|
def test_get_notify_options_overrides(self):
|
||||||
fake = FakeQbt(config={"pushover": {
|
fake = FakeQbt(config={"pushover": {
|
||||||
"format": "HTML",
|
"format": "HTML",
|
||||||
"use_emoji": True,
|
"use_emoji": False,
|
||||||
"include_hostname": True,
|
"include_hostname": True,
|
||||||
"notify_on_empty": False,
|
"notify_on_empty": False,
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
@@ -295,7 +325,7 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
}})
|
}})
|
||||||
opts = get_notify_options(fake)
|
opts = get_notify_options(fake)
|
||||||
self.assertEqual(opts["style"], "html")
|
self.assertEqual(opts["style"], "html")
|
||||||
self.assertTrue(opts["use_emoji"])
|
self.assertFalse(opts["use_emoji"])
|
||||||
self.assertTrue(opts["include_hostname"])
|
self.assertTrue(opts["include_hostname"])
|
||||||
self.assertFalse(opts["notify_on_empty"])
|
self.assertFalse(opts["notify_on_empty"])
|
||||||
self.assertEqual(opts["priority"], 2)
|
self.assertEqual(opts["priority"], 2)
|
||||||
|
|||||||
Reference in new issue
Block a user