← Commit history
README.md+12−7
@@ -11,13 +11,17 @@ wiki notification email ──poll──> per-item state ──render──> ONE  The board is a single card holding a **table**: a colored STATUS rail column (OPEN green / CLOSED gray) beside a content column, one divider-ruled row per-issue. Every row stacks the same fields in the same order, one per line --**page + issue #**, title, **actor**, **origination date**, last activity,-and the **View link on the last line**. New activity bubbles to the top.+issue. The left column holds the **item ref** (`page #N`) with its colored+status beneath; the right column holds the **description** with the **View+link |'d at its end**, then `Created by <name> on <date> <time>`,+`<Event> by <name> on <date> <time>` (Opened / Commented / Closed /+Reopened / Mentioned), and **your involvement** - "Drew opened this",+"Drew was mentioned", "Drew watches this page" - parsed from the mail's+"You're receiving this because..." footer. New activity bubbles to the top. (Chat cards have no native table widget: the columns widget caps at 2 and-grid tiles are equal-width and unreadable, so the table is a 2-column layout-with one field per line; set `"style": "blocks"` for the compact per-item-layout with View buttons.) Open items stay on the board until resolved; when an item+grid tiles are equal-width and unreadable, so the table is this 2-column+layout; set `"style": "blocks"` for the compact per-item layout with View+buttons.) Open items stay on the board until resolved; when an item is closed it shows CLOSED - so you get the satisfaction of seeing it done - then drops off at **6am the next day** to keep the board fresh. @@ -104,7 +108,8 @@ inbox. A failed board post never sweeps the email; it retries next pass. | `timezone` | container local | IANA zone for dates + the purge | | `maxRows` | `40` | card row cap | | `boardTitle` | `Wiki Notifications` | card header |-| `style` | `table` | `table` (status rail + one field per line, divider-ruled rows) or `blocks` (per-item blocks with View buttons) |+| `style` | `table` | `table` (item ref + status left, details right, divider-ruled rows) or `blocks` (per-item blocks with View buttons) |+| `meName` | `You` | your first name, used in the involvement line ("Drew opened this") |  ## Commands 
bin/__pycache__/wiki-notify-gchatcpython-312.pyc
⋯ 1 unchanged line ⋯
bin/wiki-notify-gchat+62−20
@@ -213,22 +213,54 @@ def parse_notification(msg):         url = u         break     ts = int(msg.get("internalDate", "0"))+    reason = None+    rm = re.search(r"You're receiving this because (.+?)[.\n]", body)+    if rm:+        reason = rm.group(1).strip()     return {         "key": f"{page}#{num or title[:40]}",         "page": page, "num": num, "title": title.strip(),         "event": event, "status": status, "actor": actor, "url": url, "ts": ts,+        "reason": reason,     }  +def involvement_of(ev):+    """Rank the user's involvement from the mail's 'You're receiving this+    because ...' footer (and mention events). Higher rank wins on an item."""+    r = (ev.get("reason") or "").lower()+    if "assign" in r or "own" in r:+        return 4, "owns this fix"+    if "you opened" in r or "author" in r or "you created" in r:+        return 3, "opened this"+    if ev["event"] == "mention" or "mention" in r:+        return 2, "was mentioned"+    if "you commented" in r:+        return 2, "commented on this"+    if "you closed" in r:+        return 2, "closed this"+    if "watch" in r:+        return 1, "watches this page"+    if r:+        return 1, r+    return 0, ""++ def apply_event(items, ev):     it = items.get(ev["key"])     if not it:         it = {"page": ev["page"], "num": ev["num"], "title": ev["title"],               "status": "open", "openedAt": ev["ts"], "lastAt": 0,-              "lastEvent": "", "actor": ev["actor"] or "", "url": ev["url"] or "",-              "closedAt": None, "events": 0}+              "lastEvent": "", "actor": ev["actor"] or "", "creator": "",+              "url": ev["url"] or "", "closedAt": None, "events": 0,+              "invRank": 0, "invLabel": ""}         items[ev["key"]] = it     it["events"] += 1+    rank, label = involvement_of(ev)+    if rank > it.get("invRank", 0):+        it["invRank"], it["invLabel"] = rank, label+    if ev["ts"] <= it["openedAt"] and ev["actor"]:+        it["creator"] = ev["actor"]  # actor of the earliest event we have     it["openedAt"] = min(it["openedAt"], ev["ts"])     if ev["ts"] >= it["lastAt"]:         it["lastAt"] = ev["ts"]@@ -280,10 +312,11 @@ def render_card(items, cfg):     n_closed = len(rows) - n_open     max_rows = int(cfg.get("maxRows", 40))     shown, overflow = rows[:max_rows], max(0, len(rows) - max_rows)-    if cfg.get("style", "table") == "table":-        widgets = render_table_widgets(shown, tz)-    else:+    style = cfg.get("style", "table")+    if style == "blocks":         widgets = render_block_widgets(shown, tz)+    else:+        widgets = render_table_widgets(shown, tz, me=cfg.get("meName", "You"))     if overflow:         widgets.append({"textParagraph": {"text": f"<i>+{overflow} older item(s) not shown</i>"}})     if not shown:@@ -295,12 +328,17 @@ def render_card(items, cfg):     }}]}  -def render_table_widgets(shown, tz):-    """A table built from columns widgets: a narrow STATUS rail + a content-    column, one divider-ruled row per item. Each content cell is exactly two-    controlled lines (item + title, then meta ending in the View link) so-    nothing free-wraps into unreadable pipe soup. Chat cards have no native-    table widget; this is the closest readable equivalent."""+EVENT_CAP = {"opened": "Opened", "comment": "Commented", "mention": "Mentioned",+             "closed": "Closed", "reopened": "Reopened", "activity": "Activity"}+++def render_table_widgets(shown, tz, me="You"):+    """A table built from columns widgets, one divider-ruled row per item.+    Left column: the item ref (page + issue #) with the colored status+    beneath it. Right column: description with the View link |'d at its end,+    Created-by line, last-event line, and the user's involvement. Chat cards+    have no native table widget and the columns widget caps at 2; this is+    the closest readable equivalent."""     import html as html_mod     widgets = []     for i, it in enumerate(shown):@@ -313,20 +351,24 @@ def render_table_widgets(shown, tz):         title = it["title"][:110] + ("…" if len(it["title"]) > 110 else "")         title = html_mod.escape(title, quote=False)         dim = lambda s: f'<font color="#8a94a6">{s}</font>'+        creator = it.get("creator") or it.get("actor") or "-"+        if it.get("invLabel") == "opened this":+            creator = me  # the mail footer ('you opened the issue') outranks our earliest-email guess+        actor = it.get("actor") or "-"+        event = EVENT_CAP.get(it["lastEvent"], it["lastEvent"] or "Activity")+        view = f'<a href="{it["url"]}">View</a>' if it["url"] else dim("-")         fields = [-            f"<b>{item}</b>",-            title,-            dim(f"by {it['actor']}" if it["actor"] else "by -"),-            dim(f"opened {fmt_date(it['openedAt'], tz)}"),-            dim(f"last {EVENT_LABEL.get(it['lastEvent'], it['lastEvent'])} {fmt_datetime(it['lastAt'], tz)}"),-            f'<a href="{it["url"]}">View</a>' if it["url"] else dim("-"),+            f"{title} | {view}",+            dim(f"Created by {creator} on {fmt_datetime(it['openedAt'], tz)}"),+            dim(f"{event} by {actor} on {fmt_datetime(it['lastAt'], tz)}"),         ]-        content = "<br>".join(fields)+        if it.get("invLabel"):+            fields.append(dim(f"{me} {it['invLabel']}"))         widgets.append({"columns": {"columnItems": [             {"horizontalSizeStyle": "FILL_MINIMUM_SPACE", "verticalAlignment": "TOP",-             "widgets": [{"textParagraph": {"text": status}}]},+             "widgets": [{"textParagraph": {"text": f"<b>{item}</b><br>{status}"}}]},             {"horizontalSizeStyle": "FILL_AVAILABLE_SPACE", "verticalAlignment": "TOP",-             "widgets": [{"textParagraph": {"text": content}}]},+             "widgets": [{"textParagraph": {"text": "<br>".join(fields)}}]},         ]}})     return widgets 
docs/board.png
⋯ 1 unchanged line ⋯
docs/hero.png
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "wiki-notify-gchat",   "title": "Wiki Notify for Google Chat",   "type": "app",-  "version": "1.1.3",+  "version": "1.2.0",   "description": "Turn wiki.adom.inc notification emails into a live Google Chat board. One card, one row per issue - newest first, OPEN/CLOSED status, origination date, actor, and a View link - continuously updated in your own Chat space via your own webhook. Emails are swept out of your inbox (trash, archive, or label). Closed items linger until 6am the next day, then drop off. Delegates all auth to adom-google; the only secret is your webhook URL, kept in your private config.",   "tags": [     "cli",
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "wiki-notify-gchat",   "title": "Wiki Notify for Google Chat",   "type": "app",-  "version": "1.1.3",+  "version": "1.2.0",   "description": "Turn wiki.adom.inc notification emails into a live Google Chat board. One card, one row per issue - newest first, OPEN/CLOSED status, origination date, actor, and a View link - continuously updated in your own Chat space via your own webhook. Emails are swept out of your inbox (trash, archive, or label). Closed items linger until 6am the next day, then drop off. Delegates all auth to adom-google; the only secret is your webhook URL, kept in your private config.",   "tags": [     "cli",